HuiruHo commited on
Commit
cf9ec8f
·
verified ·
1 Parent(s): 2fb9a08

gradio-zerogpu-page-vithplus

Browse files
Files changed (5) hide show
  1. Dockerfile +0 -24
  2. README.md +22 -6
  3. app.py +26 -2
  4. app_gradio.py +378 -0
  5. requirements.txt +5 -1
Dockerfile DELETED
@@ -1,24 +0,0 @@
1
- FROM python:3.11-slim
2
-
3
- ENV PYTHONUNBUFFERED=1 \
4
- PIP_NO_CACHE_DIR=1 \
5
- PORT=7860 \
6
- SPACE_DEFAULT_HF_ONLY=1 \
7
- DEFAULT_MODEL_NAME=page_vitb_screen \
8
- PRELOAD_DEFAULT_MODEL=0
9
-
10
- WORKDIR /app
11
-
12
- RUN apt-get update \
13
- && apt-get install -y --no-install-recommends git \
14
- && rm -rf /var/lib/apt/lists/*
15
-
16
- COPY requirements.txt .
17
- RUN pip install --upgrade pip \
18
- && pip install -r requirements.txt
19
-
20
- COPY . .
21
-
22
- EXPOSE 7860
23
-
24
- CMD ["python", "app.py"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -3,8 +3,9 @@ title: CrossGaze
3
  emoji: 👀
4
  colorFrom: blue
5
  colorTo: purple
6
- sdk: docker
7
- app_port: 7860
 
8
  pinned: false
9
  ---
10
 
@@ -74,17 +75,32 @@ python app.py
74
  | `page_vitb_screen` | `Octopus1/page-vitb-screen` | PaGE ViT-B Screen |
75
  | `page_vitsplus` | `Octopus1/page-vitsplus` | PaGE ViT-S+ |
76
  | `page_vits` | `Octopus1/page-vits` | PaGE ViT-S |
 
77
 
78
  这些模型会通过 `AutoModel.from_pretrained(..., trust_remote_code=True)` 加载,不需要本地 `checkpoints/*.pt`。
79
 
80
  ## Hugging Face Space 部署
81
 
82
- 本项目在 Space 上使用 Docker SDK 部署。Space 环境默认设置
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  - `SPACE_DEFAULT_HF_ONLY=1`:只暴露 Hugging Face 模型,避免依赖本地 checkpoint。
85
- - `DEFAULT_MODEL_NAME=page_vitb_screen`:启动时预加载 `Octopus1/page-vitb-screen`。
86
- - `PRELOAD_DEFAULT_MODEL=0`:Space 上阻塞启动,模型在用户选择/推理加载。
87
- - `PORT=7860`:符合 Hugging Face Docker Space 默认入口
 
 
88
 
89
  如果 PaGE 模型仓库是 private,需要在 Space Settings → Variables and secrets 中添加 secret:
90
 
 
3
  emoji: 👀
4
  colorFrom: blue
5
  colorTo: purple
6
+ sdk: gradio
7
+ app_file: app_gradio.py
8
+ short_description: CrossGaze / PaGE gaze target estimation demo on ZeroGPU.
9
  pinned: false
10
  ---
11
 
 
75
  | `page_vitb_screen` | `Octopus1/page-vitb-screen` | PaGE ViT-B Screen |
76
  | `page_vitsplus` | `Octopus1/page-vitsplus` | PaGE ViT-S+ |
77
  | `page_vits` | `Octopus1/page-vits` | PaGE ViT-S |
78
+ | `page_vithplus` | `Octopus1/page-vithplus` | PaGE ViT-H+ |
79
 
80
  这些模型会通过 `AutoModel.from_pretrained(..., trust_remote_code=True)` 加载,不需要本地 `checkpoints/*.pt`。
81
 
82
  ## Hugging Face Space 部署
83
 
84
+ 本项目提供两套入口
85
+
86
+ - `app.py`:原 FastAPI + 静态网页版本,适合 Docker Space/本地服务。
87
+ - `app_gradio.py`:Gradio + ZeroGPU 版本,适合 Hugging Face ZeroGPU Space。
88
+
89
+ Space YAML 已配置为 Gradio:
90
+
91
+ ```yaml
92
+ sdk: gradio
93
+ app_file: app_gradio.py
94
+ ```
95
+
96
+ Gradio 版默认设置:
97
 
98
  - `SPACE_DEFAULT_HF_ONLY=1`:只暴露 Hugging Face 模型,避免依赖本地 checkpoint。
99
+ - `DEFAULT_MODEL_NAME=page_vithplus`:默认模型为 `Octopus1/page-vithplus`。
100
+ - `PRELOAD_DEFAULT_MODEL=0`:不启动时加载模型,避免阻塞 Space 启动
101
+ - `@spaces.GPU(duration=180)`:只在推理函数执行期间申请 ZeroGPU
102
+
103
+ 当前 Gradio 版使用文本输入 bbox:每行 `x1,y1,x2,y2`,支持 0~1 归一化坐标;可选第 5 列颜色,例如 `0.1,0.2,0.3,0.5,#ff7a00`。
104
 
105
  如果 PaGE 模型仓库是 private,需要在 Space Settings → Variables and secrets 中添加 secret:
106
 
app.py CHANGED
@@ -67,10 +67,15 @@ ALL_MODEL_OPTIONS = {
67
  "repo_id": "Octopus1/page-vits",
68
  "display_name": "PaGE ViT-S",
69
  },
 
 
 
 
 
70
  }
71
 
72
  HF_ONLY_MODE = os.environ.get("SPACE_DEFAULT_HF_ONLY", "").lower() in {"1", "true", "yes"} or bool(os.environ.get("SPACE_ID"))
73
- DEFAULT_MODEL_NAME = os.environ.get("DEFAULT_MODEL_NAME", "page_vitb_screen" if HF_ONLY_MODE else "cross_gaze_vitb_distill_screen")
74
  PRELOAD_DEFAULT_MODEL = os.environ.get("PRELOAD_DEFAULT_MODEL", "0" if HF_ONLY_MODE else "1").lower() in {"1", "true", "yes"}
75
  OUT_THRESHOLD = 0.5
76
  MAX_INFER_SIDE = 720
@@ -133,9 +138,28 @@ def _patch_factory_backbone_paths(model_name: str):
133
  class ModelManager:
134
  def __init__(self) -> None:
135
  self._cache: dict[str, tuple[torch.nn.Module, list, list | None]] = {}
136
- self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
  def get(self, model_name: str):
 
139
  cfg = MODEL_OPTIONS.get(model_name)
140
  if cfg is None:
141
  raise HTTPException(status_code=400, detail=f"Unsupported model: {model_name}")
 
67
  "repo_id": "Octopus1/page-vits",
68
  "display_name": "PaGE ViT-S",
69
  },
70
+ "page_vithplus": {
71
+ "source": "hf_page",
72
+ "repo_id": "Octopus1/page-vithplus",
73
+ "display_name": "PaGE ViT-H+",
74
+ },
75
  }
76
 
77
  HF_ONLY_MODE = os.environ.get("SPACE_DEFAULT_HF_ONLY", "").lower() in {"1", "true", "yes"} or bool(os.environ.get("SPACE_ID"))
78
+ DEFAULT_MODEL_NAME = os.environ.get("DEFAULT_MODEL_NAME", "page_vithplus" if HF_ONLY_MODE else "cross_gaze_vitb_distill_screen")
79
  PRELOAD_DEFAULT_MODEL = os.environ.get("PRELOAD_DEFAULT_MODEL", "0" if HF_ONLY_MODE else "1").lower() in {"1", "true", "yes"}
80
  OUT_THRESHOLD = 0.5
81
  MAX_INFER_SIDE = 720
 
138
  class ModelManager:
139
  def __init__(self) -> None:
140
  self._cache: dict[str, tuple[torch.nn.Module, list, list | None]] = {}
141
+ self.device = self._current_device()
142
+
143
+ def _current_device(self) -> torch.device:
144
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
145
+
146
+ def refresh_device(self) -> torch.device:
147
+ next_device = self._current_device()
148
+ if next_device != self.device:
149
+ self.device = next_device
150
+ for model, _, _ in self._cache.values():
151
+ model.to(self.device)
152
+ return self.device
153
+
154
+ def move_to_cpu(self) -> None:
155
+ self.device = torch.device("cpu")
156
+ for model, _, _ in self._cache.values():
157
+ model.to(self.device)
158
+ if torch.cuda.is_available():
159
+ torch.cuda.empty_cache()
160
 
161
  def get(self, model_name: str):
162
+ self.refresh_device()
163
  cfg = MODEL_OPTIONS.get(model_name)
164
  if cfg is None:
165
  raise HTTPException(status_code=400, detail=f"Unsupported model: {model_name}")
app_gradio.py ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ os.environ.setdefault("SPACE_DEFAULT_HF_ONLY", "1")
8
+ os.environ.setdefault("DEFAULT_MODEL_NAME", "page_vithplus")
9
+ os.environ.setdefault("PRELOAD_DEFAULT_MODEL", "0")
10
+ os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
11
+ os.environ.setdefault("NO_PROXY", "127.0.0.1,localhost")
12
+ os.environ.setdefault("no_proxy", "127.0.0.1,localhost")
13
+ os.environ.setdefault("RELEASE_GPU_AFTER_INFERENCE", "1")
14
+
15
+ import gradio as gr
16
+ import numpy as np
17
+ import torch
18
+ from PIL import Image
19
+
20
+ try:
21
+ import spaces
22
+ except Exception:
23
+ class _SpacesFallback:
24
+ @staticmethod
25
+ def GPU(*_args, **_kwargs):
26
+ def decorator(fn):
27
+ return fn
28
+
29
+ return decorator
30
+
31
+ spaces = _SpacesFallback()
32
+
33
+ from app import (
34
+ BBox,
35
+ DEFAULT_MODEL_NAME,
36
+ MODEL_OPTIONS,
37
+ OUT_THRESHOLD,
38
+ PersonSpec,
39
+ _clamp_bbox,
40
+ _downsample_for_inference,
41
+ _draw_combined_visualization,
42
+ _draw_multi_visualization,
43
+ _find_peak,
44
+ _make_head_inputs,
45
+ model_manager,
46
+ )
47
+
48
+
49
+ ROOT = Path(__file__).resolve().parent
50
+ EXAMPLES_DIR = ROOT / "static" / "examples"
51
+ MANIFEST_PATH = EXAMPLES_DIR / "manifest.json"
52
+
53
+
54
+ def _parse_bbox_line(line: str, width: int, height: int, default_color: str) -> PersonSpec:
55
+ fields = [field.strip() for field in line.replace("\t", ",").split(",") if field.strip()]
56
+ if len(fields) < 4:
57
+ raise ValueError(f"bbox needs at least 4 values: {line}")
58
+
59
+ values = [float(fields[i]) for i in range(4)]
60
+ color = fields[4] if len(fields) >= 5 else default_color
61
+ if all(0 <= value <= 1 for value in values):
62
+ x1, y1, x2, y2 = values
63
+ values = [x1 * width, y1 * height, x2 * width, y2 * height]
64
+
65
+ return PersonSpec(
66
+ bbox=BBox(x1=values[0], y1=values[1], x2=values[2], y2=values[3]),
67
+ color=color,
68
+ )
69
+
70
+
71
+ def _parse_people(bbox_text: str, width: int, height: int) -> list[PersonSpec]:
72
+ colors = [
73
+ "#00ff00",
74
+ "#ff7a00",
75
+ "#00a7ff",
76
+ "#ff4fd8",
77
+ "#ffd400",
78
+ "#8b5cff",
79
+ ]
80
+ lines = [
81
+ line.split("# ", 1)[0].strip()
82
+ for line in bbox_text.replace(";", "\n").splitlines()
83
+ ]
84
+ lines = [line for line in lines if line]
85
+ if not lines:
86
+ raise ValueError("Please enter at least one bbox: x1,y1,x2,y2. Normalized coordinates in [0,1] are supported.")
87
+ return [_parse_bbox_line(line, width, height, colors[idx % len(colors)]) for idx, line in enumerate(lines)]
88
+
89
+
90
+ def _to_pil_image(value) -> Image.Image | None:
91
+ if value is None:
92
+ return None
93
+ if isinstance(value, Image.Image):
94
+ return value.convert("RGB")
95
+ if isinstance(value, np.ndarray):
96
+ return Image.fromarray(value).convert("RGB")
97
+ if isinstance(value, (str, Path)):
98
+ return Image.open(value).convert("RGB")
99
+ return None
100
+
101
+
102
+ def _layer_to_bbox(layer, width: int, height: int, color: str) -> PersonSpec | None:
103
+ if layer is None:
104
+ return None
105
+ if isinstance(layer, Image.Image):
106
+ arr = np.array(layer.convert("RGBA"))
107
+ elif isinstance(layer, np.ndarray):
108
+ arr = layer
109
+ if arr.ndim == 2:
110
+ mask = arr > 0
111
+ ys, xs = np.where(mask)
112
+ if len(xs) == 0:
113
+ return None
114
+ return PersonSpec(bbox=BBox(x1=float(xs.min()), y1=float(ys.min()), x2=float(xs.max() + 1), y2=float(ys.max() + 1)), color=color)
115
+ if arr.shape[-1] == 3:
116
+ mask = np.any(arr[..., :3] > 0, axis=-1)
117
+ ys, xs = np.where(mask)
118
+ if len(xs) == 0:
119
+ return None
120
+ return PersonSpec(bbox=BBox(x1=float(xs.min()), y1=float(ys.min()), x2=float(xs.max() + 1), y2=float(ys.max() + 1)), color=color)
121
+ else:
122
+ return None
123
+
124
+ mask = arr[..., 3] > 0 if arr.shape[-1] >= 4 else np.any(arr[..., :3] > 0, axis=-1)
125
+ ys, xs = np.where(mask)
126
+ if len(xs) == 0:
127
+ return None
128
+
129
+ x1 = max(0, min(width - 1, int(xs.min())))
130
+ y1 = max(0, min(height - 1, int(ys.min())))
131
+ x2 = max(x1 + 1, min(width, int(xs.max()) + 1))
132
+ y2 = max(y1 + 1, min(height, int(ys.max()) + 1))
133
+ return PersonSpec(bbox=BBox(x1=x1, y1=y1, x2=x2, y2=y2), color=color)
134
+
135
+
136
+ def _image_and_people_from_editor(editor_value, bbox_text: str) -> tuple[Image.Image, list[PersonSpec], str]:
137
+ colors = ["#00ff00", "#ff7a00", "#00a7ff", "#ff4fd8", "#ffd400", "#8b5cff"]
138
+
139
+ if isinstance(editor_value, dict):
140
+ image = _to_pil_image(editor_value.get("background")) or _to_pil_image(editor_value.get("composite"))
141
+ if image is None:
142
+ raise gr.Error("Please upload or choose an image first.")
143
+
144
+ people: list[PersonSpec] = []
145
+ for idx, layer in enumerate(editor_value.get("layers") or []):
146
+ spec = _layer_to_bbox(layer, image.width, image.height, colors[idx % len(colors)])
147
+ if spec is not None:
148
+ people.append(spec)
149
+
150
+ if people:
151
+ return image, people, "editor"
152
+ return image, _parse_people(bbox_text, image.width, image.height), "text"
153
+
154
+ image = _to_pil_image(editor_value)
155
+ if image is None:
156
+ raise gr.Error("Please upload or choose an image first.")
157
+ return image, _parse_people(bbox_text, image.width, image.height), "text"
158
+
159
+
160
+ def _run_one(
161
+ pil_image: Image.Image,
162
+ model_name: str,
163
+ spec: PersonSpec,
164
+ show_heatmap: bool,
165
+ show_gaze: bool,
166
+ ):
167
+ original_width, original_height = pil_image.size
168
+ pil_image, image_scale = _downsample_for_inference(pil_image)
169
+ width, height = pil_image.size
170
+
171
+ bbox_obj = spec.bbox
172
+ if image_scale != 1.0:
173
+ bbox_obj = BBox(
174
+ x1=bbox_obj.x1 * image_scale,
175
+ y1=bbox_obj.y1 * image_scale,
176
+ x2=bbox_obj.x2 * image_scale,
177
+ y2=bbox_obj.y2 * image_scale,
178
+ )
179
+ bbox_px = _clamp_bbox(bbox_obj, width, height)
180
+ bbox_norm = [bbox_px[0] / width, bbox_px[1] / height, bbox_px[2] / width, bbox_px[3] / height]
181
+
182
+ model, scene_transforms, head_transforms = model_manager.get(model_name)
183
+ scene_inputs = [transform(pil_image).unsqueeze(0).to(model_manager.device) for transform in scene_transforms]
184
+ head_inputs = _make_head_inputs(pil_image, bbox_px, head_transforms)
185
+ if head_inputs is not None:
186
+ head_inputs = [tensor.to(model_manager.device) for tensor in head_inputs]
187
+
188
+ with torch.inference_mode():
189
+ output = model({
190
+ "images": scene_inputs,
191
+ "head_images": head_inputs,
192
+ "bboxes": [[tuple(bbox_norm)]],
193
+ })
194
+
195
+ heatmap = output["heatmap"][0][0]
196
+ inout_score = float(output["inout"][0][0].detach().cpu().item()) if output.get("inout") is not None else 1.0
197
+ is_out = inout_score < OUT_THRESHOLD
198
+ target_x, target_y, peak_score = _find_peak(heatmap, width, height)
199
+ combined = _draw_combined_visualization(
200
+ pil_image,
201
+ bbox_px,
202
+ bbox_norm,
203
+ heatmap,
204
+ (target_x, target_y),
205
+ is_out,
206
+ show_heatmap=show_heatmap,
207
+ show_gaze=show_gaze,
208
+ )
209
+ return {
210
+ "bbox_px": bbox_px,
211
+ "bbox_norm": bbox_norm,
212
+ "heatmap": heatmap,
213
+ "target_xy": (target_x, target_y),
214
+ "is_out": is_out,
215
+ "result": {
216
+ "bbox": {"x1": bbox_px[0], "y1": bbox_px[1], "x2": bbox_px[2], "y2": bbox_px[3]},
217
+ "target": None if is_out else {"x": target_x, "y": target_y, "score": peak_score},
218
+ "inout_score": inout_score,
219
+ "is_out": is_out,
220
+ "image_size": {"width": width, "height": height},
221
+ "original_image_size": {"width": original_width, "height": original_height},
222
+ },
223
+ "combined": combined,
224
+ }
225
+
226
+
227
+ @spaces.GPU(duration=180)
228
+ def run_inference(
229
+ image_editor,
230
+ bbox_text: str,
231
+ model_name: str,
232
+ show_heatmap: bool,
233
+ show_gaze: bool,
234
+ ):
235
+ try:
236
+ pil_image, people, bbox_source = _image_and_people_from_editor(image_editor, bbox_text)
237
+ if model_name not in MODEL_OPTIONS:
238
+ raise gr.Error(f"Unsupported model: {model_name}")
239
+
240
+ if len(people) == 1:
241
+ item = _run_one(pil_image, model_name, people[0], show_heatmap, show_gaze)
242
+ return item["combined"], json.dumps({
243
+ "model_name": model_name,
244
+ "device": str(model_manager.device),
245
+ "bbox_source": bbox_source,
246
+ **item["result"],
247
+ }, ensure_ascii=False, indent=2)
248
+
249
+ downsampled, _ = _downsample_for_inference(pil_image)
250
+ width, height = downsampled.size
251
+
252
+ bbox_px_list = []
253
+ bbox_norm_list = []
254
+ heatmaps = []
255
+ target_xy_list = []
256
+ is_out_list = []
257
+ colors = []
258
+ results = []
259
+
260
+ for idx, spec in enumerate(people):
261
+ item = _run_one(pil_image, model_name, spec, False, show_gaze)
262
+ bbox_px_list.append(item["bbox_px"])
263
+ bbox_norm_list.append(item["bbox_norm"])
264
+ heatmaps.append(item["heatmap"])
265
+ target_xy_list.append(item["target_xy"])
266
+ is_out_list.append(item["is_out"])
267
+ colors.append(spec.color)
268
+ results.append({"index": idx, "color": spec.color, **item["result"]})
269
+
270
+ combined = _draw_multi_visualization(
271
+ downsampled,
272
+ bbox_px_list,
273
+ bbox_norm_list,
274
+ heatmaps,
275
+ target_xy_list,
276
+ is_out_list,
277
+ colors,
278
+ show_heatmap=show_heatmap,
279
+ show_gaze=show_gaze,
280
+ )
281
+ return combined, json.dumps({
282
+ "model_name": model_name,
283
+ "device": str(model_manager.device),
284
+ "bbox_source": bbox_source,
285
+ "people": results,
286
+ "image_size": {"width": width, "height": height},
287
+ "original_image_size": {"width": pil_image.width, "height": pil_image.height},
288
+ }, ensure_ascii=False, indent=2)
289
+ finally:
290
+ if os.environ.get("RELEASE_GPU_AFTER_INFERENCE", "1").lower() in {"1", "true", "yes"}:
291
+ model_manager.move_to_cpu()
292
+
293
+
294
+ def _load_examples() -> list[list[str]]:
295
+ if not MANIFEST_PATH.exists():
296
+ return []
297
+ items = json.loads(MANIFEST_PATH.read_text())
298
+ examples = []
299
+ for item in items[:8]:
300
+ image_path = ROOT / item["image"].lstrip("/")
301
+ if image_path.exists():
302
+ examples.append([
303
+ str(image_path),
304
+ "0.35,0.15,0.60,0.55",
305
+ DEFAULT_MODEL_NAME,
306
+ True,
307
+ True,
308
+ ])
309
+ return examples
310
+
311
+
312
+ def build_demo() -> gr.Blocks:
313
+ model_choices = [
314
+ (cfg.get("display_name", name), name)
315
+ for name, cfg in MODEL_OPTIONS.items()
316
+ ]
317
+
318
+ with gr.Blocks(title="CrossGaze ZeroGPU Demo") as demo:
319
+ gr.Markdown(
320
+ """
321
+ # CrossGaze / PaGE ZeroGPU Demo
322
+
323
+ Upload an image, enter one or more head/face bounding boxes, and run gaze target inference.
324
+ BBox format: `x1,y1,x2,y2` per line. If all four values are in `[0,1]`, they are treated as normalized coordinates.
325
+ Optional fifth value sets color, e.g. `0.1,0.2,0.3,0.5,#ff7a00`.
326
+ """
327
+ )
328
+
329
+ with gr.Row():
330
+ with gr.Column(scale=1):
331
+ image_input = gr.ImageEditor(
332
+ type="pil",
333
+ label="Input image / draw head region",
334
+ image_mode="RGBA",
335
+ sources=["upload", "clipboard"],
336
+ brush=gr.Brush(default_size=24),
337
+ eraser=gr.Eraser(default_size=24),
338
+ layers=True,
339
+ )
340
+ bbox_input = gr.Textbox(
341
+ label="BBox coordinates (fallback / precise input)",
342
+ value="0.35,0.15,0.60,0.55",
343
+ lines=5,
344
+ placeholder="0.35,0.15,0.60,0.55\n0.12,0.20,0.30,0.48,#ff7a00",
345
+ )
346
+ model_input = gr.Dropdown(
347
+ choices=model_choices,
348
+ value=DEFAULT_MODEL_NAME,
349
+ label="Model",
350
+ )
351
+ show_heatmap = gr.Checkbox(value=True, label="Show heatmap")
352
+ show_gaze = gr.Checkbox(value=True, label="Show gaze point and line")
353
+ run_button = gr.Button("Run inference", variant="primary")
354
+
355
+ with gr.Column(scale=1):
356
+ output_image = gr.Image(type="pil", label="Result")
357
+ output_json = gr.Code(label="Result JSON", language="json")
358
+
359
+ gr.Examples(
360
+ examples=_load_examples(),
361
+ inputs=[image_input, bbox_input, model_input, show_heatmap, show_gaze],
362
+ label="Examples",
363
+ )
364
+
365
+ run_button.click(
366
+ run_inference,
367
+ inputs=[image_input, bbox_input, model_input, show_heatmap, show_gaze],
368
+ outputs=[output_image, output_json],
369
+ )
370
+
371
+ return demo
372
+
373
+
374
+ demo = build_demo()
375
+
376
+
377
+ if __name__ == "__main__":
378
+ demo.launch()
requirements.txt CHANGED
@@ -1,6 +1,10 @@
1
- fastapi
 
2
  uvicorn
3
  python-multipart
 
 
 
4
  # Model runtime deps (versions aligned with the gazelle env)
5
  torch==2.10.0
6
  torchvision==0.25.0
 
1
+ fastapi==0.115.11
2
+ starlette==0.46.1
3
  uvicorn
4
  python-multipart
5
+ gradio==4.44.1
6
+ spaces
7
+ huggingface-hub>=0.34.0,<1.0
8
  # Model runtime deps (versions aligned with the gazelle env)
9
  torch==2.10.0
10
  torchvision==0.25.0