anonymous-IA commited on
Commit
4d03be3
Β·
verified Β·
1 Parent(s): 5082dac

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +36 -13
  2. app.py +608 -0
  3. requirements.txt +14 -0
README.md CHANGED
@@ -1,13 +1,36 @@
1
- ---
2
- title: GazeAlign
3
- emoji: πŸ“Š
4
- colorFrom: pink
5
- colorTo: gray
6
- sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: GazeAlign
3
+ emoji: πŸ‘οΈ
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: "4.44.0"
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # GazeAlign
14
+
15
+ Gaze-supervised medical image classification.
16
+
17
+ **How to use**
18
+
19
+ 1. **Upload an image** β€” JPG / PNG / BMP / TIFF / WEBP or a **DICOM** (`.dcm`).
20
+ 2. **Add fixations** (a radiologist-style scanpath) in either of two ways:
21
+ - **Click** on the image to drop fixation points, or
22
+ - **Upload a fixation table** (`.csv` / `.xlsx` / `.xls`) and map its
23
+ `ID / X / Y / Time` columns β€” X/Y may be raw pixels or normalised `[0,1]`.
24
+ 3. **Run** to get the **predicted class** with per-class probabilities,
25
+ plus the learned gaze-conditioned attention mask/overlay.
26
+
27
+ **Model weights**
28
+
29
+ The demo loads the checkpoint declared by the `cxr` preset in
30
+ `configs/presets.yaml` (default `checkpoints/best_model_CXR.pth`). The
31
+ weights are not committed to the GitHub repo (too large); add them to this
32
+ Space β€” e.g. track `checkpoints/*.pth` with Git LFS, or download them in a
33
+ startup step β€” so `checkpoints/best_model_CXR.pth` exists at launch.
34
+
35
+ See the [GitHub repository](https://github.com/MohammedOussamaBEN/GazeAlign)
36
+ for training code, evaluation scripts, and the paper.
app.py ADDED
@@ -0,0 +1,608 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio demo for GazeAlign β€” gaze-supervised medical image classification.
3
+
4
+ Workflow
5
+ --------
6
+ 1. Upload an image (JPG / PNG / BMP / TIFF / WEBP / DICOM).
7
+ 2. Provide a radiologist-style scanpath in **either** of two ways:
8
+ β€’ click on the image to drop fixation points, or
9
+ β€’ upload a fixation table (.csv / .xlsx / .xls) and map its columns.
10
+ 3. Run the model to get the predicted class (+ per-class probabilities)
11
+ and the learned gaze-conditioned attention mask.
12
+
13
+ Run locally with: python app.py
14
+ Deployed as a HuggingFace Space, this file is the entry point.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ import types
21
+ import os
22
+ from pathlib import Path
23
+
24
+ # ── 1. audioop shim (Python 3.13 removed audioop; some deps import it) ────────
25
+ if sys.version_info >= (3, 13):
26
+ for _mod in ("audioop", "pyaudioop"):
27
+ if _mod not in sys.modules:
28
+ sys.modules[_mod] = types.ModuleType(_mod)
29
+
30
+ # ── 2. Patch starlette Jinja2Templates.TemplateResponse (old/new signature) ──
31
+ import starlette.templating as _st
32
+
33
+ _orig_TR = _st.Jinja2Templates.TemplateResponse
34
+
35
+
36
+ def _compat_TR(self, *args, **kwargs):
37
+ if args and isinstance(args[0], str) and len(args) >= 2 and isinstance(args[1], dict):
38
+ name = args[0]
39
+ context = args[1]
40
+ status_code = args[2] if len(args) > 2 else kwargs.get("status_code", 200)
41
+ headers = kwargs.get("headers")
42
+ media_type = kwargs.get("media_type")
43
+ background = kwargs.get("background")
44
+ template = self.get_template(name)
45
+ return _st._TemplateResponse(
46
+ template, context,
47
+ status_code=status_code,
48
+ headers=headers,
49
+ media_type=media_type,
50
+ background=background,
51
+ )
52
+ return _orig_TR(self, *args, **kwargs)
53
+
54
+
55
+ _st.Jinja2Templates.TemplateResponse = _compat_TR # type: ignore[method-assign]
56
+
57
+ import gradio as gr
58
+
59
+ # ── 3. gradio_client schema shim (guards against bad additionalProperties) ───
60
+ try:
61
+ import gradio_client.utils as _gcu
62
+
63
+ _orig_inner = _gcu._json_schema_to_python_type
64
+
65
+ def _safe_inner(schema, defs=None):
66
+ if not isinstance(schema, dict):
67
+ return "Any"
68
+ if not isinstance(schema.get("additionalProperties"), dict):
69
+ schema = {k: v for k, v in schema.items() if k != "additionalProperties"}
70
+ return _orig_inner(schema, defs)
71
+
72
+ _gcu._json_schema_to_python_type = _safe_inner
73
+ except Exception:
74
+ pass
75
+
76
+ # ── 4. huggingface_hub HfFolder shim (removed in newer versions) ─────────────
77
+ try:
78
+ from huggingface_hub import HfFolder # noqa: F401
79
+ except ImportError:
80
+ import huggingface_hub as _hfh
81
+
82
+ class _FakeHfFolder:
83
+ @staticmethod
84
+ def get_token():
85
+ return None
86
+
87
+ _hfh.HfFolder = _FakeHfFolder # type: ignore[attr-defined]
88
+ sys.modules["huggingface_hub"].HfFolder = _FakeHfFolder # type: ignore[assignment]
89
+
90
+ import numpy as np
91
+ import pandas as pd
92
+ import torch
93
+ from PIL import Image, ImageDraw
94
+
95
+ # ── 5. Path setup β€” make the repo root importable ────────────────────────────
96
+ _here = Path(__file__).resolve().parent
97
+ for _candidate in [_here] + list(_here.parents):
98
+ _s = str(_candidate)
99
+ if _s not in sys.path:
100
+ sys.path.insert(0, _s)
101
+
102
+ from GazeAlign import get_device, get_scanpath # noqa: E402
103
+ from GazeAlign.visualize import heatmap_to_image, make_overlay, patch_to_image # noqa: E402
104
+ from scripts.predict_single import GazeAlignPredictor # noqa: E402
105
+
106
+ # ─────────────────────────────────────────────────────────────────────────────
107
+ # Config
108
+ # ─────────────────────────────────────────────────────────────────────────────
109
+
110
+ PRESETS_PATH = os.path.join(os.path.dirname(__file__), "..", "configs", "presets.yaml")
111
+ # Friendly label β†’ preset key in configs/presets.yaml. Add rows here as you
112
+ # train GazeAlign on new modalities.
113
+ PRESETS = {
114
+ "Chest X-ray β€” CHF / Normal / Pneumonia": "cxr",
115
+ }
116
+ POINT_COLORS = ["#ff3b30", "#ff9500", "#ffcc00", "#34c759", "#5ac8fa", "#007aff", "#af52de"]
117
+ _NO_COL = "β€” none β€”"
118
+
119
+ _UPLOAD_LABEL = "Drop / click to load .jpg .png .bmp .tif .tiff .webp .dcm"
120
+ _FIXATION_LABEL = "Click to place fixations"
121
+ _FIXFILE_LABEL = "Upload fixation file (.csv / .xlsx / .xls) β€” optional"
122
+
123
+ _DEVICE = get_device()
124
+ _PREDICTORS: dict[str, GazeAlignPredictor] = {}
125
+
126
+
127
+ def get_predictor(preset_key: str) -> GazeAlignPredictor:
128
+ """Lazily build & cache one predictor per preset."""
129
+ if preset_key not in _PREDICTORS:
130
+ _PREDICTORS[preset_key] = GazeAlignPredictor.from_preset(
131
+ preset_key, presets_path=PRESETS_PATH, device=str(_DEVICE)
132
+ )
133
+ return _PREDICTORS[preset_key]
134
+
135
+
136
+ # ─────────────────────────────────────────────────────────────────────────────
137
+ # Helpers
138
+ # ─────────────────────────────────────────────────────────────────────────────
139
+
140
+
141
+ def dcm_to_pil(dcm_path: str) -> Image.Image:
142
+ """Load a DICOM file and return an RGB PIL image."""
143
+ import pydicom
144
+
145
+ dcm = pydicom.dcmread(dcm_path)
146
+ arr = dcm.pixel_array.astype(np.float32)
147
+ arr = arr - arr.min()
148
+ arr = arr / (arr.max() + 1e-8)
149
+ arr = (arr * 255).astype(np.uint8)
150
+ if arr.ndim == 2:
151
+ return Image.fromarray(arr, mode="L").convert("RGB")
152
+ if arr.ndim == 3 and arr.shape[0] in (1, 3, 4): # (C, H, W) β†’ (H, W, C)
153
+ arr = arr.transpose(1, 2, 0)
154
+ return Image.fromarray(arr).convert("RGB")
155
+
156
+
157
+ def draw_points(image: Image.Image, points: list) -> Image.Image:
158
+ """Overlay fixation circles + connecting saccades on a copy of `image`.
159
+
160
+ `points`: list of (x_px, y_px, weight) in original-image pixel coords,
161
+ `weight` in [0, 1] (relative dwell / recency, controls circle size).
162
+ """
163
+ if image is None:
164
+ return None
165
+ vis = image.convert("RGB").copy()
166
+ draw = ImageDraw.Draw(vis)
167
+ w, h = vis.size
168
+ r = max(6, min(w, h) // 80)
169
+ prev = None
170
+ for (x_px, y_px, _weight) in points:
171
+ color = POINT_COLORS[0]
172
+ if prev is not None:
173
+ draw.line([prev, (x_px, y_px)], fill=color, width=2)
174
+ prev = (x_px, y_px)
175
+ for i, (x_px, y_px, weight) in enumerate(points):
176
+ color = POINT_COLORS[i % len(POINT_COLORS)]
177
+ rad = r * (0.6 + 0.8 * float(weight))
178
+ draw.ellipse(
179
+ [x_px - rad, y_px - rad, x_px + rad, y_px + rad],
180
+ outline=color, width=3,
181
+ )
182
+ draw.text((x_px + rad + 2, y_px - rad), str(i + 1), fill=color)
183
+ return vis
184
+
185
+
186
+ def read_table(path: str) -> pd.DataFrame:
187
+ """Load a .csv / .xlsx / .xls fixation file into a DataFrame."""
188
+ ext = Path(path).suffix.lower()
189
+ if ext in (".xlsx", ".xls"):
190
+ return pd.read_excel(path)
191
+ # Sniff delimiter β€” eye-tracker exports are sometimes tab-separated
192
+ # even with a .csv extension.
193
+ return pd.read_csv(path, sep=None, engine="python")
194
+
195
+
196
+ def normalize_xy(x_vals: np.ndarray, y_vals: np.ndarray, img_w: int, img_h: int):
197
+ """Convert X/Y column values to pixel coords for the given image size.
198
+
199
+ Values already in [0, 1] (with rounding slack) are treated as
200
+ normalised; otherwise they are assumed to be raw pixels and clamped to
201
+ the image bounds.
202
+ """
203
+ looks_normalized = (
204
+ np.nanmax(x_vals) <= 1.05 and np.nanmax(y_vals) <= 1.05
205
+ and np.nanmin(x_vals) >= -0.05 and np.nanmin(y_vals) >= -0.05
206
+ )
207
+ if looks_normalized:
208
+ x_px = np.clip(x_vals, 0, 1) * img_w
209
+ y_px = np.clip(y_vals, 0, 1) * img_h
210
+ else:
211
+ x_px = np.clip(x_vals, 0, img_w)
212
+ y_px = np.clip(y_vals, 0, img_h)
213
+ return x_px, y_px
214
+
215
+
216
+ def _resolve_path(file_obj):
217
+ """Extract a filesystem path from whatever gr.File passes."""
218
+ if isinstance(file_obj, str):
219
+ return file_obj
220
+ if isinstance(file_obj, dict):
221
+ return file_obj.get("name") or file_obj.get("path") or file_obj.get("tmp_path") or ""
222
+ if hasattr(file_obj, "name"):
223
+ return file_obj.name
224
+ return ""
225
+
226
+
227
+ # ─────────────────────────────────────────────────────────────────────────────
228
+ # Event handlers β€” image
229
+ # ─────────────────────────────────────────────────────────────────────────────
230
+
231
+
232
+ def on_file_upload(file_obj):
233
+ """Load any image or DICOM and switch the panel to fixation-click mode."""
234
+ _no_change = (None, [], "", gr.update(), gr.update(), gr.update())
235
+ if file_obj is None:
236
+ return _no_change
237
+
238
+ path = _resolve_path(file_obj)
239
+ if not path:
240
+ gr.Warning("Could not resolve file path.")
241
+ return _no_change
242
+
243
+ image_name = Path(path).name
244
+ ext = Path(path).suffix.lower()
245
+ try:
246
+ pil = dcm_to_pil(path) if ext == ".dcm" else Image.open(path).convert("RGB")
247
+ except Exception as e: # noqa: BLE001
248
+ gr.Warning(f"Could not load file: {e}")
249
+ return _no_change
250
+
251
+ return (
252
+ pil, # orig_image_state
253
+ [], # points_state
254
+ image_name, # image_name_state
255
+ gr.update(visible=False), # upload_zone β†’ hide
256
+ gr.update(value=pil, visible=True, label=_FIXATION_LABEL), # image_panel β†’ show
257
+ gr.update(visible=True), # delete_btn β†’ show
258
+ )
259
+
260
+
261
+ def on_select(orig_image: Image.Image, points: list, weight: float, evt: gr.SelectData):
262
+ """Record a fixation click in original-image pixel coords."""
263
+ if orig_image is None:
264
+ gr.Warning("Upload an image first.")
265
+ return points, gr.update()
266
+ x_px, y_px = float(evt.index[0]), float(evt.index[1])
267
+ new_points = points + [(x_px, y_px, float(weight))]
268
+ return new_points, draw_points(orig_image, new_points)
269
+
270
+
271
+ def on_clear(orig_image):
272
+ """Remove all fixations but keep the current image."""
273
+ if orig_image is None:
274
+ return [], gr.update()
275
+ return [], gr.update(value=orig_image)
276
+
277
+
278
+ def on_delete():
279
+ """Delete the current image and return to upload mode."""
280
+ return (
281
+ None, # orig_image_state
282
+ [], # points_state
283
+ "", # image_name_state
284
+ gr.update(value=None, visible=True), # upload_zone β†’ show (reset)
285
+ gr.update(value=None, visible=False), # image_panel β†’ hide
286
+ gr.update(visible=False), # delete_btn β†’ hide
287
+ )
288
+
289
+
290
+ # ─────────────────────────────────────────────────────────────────────────────
291
+ # Event handlers β€” fixation file
292
+ # ─────────────────────────────────────────────────────────────────────────────
293
+
294
+
295
+ def on_fixfile_upload(file_obj):
296
+ """Load the fixation table and populate the column-mapping dropdowns."""
297
+ _hide = (
298
+ None, gr.update(visible=False),
299
+ gr.update(choices=[], value=None), gr.update(choices=[], value=None),
300
+ gr.update(choices=[], value=None), gr.update(choices=[], value=None),
301
+ gr.update(visible=False),
302
+ )
303
+ if file_obj is None:
304
+ return _hide
305
+
306
+ path = _resolve_path(file_obj)
307
+ if not path:
308
+ gr.Warning("Could not resolve fixation file path.")
309
+ return _hide
310
+
311
+ try:
312
+ df = read_table(path)
313
+ except Exception as e: # noqa: BLE001
314
+ gr.Warning(f"Could not read fixation file: {e}")
315
+ return _hide
316
+
317
+ if df.empty or len(df.columns) == 0:
318
+ gr.Warning("Fixation file appears to be empty.")
319
+ return _hide
320
+
321
+ cols = [str(c) for c in df.columns]
322
+
323
+ def _guess(*keywords, fallback=None):
324
+ # Priority-ordered: try each keyword across ALL columns before moving
325
+ # to the next, so e.g. "dicom" wins over a stray "id" in "SESSION_ID".
326
+ for k in keywords:
327
+ for c in cols:
328
+ if k in c.lower():
329
+ return c
330
+ return fallback if fallback is not None else cols[0]
331
+
332
+ guess_id = _guess("dicom", "image", "id", "name", "file", fallback=cols[0])
333
+ guess_x = _guess("x_original", "x_orig", "fix_x", "pos_x", "gaze_x", "x_pixel", fallback=None)
334
+ if guess_x is None:
335
+ guess_x = next(
336
+ (c for c in cols if c.lower().rstrip("_").endswith("x") and "index" not in c.lower()),
337
+ cols[0],
338
+ )
339
+ guess_y = _guess("y_original", "y_orig", "fix_y", "pos_y", "gaze_y", "y_pixel", fallback=None)
340
+ if guess_y is None:
341
+ guess_y = next(
342
+ (c for c in cols if c.lower().rstrip("_").endswith("y") and "index" not in c.lower()),
343
+ cols[0],
344
+ )
345
+
346
+ time_choices = [_NO_COL] + cols
347
+ guess_time = _guess("time", "secs", "duration", "dur", "timestamp", fallback=_NO_COL)
348
+
349
+ return (
350
+ df.to_json(), # fixfile_df_state
351
+ gr.update(visible=True), # mapping_row β†’ show
352
+ gr.update(choices=cols, value=guess_id), # id_col_dd
353
+ gr.update(choices=cols, value=guess_x), # x_col_dd
354
+ gr.update(choices=cols, value=guess_y), # y_col_dd
355
+ gr.update(choices=time_choices, value=guess_time), # time_col_dd
356
+ gr.update(visible=True), # apply_fix_btn β†’ show
357
+ )
358
+
359
+
360
+ def on_apply_fixfile(fixfile_json, id_col, x_col, y_col, time_col, orig_image, image_name):
361
+ """Match rows to the loaded image (by filename) and load them as
362
+ fixation points, replacing whatever points are currently set.
363
+
364
+ If rows can't be matched by filename but the file holds a single image's
365
+ worth of fixations, all rows are used (handy for single-image CSVs whose
366
+ ID column doesn't match the uploaded filename)."""
367
+ if orig_image is None:
368
+ gr.Warning("Load an image first, then apply the fixation file.")
369
+ return gr.update(), gr.update()
370
+ if not fixfile_json:
371
+ gr.Warning("Upload a fixation file first.")
372
+ return gr.update(), gr.update()
373
+ if not x_col or not y_col:
374
+ gr.Warning("Pick the X and Y columns first.")
375
+ return gr.update(), gr.update()
376
+
377
+ df = pd.read_json(fixfile_json)
378
+
379
+ sub = df
380
+ if id_col and image_name:
381
+ mask = df[id_col].astype(str) == image_name
382
+ if not mask.any():
383
+ stem = Path(image_name).stem
384
+ mask = df[id_col].astype(str).apply(lambda v: Path(str(v)).stem) == stem
385
+ if mask.any():
386
+ sub = df[mask]
387
+ elif df[id_col].nunique() > 1:
388
+ gr.Warning(
389
+ f"No rows match the loaded image ('{image_name}') and the file "
390
+ f"has several ids β€” using ALL rows. Check the ID column."
391
+ )
392
+
393
+ if sub.empty:
394
+ gr.Warning("No usable fixation rows found.")
395
+ return gr.update(), gr.update()
396
+
397
+ w, h = orig_image.size
398
+ x_vals = sub[x_col].astype(float).to_numpy()
399
+ y_vals = sub[y_col].astype(float).to_numpy()
400
+ x_px, y_px = normalize_xy(x_vals, y_vals, w, h)
401
+
402
+ if time_col and time_col != _NO_COL and time_col in sub.columns:
403
+ t_raw = sub[time_col].astype(float).to_numpy()
404
+ order = np.argsort(t_raw) # chronological order
405
+ x_px, y_px, t_raw = x_px[order], y_px[order], t_raw[order]
406
+ tmin, tmax = float(np.nanmin(t_raw)), float(np.nanmax(t_raw))
407
+ weight = (t_raw - tmin) / (tmax - tmin + 1e-8)
408
+ else:
409
+ weight = np.linspace(0.0, 1.0, len(sub))
410
+
411
+ new_points = [(float(xp), float(yp), float(wt)) for xp, yp, wt in zip(x_px, y_px, weight)]
412
+ return new_points, draw_points(orig_image, new_points)
413
+
414
+
415
+ # ─────────────────────────────────────────────────────────────────────────────
416
+ # Inference
417
+ # ─────────────────────────────────────────────────────────────────────────────
418
+
419
+
420
+ def run(orig_image: Image.Image, points: list, preset_name: str):
421
+ import traceback
422
+
423
+ if orig_image is None:
424
+ gr.Warning("Upload an image first.")
425
+ return None, "", None, None
426
+ if not points or len(points) < 2:
427
+ gr.Warning("Provide at least 2 fixations (click the image or load a fixation file).")
428
+ return None, "", None, None
429
+
430
+ preset_key = PRESETS[preset_name]
431
+ try:
432
+ predictor = get_predictor(preset_key)
433
+ except FileNotFoundError as e:
434
+ gr.Warning(str(e))
435
+ return None, f"**Checkpoint not found** for preset `{preset_key}`.", None, None
436
+ except Exception as e: # noqa: BLE001
437
+ traceback.print_exc()
438
+ gr.Warning(f"Could not load model: {e}")
439
+ return None, "", None, None
440
+
441
+ w, h = orig_image.size
442
+ # Build a MIMIC-style scanpath dataframe. The 3rd component (weight)
443
+ # drives a monotonically increasing time axis for the scanpath encoder.
444
+ weights = np.asarray([p[2] for p in points], dtype=float)
445
+ times = np.cumsum(np.clip(weights, 1e-3, None))
446
+ df = pd.DataFrame(
447
+ {
448
+ "DICOM_ID": ["webdemo"] * len(points),
449
+ "X_ORIGINAL": [p[0] for p in points],
450
+ "Y_ORIGINAL": [p[1] for p in points],
451
+ "Time (in secs)": times,
452
+ }
453
+ )
454
+
455
+ scanpath = get_scanpath(df, "webdemo", img_height=h, img_width=w)
456
+ if scanpath is None or scanpath.numel() == 0:
457
+ gr.Warning("Could not build a scanpath from the fixations.")
458
+ return None, "", None, None
459
+ scanpath = scanpath[:200].to(predictor.device)
460
+
461
+ img_tensor = predictor.transform(np.array(orig_image)).unsqueeze(0).to(predictor.device)
462
+ try:
463
+ with torch.no_grad():
464
+ _, patch_tokens, _ = predictor.image_encoder(img_tensor)
465
+ _, sp_emb, _ = predictor.scanpath_encoder([scanpath])
466
+ patch_mask = torch.sigmoid(predictor.mask_generator(sp_emb)) # [1, g, g]
467
+
468
+ B, N, D = patch_tokens.shape
469
+ feat_attended = (patch_tokens * patch_mask.view(B, N, 1)).mean(dim=1)
470
+ logits = predictor.classifier(feat_attended)
471
+ probs = torch.softmax(logits, dim=1)[0].cpu().numpy()
472
+ except Exception as e: # noqa: BLE001
473
+ traceback.print_exc()
474
+ gr.Warning(f"Prediction failed: {e}")
475
+ return None, "", None, None
476
+
477
+ class_probs = {c: float(p) for c, p in zip(predictor.classes, probs)}
478
+ predicted_class = max(class_probs, key=class_probs.get)
479
+
480
+ # Learned gaze-conditioned attention mask, upsampled + overlaid.
481
+ img_size = predictor.img_size
482
+ mask_full = patch_to_image(patch_mask[0].cpu().numpy(), img_size, img_size)
483
+ display_img = np.array(orig_image.resize((img_size, img_size)))
484
+ overlay = make_overlay(display_img, mask_full)
485
+ mask_img = heatmap_to_image(mask_full)
486
+
487
+ prob_lines = "\n".join(
488
+ f"- **{c}**: {p:.3f}" for c, p in sorted(class_probs.items(), key=lambda kv: -kv[1])
489
+ )
490
+ summary = f"### Predicted: **{predicted_class}**\n\n{prob_lines}"
491
+
492
+ return class_probs, summary, overlay, mask_img
493
+
494
+
495
+ # ─────────────────────────────────────────────────────────────────────────────
496
+ # UI
497
+ # ─────────────────────────────────────────────────────────────────────────────
498
+
499
+ _CSS = """
500
+ #run-btn {font-weight: 600;}
501
+ .footer-note {opacity: 0.7; font-size: 0.85rem;}
502
+ """
503
+
504
+ with gr.Blocks(title="GazeAlign", css=_CSS) as demo:
505
+ gr.Markdown(
506
+ """
507
+ # πŸ‘οΈ GazeAlign β€” Gaze-Supervised Medical Image Classification
508
+
509
+ **1.** Upload an image Β· **2.** Add fixations by *clicking* the image
510
+ **or** *uploading a fixation table (.csv / .xlsx)* Β· **3.** Run the model.
511
+ """
512
+ )
513
+
514
+ orig_image_state = gr.State(None)
515
+ points_state = gr.State([])
516
+ image_name_state = gr.State("")
517
+ fixfile_df_state = gr.State(None)
518
+
519
+ with gr.Row():
520
+ # ── Left: image + fixations ──────────────────────────────────────────
521
+ with gr.Column(scale=1):
522
+ preset_dd = gr.Dropdown(
523
+ choices=list(PRESETS.keys()),
524
+ value=list(PRESETS.keys())[0],
525
+ label="Model / modality preset",
526
+ )
527
+
528
+ upload_zone = gr.File(
529
+ label=_UPLOAD_LABEL,
530
+ file_types=[".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp", ".dcm"],
531
+ type="filepath",
532
+ )
533
+ image_panel = gr.Image(
534
+ label=_FIXATION_LABEL, type="pil", interactive=True, visible=False
535
+ )
536
+
537
+ weight_slider = gr.Slider(
538
+ 0.0, 1.0, value=0.5, step=0.05,
539
+ label="Fixation weight (dwell) for clicks",
540
+ )
541
+ with gr.Row():
542
+ clear_btn = gr.Button("Clear fixations")
543
+ delete_btn = gr.Button("Delete image", visible=False)
544
+
545
+ gr.Markdown("**β€” or β€”** load fixations from a file:")
546
+ fixfile = gr.File(
547
+ label=_FIXFILE_LABEL, file_types=[".csv", ".xlsx", ".xls"], type="filepath"
548
+ )
549
+ with gr.Row(visible=False) as mapping_row:
550
+ id_col_dd = gr.Dropdown(label="ID column", choices=[])
551
+ x_col_dd = gr.Dropdown(label="X column", choices=[])
552
+ y_col_dd = gr.Dropdown(label="Y column", choices=[])
553
+ time_col_dd = gr.Dropdown(label="Time column (optional)", choices=[])
554
+ apply_fix_btn = gr.Button("Apply fixation file", visible=False)
555
+
556
+ # ── Right: results ───────────────────────────────────────────────────
557
+ with gr.Column(scale=1):
558
+ run_btn = gr.Button("Run GazeAlign", variant="primary", elem_id="run-btn")
559
+ label_output = gr.Label(label="Predicted class (probabilities)", num_top_classes=5)
560
+ summary_output = gr.Markdown()
561
+ overlay_output = gr.Image(label="Gaze-conditioned attention overlay")
562
+ mask_output = gr.Image(label="Learned attention mask")
563
+
564
+ gr.Markdown(
565
+ "<div class='footer-note'>Fixation tables use raw pixel or normalised "
566
+ "[0,1] X/Y coordinates. See the "
567
+ "<a href='https://github.com/MohammedOussamaBEN/GazeAlign'>GitHub repo</a> "
568
+ "for training and evaluation code.</div>"
569
+ )
570
+
571
+ # ── wiring ──
572
+ upload_zone.upload(
573
+ on_file_upload,
574
+ [upload_zone],
575
+ [orig_image_state, points_state, image_name_state, upload_zone, image_panel, delete_btn],
576
+ )
577
+ image_panel.select(
578
+ on_select,
579
+ [orig_image_state, points_state, weight_slider],
580
+ [points_state, image_panel],
581
+ )
582
+ clear_btn.click(on_clear, [orig_image_state], [points_state, image_panel])
583
+ delete_btn.click(
584
+ on_delete,
585
+ None,
586
+ [orig_image_state, points_state, image_name_state, upload_zone, image_panel, delete_btn],
587
+ )
588
+
589
+ fixfile.upload(
590
+ on_fixfile_upload,
591
+ [fixfile],
592
+ [fixfile_df_state, mapping_row, id_col_dd, x_col_dd, y_col_dd, time_col_dd, apply_fix_btn],
593
+ )
594
+ apply_fix_btn.click(
595
+ on_apply_fixfile,
596
+ [fixfile_df_state, id_col_dd, x_col_dd, y_col_dd, time_col_dd, orig_image_state, image_name_state],
597
+ [points_state, image_panel],
598
+ )
599
+
600
+ run_btn.click(
601
+ run,
602
+ [orig_image_state, points_state, preset_dd],
603
+ [label_output, summary_output, overlay_output, mask_output],
604
+ )
605
+
606
+
607
+ if __name__ == "__main__":
608
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.1
2
+ torchvision>=0.16
3
+ timm>=1.0
4
+ transformers>=4.40
5
+ opencv-python-headless>=4.8
6
+ numpy>=1.24
7
+ pandas>=2.0
8
+ scipy>=1.10
9
+ scikit-learn>=1.3
10
+ pyyaml>=6.0
11
+ pillow>=10.0
12
+ gradio>=4.0
13
+ pydicom>=2.4
14
+ openpyxl>=3.1