anonymous-IA commited on
Commit
cb6ac42
·
verified ·
1 Parent(s): f66bbd0

Upload 41 files

Browse files
Files changed (4) hide show
  1. README.md +17 -26
  2. __pycache__/app.cpython-310.pyc +0 -0
  3. app.py +133 -836
  4. requirements.txt +2 -9
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: GazeRefine
3
  emoji: 👁️
4
  colorFrom: blue
5
  colorTo: red
@@ -8,31 +8,22 @@ sdk_version: 4.44.1
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
- short_description: Zero-shot, training-free gaze-guided medical segmentation
12
  ---
13
 
14
- # GazeRefineExpert Gaze as a Test-Time Prompt
15
 
16
- Interactive demo for **GazeRefine**, a training-free, zero-shot framework
17
- that turns expert eye-gaze into an inference-time prompt for medical image
18
- segmentation. Frozen DINOv3 patch features + gaze-anchored
19
- foreground/background prototypes + recurrent contrastive cleaning + kNN
20
- affinity propagation — no masks, no clicks-as-boxes, no fine-tuning, no
21
- adapters, no prompt encoder.
22
 
23
  ## How to use
24
- 1. Upload a colonoscopy or grayscale-MRI-style image.
25
- 2. Click on the image 1–5 times where a clinician's gaze would land on the
26
- structure of interest (a polyp, the prostate, ...). Each click adds a
27
- numbered fixation marker; the slider controls that fixation's relative
28
- duration/weight before your next click.
29
- 3. Pick a hyperparameter preset (tuned per-modality, see the paper).
30
- 4. Press **Run GazeRefine** to get the gaze-prior overlay and the predicted
31
- segmentation mask.
32
- 5. Optionally open **Correct the gaze-attended region**. The predicted mask is
33
- feathered, Gaussian noise is added only inside that region, and the noised
34
- image is sent to RoentGen-v2 image-to-image inference. The generated result
35
- is composited back only inside the mask, retaining the original background.
36
 
37
  ## Synthetic chest X-ray correction
38
 
@@ -63,11 +54,11 @@ and correction callbacks with `@spaces.GPU`. Keep `app.py` unchanged at the
63
  top level; removing those decorators causes the ZeroGPU startup error
64
  “No @spaces.GPU function detected”.
65
 
66
- ## Notes
67
- - Inference uses a frozen `vit_large_patch16_dinov3.lvd1689m` backbone from
68
- `timm`. First run will download the checkpoint.
69
- - CPU inference works but is slow; a GPU Space is recommended for a smooth
70
- demo.
71
  - This Space is for research/demonstration only — it is **not** a clinical
72
  diagnostic tool.
73
 
 
1
  ---
2
+ title: GazeCorrect
3
  emoji: 👁️
4
  colorFrom: blue
5
  colorTo: red
 
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
+ short_description: Gaze-guided Gaussian-noise image regeneration
12
  ---
13
 
14
+ # GazeCorrect — Gaze-Guided Regeneration
15
 
16
+ Interactive demo that converts clinician gaze into a duration-weighted Gaussian
17
+ attention map. It adds Gaussian noise in the attended region, then uses a
18
+ text description and RoentGen-v2 image-to-image generation to regenerate that
19
+ region. No DINOv3 or segmentation pipeline is used.
 
 
20
 
21
  ## How to use
22
+ 1. Upload a chest X-ray.
23
+ 2. Click gaze points or upload a CSV containing `x,y,duration`.
24
+ 3. Enter a disease or radiology description.
25
+ 4. Press **Generate** to receive the gaze attention map, attention-weighted
26
+ Gaussian-noise image, and corrected regenerated image.
 
 
 
 
 
 
 
27
 
28
  ## Synthetic chest X-ray correction
29
 
 
54
  top level; removing those decorators causes the ZeroGPU startup error
55
  “No @spaces.GPU function detected”.
56
 
57
+ ## Notes
58
+ - RoentGen-v2 is trained for chest X-rays; do not use this workflow for other
59
+ medical modalities.
60
+ - The first generation downloads the model. A GPU or ZeroGPU Space is
61
+ recommended.
62
  - This Space is for research/demonstration only — it is **not** a clinical
63
  diagnostic tool.
64
 
__pycache__/app.cpython-310.pyc CHANGED
Binary files a/__pycache__/app.cpython-310.pyc and b/__pycache__/app.cpython-310.pyc differ
 
app.py CHANGED
@@ -1,860 +1,157 @@
1
- """
2
- app.py GazeRefine interactive demo (Hugging Face Space).
3
-
4
- Upload flow
5
- -----------
6
- * Standard images (.jpg / .png / etc.) — drag-and-drop or click on the main
7
- gr.Image widget. The same widget also accepts clicks to place fixations, so
8
- there is now only ONE image panel instead of two.
9
- * DICOM files (.dcm) — use the separate "Upload DICOM" file picker. The file
10
- is decoded with pydicom and converted to an RGB PIL image before being handed
11
- to the same fixation / run pipeline.
12
- * Fixation file (.csv / .xlsx / .xls) — use the separate "Upload fixation
13
- file" picker. This can contain fixations for one or many images (e.g. an
14
- eye-tracker export with one row per fixation). After upload, three
15
- dropdowns let the user pick which column is the image-name/ID column and
16
- which columns hold X / Y (and, optionally, duration). Rows are matched to
17
- the currently loaded image by filename; X/Y values are auto-detected as
18
- either normalised [0,1] or raw pixel coordinates.
19
- """
20
- from __future__ import annotations
21
-
22
- import sys
23
- import types
24
- import tempfile
25
- import csv
26
- import os
27
- from pathlib import Path
28
 
29
- # ZeroGPU must see this import and decorator before any module imports torch.
30
- # The fallback keeps the same app runnable in a regular CPU/GPU Space and
31
- # during local development, where Hugging Face's `spaces` package is absent.
32
  try:
33
  import spaces
34
  except ImportError:
35
- class _SpacesFallback:
36
  @staticmethod
37
- def GPU(*args, **kwargs):
38
- if args and callable(args[0]) and len(args) == 1 and not kwargs:
39
- return args[0]
40
- return lambda function: function
41
- spaces = _SpacesFallback()
 
 
 
 
 
 
 
 
42
 
43
- # ── 1. audioop shim (Python 3.13 removed audioop; pydub needs it) ─────────────
44
- if sys.version_info >= (3, 13):
45
- for _mod in ("audioop", "pyaudioop"):
46
- if _mod not in sys.modules:
47
- sys.modules[_mod] = types.ModuleType(_mod)
48
-
49
- # ── 2. Patch starlette Jinja2Templates.TemplateResponse ──────────────────────
50
- import starlette.templating as _st
51
-
52
- _orig_TR = _st.Jinja2Templates.TemplateResponse
53
-
54
- def _compat_TR(self, *args, **kwargs):
55
- if args and isinstance(args[0], str) and len(args) >= 2 and isinstance(args[1], dict):
56
- name = args[0]
57
- context = args[1]
58
- status_code = args[2] if len(args) > 2 else kwargs.get("status_code", 200)
59
- headers = kwargs.get("headers")
60
- media_type = kwargs.get("media_type")
61
- background = kwargs.get("background")
62
- template = self.get_template(name)
63
- return _st._TemplateResponse(
64
- template, context,
65
- status_code=status_code,
66
- headers=headers,
67
- media_type=media_type,
68
- background=background,
69
- )
70
- return _orig_TR(self, *args, **kwargs)
71
-
72
- _st.Jinja2Templates.TemplateResponse = _compat_TR # type: ignore[method-assign]
73
-
74
- import gradio as gr
75
-
76
- # ── 3. gradio_client schema shim ──────────────────────────────────────────────
77
- try:
78
- import gradio_client.utils as _gcu
79
- _orig_inner = _gcu._json_schema_to_python_type
80
-
81
- def _safe_inner(schema, defs=None):
82
- if not isinstance(schema, dict):
83
- return "Any"
84
- if not isinstance(schema.get("additionalProperties"), dict):
85
- schema = {k: v for k, v in schema.items() if k != "additionalProperties"}
86
- return _orig_inner(schema, defs)
87
-
88
- _gcu._json_schema_to_python_type = _safe_inner
89
- except Exception:
90
- pass
91
-
92
- # ── 4. huggingface_hub HfFolder shim ─────────────────────────────────────────
93
- try:
94
- from huggingface_hub import HfFolder # noqa: F401
95
- except ImportError:
96
- import huggingface_hub as _hfh
97
- class _FakeHfFolder:
98
- @staticmethod
99
- def get_token(): return None
100
- _hfh.HfFolder = _FakeHfFolder # type: ignore[attr-defined]
101
- sys.modules["huggingface_hub"].HfFolder = _FakeHfFolder # type: ignore[assignment]
102
-
103
- import numpy as np
104
- from PIL import Image, ImageDraw
105
-
106
- # ── 5. Path setup ─────────────────────────────────────────────────────────────
107
- _here = Path(__file__).resolve().parent
108
- for _candidate in [_here] + list(_here.parents):
109
- _s = str(_candidate)
110
- if _s not in sys.path:
111
- sys.path.insert(0, _s)
112
-
113
- import scripts.predict_single as _predict_module # noqa: E402
114
- from scripts.predict_single import predict # noqa: E402
115
- from gazecorrect.correction import ( # noqa: E402
116
- apply_correction_only_to_attention,
117
- correct_with_roentgen,
118
- make_attention_noise,
119
- )
120
-
121
- # ── Monkey-patch load_fixation_csv ────────────────────────────────────────────
122
- # Our single-image temp CSV has x,y,duration in raw pixel coordinates with no
123
- # image_name column. The original loader expects a dataset CSV and returns an
124
- # empty tensor when that column is absent.
125
- # This patch detects the missing column, reads the CSV directly, normalises
126
- # pixel → [0,1], and adds the batch dimension the model requires: (N,3)→(1,N,3).
127
-
128
- import pandas as _pd
129
- import torch as _torch
130
-
131
- try:
132
- from gazecorrect.gaze import load_fixation_csv as _orig_load_fixation_csv
133
- except Exception:
134
- _orig_load_fixation_csv = None
135
-
136
- def _patched_load_fixation_csv(csv_path, image_width=1, image_height=1, image_name=None):
137
- df = _pd.read_csv(csv_path)
138
- print(f"[PATCH] load_fixation_csv — columns: {list(df.columns)}, rows: {len(df)}")
139
-
140
- if "image_name" in df.columns and _orig_load_fixation_csv is not None:
141
- print("[PATCH] image_name column present — using original loader")
142
- return _orig_load_fixation_csv(csv_path, image_width=image_width,
143
- image_height=image_height, image_name=image_name)
144
-
145
- x = df["x"].values.astype(float)
146
- y = df["y"].values.astype(float)
147
- dur = df["duration"].values.astype(float)
148
-
149
- x_n = x / max(float(image_width), 1.0)
150
- y_n = y / max(float(image_height), 1.0)
151
- dur_n = dur / (dur.max() + 1e-8)
152
-
153
- # model expects (B, N, 3) — add batch dim
154
- fixations = _torch.tensor(
155
- list(zip(x_n, y_n, dur_n)), dtype=_torch.float32
156
- ).unsqueeze(0) # (N, 3) → (1, N, 3)
157
-
158
- print(f"[PATCH] tensor shape: {tuple(fixations.shape)}")
159
- print(f"[PATCH] fixations (x_norm, y_norm, dur_norm):\n{fixations[0]}")
160
- return fixations
161
-
162
- _predict_module.load_fixation_csv = _patched_load_fixation_csv
163
-
164
-
165
- # ─────────────────────────────────────────────────────────────────────────────
166
- # Helpers
167
- # ─────────────────────────────────────────────────────────────────────────────
168
-
169
- PRESETS = {
170
- "Colonoscopy / polyp (Kvasir-SEG settings)": "colonoscopy",
171
- "Grayscale MRI / CT (prostate-MRI settings)": "mri",
172
- }
173
- POINT_COLORS = ["#ff3b30", "#ff9500", "#ffcc00", "#34c759", "#5ac8fa", "#007aff", "#af52de"]
174
-
175
- _NO_COL = "— none —"
176
-
177
-
178
- def dcm_to_pil(dcm_path: str) -> Image.Image:
179
- """Load a DICOM file and return an RGB PIL image."""
180
- import pydicom
181
- dcm = pydicom.dcmread(dcm_path)
182
- arr = dcm.pixel_array.astype(np.float32)
183
- arr = arr - arr.min()
184
- arr = arr / (arr.max() + 1e-8)
185
- arr = (arr * 255).astype(np.uint8)
186
- # Handle multi-frame / greyscale / RGB DICOM
187
- if arr.ndim == 2:
188
- return Image.fromarray(arr, mode="L").convert("RGB")
189
- if arr.ndim == 3 and arr.shape[0] in (1, 3, 4):
190
- # (C, H, W) → (H, W, C)
191
- arr = arr.transpose(1, 2, 0)
192
- return Image.fromarray(arr).convert("RGB")
193
-
194
-
195
- def draw_points(image: Image.Image, points: list) -> Image.Image:
196
- """Overlay fixation circles on a copy of `image`.
197
-
198
- `points`: list of (x_px, y_px, duration) in original-image pixel coords.
199
- """
200
- if image is None:
201
- return None
202
- vis = image.convert("RGB").copy()
203
- draw = ImageDraw.Draw(vis)
204
- w, h = vis.size
205
- r = max(6, min(w, h) // 80)
206
- for i, (x_px, y_px, dur) in enumerate(points):
207
- color = POINT_COLORS[i % len(POINT_COLORS)]
208
- rad = r * (0.6 + 0.8 * dur)
209
- draw.ellipse(
210
- [x_px - rad, y_px - rad, x_px + rad, y_px + rad],
211
- outline=color, width=3,
212
- )
213
- draw.text((x_px + rad + 2, y_px - rad), str(i + 1), fill=color)
214
- return vis
215
-
216
-
217
- def read_table(path: str) -> "_pd.DataFrame":
218
- """Load a .csv / .xlsx / .xls fixation file into a DataFrame."""
219
- ext = Path(path).suffix.lower()
220
- if ext in (".xlsx", ".xls"):
221
- return _pd.read_excel(path)
222
- # Sniff delimiter for csv/tsv/txt — eye-tracker exports are sometimes
223
- # tab-separated even with a .csv extension.
224
- return _pd.read_csv(path, sep=None, engine="python")
225
-
226
-
227
- def normalize_xy(x_vals: np.ndarray, y_vals: np.ndarray, img_w: int, img_h: int):
228
- """Convert X/Y column values to pixel coords for the given image size.
229
-
230
- Values already in [0, 1] (inclusive, with a little slack for rounding)
231
- are treated as normalised; anything else is assumed to already be raw
232
- pixel coordinates and is left as-is (but clamped to the image bounds).
233
- """
234
- looks_normalized = (
235
- np.nanmax(x_vals) <= 1.05 and np.nanmax(y_vals) <= 1.05
236
- and np.nanmin(x_vals) >= -0.05 and np.nanmin(y_vals) >= -0.05
237
- )
238
- if looks_normalized:
239
- x_px = np.clip(x_vals, 0, 1) * img_w
240
- y_px = np.clip(y_vals, 0, 1) * img_h
241
- else:
242
- x_px = np.clip(x_vals, 0, img_w)
243
- y_px = np.clip(y_vals, 0, img_h)
244
- return x_px, y_px
245
-
246
-
247
- # ─────────────────────────────────────────────────────────────────────────────
248
- # Event handlers
249
- # ─────────────────────────────────────────────────────────────────────────────
250
-
251
-
252
- _UPLOAD_LABEL = "Drop / click to load .jpg .png .bmp .tif .tiff .webp .dcm"
253
- _FIXATION_LABEL = "Click to place fixations"
254
- _FIXFILE_LABEL = "Upload fixation file (.csv / .xlsx / .xls) — optional"
255
-
256
-
257
- def _resolve_path(file_obj):
258
- """Extract a filesystem path from whatever gr.File passes."""
259
- if isinstance(file_obj, str):
260
- return file_obj
261
- if isinstance(file_obj, dict):
262
- return file_obj.get("name") or file_obj.get("path") or file_obj.get("tmp_path") or ""
263
- if hasattr(file_obj, "name"):
264
- return file_obj.name
265
- return ""
266
-
267
-
268
- def on_file_upload(file_obj):
269
- """Load any image or DICOM and switch the panel to fixation-click mode."""
270
- _no_change = (None, [], gr.update(), gr.update(), gr.update(), gr.update(), None)
271
-
272
- if file_obj is None:
273
- return _no_change
274
-
275
- image_name = ""
276
-
277
- # gr.Image gives PIL/numpy; gr.File gives a path
278
- if isinstance(file_obj, Image.Image):
279
- pil = file_obj.convert("RGB")
280
- elif isinstance(file_obj, np.ndarray):
281
- pil = Image.fromarray(file_obj).convert("RGB")
282
- else:
283
- path = _resolve_path(file_obj)
284
- if not path:
285
- gr.Warning("Could not resolve file path.")
286
- return _no_change
287
- image_name = Path(path).name
288
- ext = Path(path).suffix.lower()
289
- try:
290
- pil = dcm_to_pil(path) if ext == ".dcm" else Image.open(path).convert("RGB")
291
- except Exception as e:
292
- gr.Warning(f"Could not load file: {e}")
293
- return _no_change
294
-
295
- print(f"[DEBUG] on_file_upload — size={pil.size} name={image_name!r}")
296
- # Switch: hide upload zone, show image panel + delete button
297
- return (
298
- pil, # orig_image_state
299
- [], # points_state
300
- image_name, # image_name_state
301
- gr.update(visible=False), # upload_zone → hide
302
- gr.update(value=pil, visible=True,
303
- label=_FIXATION_LABEL), # image_panel → show with image
304
- gr.update(visible=True), # delete_btn → show
305
- None, # attention_mask_state → reset
306
- )
307
-
308
-
309
- def on_select(orig_image: Image.Image, points: list, duration: float, evt: gr.SelectData):
310
- """Record a fixation click in original-image pixel coords."""
311
- if orig_image is None:
312
- gr.Warning("Upload an image first.")
313
- return points, gr.update(), None
314
- x_px, y_px = float(evt.index[0]), float(evt.index[1])
315
- new_points = points + [(x_px, y_px, duration)]
316
- print(f"[DEBUG] fixation #{len(new_points)}: x={x_px:.1f} y={y_px:.1f} dur={duration}")
317
- return new_points, draw_points(orig_image, new_points), None
318
-
319
-
320
- def on_clear(orig_image):
321
- """Remove all fixations but keep the current image."""
322
- if orig_image is None:
323
- return [], gr.update(), None
324
- return [], gr.update(value=orig_image), None
325
-
326
-
327
- def on_delete():
328
- """Delete the current image and return to upload mode."""
329
- return (
330
- None, # orig_image_state
331
- [], # points_state
332
- "", # image_name_state
333
- gr.update(value=None, visible=True), # upload_zone → show (reset)
334
- gr.update(value=None, visible=False), # image_panel → hide
335
- gr.update(visible=False), # delete_btn → hide
336
- None, # attention_mask_state → reset
337
- )
338
-
339
-
340
- # ── Fixation-file upload → column mapping ─────────────────────���──────────────
341
-
342
- def on_fixfile_upload(file_obj):
343
- """Load the fixation table and populate the column-mapping dropdowns."""
344
- _hide = (
345
- None, gr.update(visible=False),
346
- gr.update(choices=[], value=None), gr.update(choices=[], value=None),
347
- gr.update(choices=[], value=None), gr.update(choices=[], value=None),
348
- gr.update(visible=False),
349
- )
350
- if file_obj is None:
351
- return _hide
352
-
353
- path = _resolve_path(file_obj)
354
- if not path:
355
- gr.Warning("Could not resolve fixation file path.")
356
- return _hide
357
-
358
- try:
359
- df = read_table(path)
360
- except Exception as e:
361
- gr.Warning(f"Could not read fixation file: {e}")
362
- return _hide
363
-
364
- if df.empty or len(df.columns) == 0:
365
- gr.Warning("Fixation file appears to be empty.")
366
- return _hide
367
-
368
- cols = [str(c) for c in df.columns]
369
- print(f"[DEBUG] fixation file loaded — columns: {cols}, rows: {len(df)}")
370
-
371
- def _guess(*keywords, fallback=None):
372
- for c in cols:
373
- cl = c.lower()
374
- if any(k in cl for k in keywords):
375
- return c
376
- return fallback if fallback is not None else cols[0]
377
-
378
- guess_id = _guess("image", "id", "name", "file", fallback=cols[0])
379
- # exact / boundary-aware matches first (avoids "fix_index" matching "x"),
380
- # then fall back to a bare trailing "x" / "y".
381
- guess_x = _guess("fix_x", "pos_x", "gaze_x", fallback=None)
382
- if guess_x is None:
383
- guess_x = next((c for c in cols if c.lower().rstrip("_") .endswith("x")
384
- and "index" not in c.lower()), cols[0])
385
- guess_y = _guess("fix_y", "pos_y", "gaze_y", fallback=None)
386
- if guess_y is None:
387
- guess_y = next((c for c in cols if c.lower().rstrip("_").endswith("y")
388
- and "index" not in c.lower()), cols[0])
389
-
390
- dur_choices = [_NO_COL] + cols
391
- guess_dur = _guess("duration", "dur", fallback=_NO_COL)
392
-
393
- return (
394
- df.to_json(), # fixfile_df_state (serialized)
395
- gr.update(visible=True), # mapping_row → show
396
- gr.update(choices=cols, value=guess_id), # id_col_dd
397
- gr.update(choices=cols, value=guess_x), # x_col_dd
398
- gr.update(choices=cols, value=guess_y), # y_col_dd
399
- gr.update(choices=dur_choices, value=guess_dur), # dur_col_dd
400
- gr.update(visible=True), # apply_fix_btn → show
401
- )
402
-
403
-
404
- def on_apply_fixfile(fixfile_json, id_col, x_col, y_col, dur_col,
405
- orig_image, image_name):
406
- """Match rows to the currently loaded image (by filename) and load
407
- them as fixation points, replacing whatever points are currently set.
408
-
409
- If no rows match the loaded image's filename, nothing is loaded — the
410
- existing points (if any) are left untouched, and the user is warned so
411
- they can check the ID column / image filename instead of silently
412
- getting fixations for the wrong image."""
413
- if orig_image is None:
414
- gr.Warning("Load an image first, then apply the fixation file.")
415
- return gr.update(), gr.update(), None
416
- if not fixfile_json:
417
- gr.Warning("Upload a fixation file first.")
418
- return gr.update(), gr.update(), None
419
- if not id_col or not x_col or not y_col:
420
- gr.Warning("Pick the ID, X and Y columns first.")
421
- return gr.update(), gr.update(), None
422
- if not image_name:
423
- gr.Warning(
424
- "Couldn't determine the loaded image's filename (this can "
425
- "happen if the image was pasted/dropped without a filename). "
426
- "Re-upload the image as a file and try again."
427
- )
428
- return gr.update(), gr.update(), None
429
-
430
- df = _pd.read_json(fixfile_json)
431
-
432
- # Match by exact filename first, then by stem-without-extension, so the
433
- # mapping still works if the fixation file's IMAGE column omits the
434
- # extension or uses a different one than the uploaded image.
435
- mask = df[id_col].astype(str) == image_name
436
- if not mask.any():
437
- stem_no_ext = Path(image_name).stem
438
- mask = df[id_col].astype(str).apply(lambda v: Path(str(v)).stem) == stem_no_ext
439
-
440
- sub = df[mask]
441
-
442
- if sub.empty:
443
- gr.Warning(
444
- f"No rows in the fixation file match the loaded image "
445
- f"('{image_name}'). Nothing was loaded — check that the ID "
446
- f"column values match the image filename."
447
- )
448
- return gr.update(), gr.update(), None
449
-
450
- w, h = orig_image.size
451
- x_vals = sub[x_col].astype(float).to_numpy()
452
- y_vals = sub[y_col].astype(float).to_numpy()
453
- x_px, y_px = normalize_xy(x_vals, y_vals, w, h)
454
-
455
- if dur_col and dur_col != _NO_COL and dur_col in sub.columns:
456
- dur_raw = sub[dur_col].astype(float).to_numpy()
457
- dmax = float(np.nanmax(dur_raw)) if len(dur_raw) else 1.0
458
- dur_n = dur_raw / (dmax + 1e-8)
459
- else:
460
- dur_n = np.full(len(sub), 1.0)
461
-
462
- new_points = [
463
- (float(xp), float(yp), float(d))
464
- for xp, yp, d in zip(x_px, y_px, dur_n)
465
- ]
466
- print(f"[DEBUG] loaded {len(new_points)} fixations from file for image '{image_name}'")
467
-
468
- return new_points, draw_points(orig_image, new_points), None
469
-
470
-
471
- @spaces.GPU(duration=120)
472
- def run(orig_image: Image.Image, points: list, preset_name: str, threshold: float):
473
- import traceback, uuid
474
- print(f"[DEBUG] run — points={len(points)} preset={preset_name}")
475
-
476
- if orig_image is None:
477
- gr.Warning("Upload an image first.")
478
- return None, None, None, None, "Upload an image first."
479
- if not points:
480
- gr.Warning("Click on the image at least once to place a fixation (or load a fixation file).")
481
- return None, None, None, None, "Add at least one gaze fixation, then run segmentation."
482
-
483
- preset_key = PRESETS[preset_name]
484
- w, h = orig_image.size
485
-
486
- shared_stem = f"gazerefine_{uuid.uuid4().hex}"
487
- tmp_img_path = os.path.join(tempfile.gettempdir(), f"{shared_stem}.png")
488
- fixation_csv_path = os.path.join(tempfile.gettempdir(), f"{shared_stem}.csv")
489
-
490
- orig_image.convert("RGB").save(tmp_img_path)
491
-
492
- with open(fixation_csv_path, "w", newline="") as f:
493
- writer = csv.writer(f)
494
- writer.writerow(["x", "y", "duration"])
495
- for x_px, y_px, dur in points:
496
- writer.writerow([x_px, y_px, dur])
497
-
498
- print(f"[DEBUG] image {w}x{h} | {len(points)} fixations | preset={preset_key} thr={threshold}")
499
-
500
- with open(fixation_csv_path) as f:
501
- print(f"[DEBUG] CSV:\n{f.read()}")
502
-
503
- try:
504
- out = predict(
505
- image_path=tmp_img_path,
506
- fixation_csv=fixation_csv_path,
507
- preset=preset_key,
508
- threshold=threshold,
509
- return_all=True,
510
- )
511
- except Exception as e:
512
- print(f"[ERROR] predict() raised: {e}")
513
- traceback.print_exc()
514
- message = f"Segmentation failed: {e}"
515
- gr.Warning(message)
516
- return None, None, None, None, message
517
- finally:
518
- for p in (tmp_img_path, fixation_csv_path):
519
- try:
520
- os.unlink(p)
521
- except OSError:
522
- pass
523
-
524
- mask_arr = np.array(out["mask"])
525
- print(f"[DEBUG] mask non-zero: {(mask_arr > 0).sum()} / {mask_arr.size}")
526
- return (
527
- out["gaze_overlay"], out["mask_overlay"], out["mask"], out["mask"],
528
- "Segmentation completed. You can now apply attention-weighted Gaussian noise and correction.",
529
- )
530
 
 
 
 
 
 
 
 
 
 
531
 
532
- @spaces.GPU(duration=180)
533
- def correct_attention_region(
534
- orig_image: Image.Image,
535
- attention_mask: Image.Image,
536
- noise_strength: float,
537
- mask_feather: float,
538
- diffusion_strength: float,
539
- steps: int,
540
- prompt: str,
541
- seed: float,
542
- progress=gr.Progress(track_tqdm=True),
543
- ):
544
- """Noise the DINOv3/gaze-attended region, then restore it with RoentGen."""
545
- if orig_image is None or attention_mask is None:
546
- message = "Run GazeRefine first so a gaze-guided attention mask is available."
547
- gr.Warning(message)
548
- return None, None, message
549
- seed_value = int(seed) if seed >= 0 else None
550
  try:
551
- progress(0.05, desc="Applying Gaussian noise to the attended region")
552
- noised, feathered_mask = make_attention_noise(
553
- orig_image, attention_mask, noise_strength, mask_feather, seed_value
554
- )
555
  except Exception as exc:
556
- message = f"Could not create the attention-weighted noise image: {exc}"
557
- print(f"[ERROR] {message}")
558
- gr.Warning(message)
559
- return None, None, message
 
 
 
 
 
560
 
 
 
561
  try:
562
- progress(0.15, desc="Loading RoentGen-v2 (first run downloads model weights)")
563
- generated = correct_with_roentgen(
564
- noised,
565
- prompt=prompt.strip() or "chest x-ray",
566
- strength=diffusion_strength,
567
- steps=int(steps),
568
- seed=seed_value,
569
- )
570
- progress(0.95, desc="Compositing corrected attention region")
571
- corrected = apply_correction_only_to_attention(orig_image, generated, feathered_mask)
572
- return noised, corrected, "Completed: RoentGen-v2 corrected the gaze-attended region."
 
573
  except Exception as exc:
574
- message = (
575
- "Gaussian-noise preview created, but RoentGen-v2 could not run. "
576
- "In your Hugging Face Space, add a Settings → Secrets value named "
577
- "HF_TOKEN, accept access for stanfordmimi/RoentGen-v2 with that account, "
578
- f"and use GPU hardware. Error: {exc}"
579
- )
580
- print(f"[ERROR] diffusion correction failed: {exc}")
581
- gr.Warning(message)
582
- return noised, None, message
583
 
584
 
585
- @spaces.GPU(duration=300)
586
- def segment_noise_and_regenerate(
587
- orig_image: Image.Image,
588
- points: list,
589
- preset_name: str,
590
- threshold: float,
591
- disease_description: str,
592
- noise_strength: float,
593
- mask_feather: float,
594
- diffusion_strength: float,
595
- steps: int,
596
- seed: float,
597
- progress=gr.Progress(track_tqdm=True),
598
- ):
599
- """One-click workflow: gaze segmentation -> masked noise -> regeneration."""
600
- if orig_image is None:
601
- return None, None, None, None, None, None, "Upload an image first."
602
- if not points:
603
- return None, None, None, None, None, None, "Add gaze clicks or load a gaze CSV first."
604
 
605
- import traceback
606
- import uuid
607
 
608
- shared_stem = f"gazerefine_{uuid.uuid4().hex}"
609
- tmp_img_path = os.path.join(tempfile.gettempdir(), f"{shared_stem}.png")
610
- fixation_csv_path = os.path.join(tempfile.gettempdir(), f"{shared_stem}.csv")
611
- try:
612
- progress(0.03, desc="Preparing gaze input")
613
- orig_image.convert("RGB").save(tmp_img_path)
614
- with open(fixation_csv_path, "w", newline="") as file:
615
- writer = csv.writer(file)
616
- writer.writerow(["x", "y", "duration"])
617
- writer.writerows(points)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
 
619
- progress(0.12, desc="DINOv3 gaze-guided segmentation")
620
- out = predict(
621
- image_path=tmp_img_path,
622
- fixation_csv=fixation_csv_path,
623
- preset=PRESETS[preset_name],
624
- threshold=threshold,
625
- return_all=True,
626
- )
627
- except Exception as exc:
628
- traceback.print_exc()
629
- message = f"Segmentation failed: {exc}"
630
- gr.Warning(message)
631
- return None, None, None, None, None, None, message
632
- finally:
633
- for path in (tmp_img_path, fixation_csv_path):
634
- try:
635
- os.unlink(path)
636
- except OSError:
637
- pass
638
 
639
- noised = None
 
 
 
 
640
  try:
641
- seed_value = int(seed) if seed >= 0 else None
642
- progress(0.45, desc="Adding attention-weighted Gaussian noise")
643
- noised, feathered_mask = make_attention_noise(
644
- orig_image, out["mask"], noise_strength, mask_feather, seed_value
645
- )
646
- progress(0.55, desc="RoentGen-v2 regeneration")
647
- generated = correct_with_roentgen(
648
- noised,
649
- prompt=disease_description.strip() or "Normal chest radiograph.",
650
- strength=diffusion_strength,
651
- steps=int(steps),
652
- seed=seed_value,
653
- )
654
- corrected = apply_correction_only_to_attention(orig_image, generated, feathered_mask)
655
- return (
656
- out["gaze_overlay"], out["mask_overlay"], out["mask"], out["mask"],
657
- noised, corrected,
658
- "Completed: gaze mask, attention-weighted noise, and regenerated image are ready.",
659
- )
660
  except Exception as exc:
661
- message = (
662
- "The gaze mask and noise image were created, but RoentGen-v2 is not authorized. "
663
- "Accept access to the model and set HF_TOKEN. Error: " + str(exc)
664
- )
665
- gr.Warning(message)
666
- return out["gaze_overlay"], out["mask_overlay"], out["mask"], out["mask"], noised, None, message
667
-
668
-
669
- # ─────────────────────────────────────────────────────────────────────────────
670
- # UI
671
- # ─────────────────────────────────────────────────────────────────────────────
672
-
673
- with gr.Blocks(title="GazeRefine — gaze-guided zero-shot segmentation") as demo:
674
- gr.Markdown(
675
- """
676
- # 👁️ GazeRefine — Expert Gaze as a Test-Time Prompt
677
- Training-free, zero-shot medical image segmentation.
678
- Upload an image or DICOM, click to place fixations (or load a fixation
679
- file), then hit **Run**.
680
- """
681
- )
682
-
683
- orig_image_state = gr.State(None)
684
- points_state = gr.State([])
685
- image_name_state = gr.State("") # filename of the currently loaded image
686
- fixfile_df_state = gr.State(None) # serialized DataFrame (to_json) of the uploaded fixation file
687
- attention_mask_state = gr.State(None) # binary DINOv3/gaze segmentation output
688
-
689
- with gr.Row():
690
- # ── Left column ───────────────────────────────────────────────────────
691
- with gr.Column(scale=1):
692
-
693
- # ── Upload zone (visible when no image loaded) ────────────────────
694
- upload_zone = gr.File(
695
- label=_UPLOAD_LABEL,
696
- file_types=[".jpg", ".jpeg", ".png", ".bmp",
697
- ".tif", ".tiff", ".webp", ".gif", ".dcm"],
698
- file_count="single",
699
- visible=True,
700
- elem_id="upload_zone",
701
- )
702
-
703
- # ── Image panel (hidden until image loaded; click to fixate) ──────
704
- image_panel = gr.Image(
705
- type="pil",
706
- label=_FIXATION_LABEL,
707
- height=430,
708
- interactive=False, # no toolbar → .select fires on click
709
- show_download_button=False,
710
- visible=False,
711
- elem_id="image_panel",
712
- )
713
-
714
- # ── Delete button (hidden until image loaded) ─────────────────────
715
- delete_btn = gr.Button("🗑 Delete image — load another", visible=False, variant="secondary")
716
-
717
- # ── Fixation file upload (optional alternative to manual clicks) ──
718
- with gr.Accordion("📄 Load fixations from file", open=False):
719
- fixfile_upload = gr.File(
720
- label=_FIXFILE_LABEL,
721
- file_types=[".csv", ".xlsx", ".xls", ".tsv", ".txt"],
722
- file_count="single",
723
- elem_id="fixfile_upload",
724
- )
725
- with gr.Row(visible=False) as mapping_row:
726
- id_col_dd = gr.Dropdown(label="Image / ID column", choices=[])
727
- x_col_dd = gr.Dropdown(label="X column", choices=[])
728
- y_col_dd = gr.Dropdown(label="Y column", choices=[])
729
- dur_col_dd = gr.Dropdown(label="Duration column (optional)", choices=[])
730
- apply_fix_btn = gr.Button(
731
- "📥 Load fixations for current image", visible=False,
732
- )
733
-
734
- # ── Controls ──────────────────────────────────────────────────────
735
- with gr.Row():
736
- duration_slider = gr.Slider(
737
- 0.1, 1.0, value=1.0, step=0.1,
738
- label="Fixation duration weight",
739
- )
740
- clear_btn = gr.Button("✖ Clear fixations")
741
-
742
- preset = gr.Radio(
743
- list(PRESETS.keys()), value=list(PRESETS.keys())[0],
744
- label="Preset",
745
- )
746
- threshold = gr.Slider(
747
- 0.1, 0.9, value=0.5, step=0.05,
748
- label="Mask threshold",
749
- )
750
- run_btn = gr.Button("▶ Run GazeRefine", variant="primary")
751
- segmentation_status = gr.Textbox(
752
- label="Segmentation status", value="Upload an image and add gaze points.",
753
- interactive=False, lines=2,
754
- )
755
 
756
- with gr.Accordion("🩻 Correct the gaze-attended region (RoentGen-v2)", open=False):
757
- gr.Markdown(
758
- "After segmentation, Gaussian noise is applied only inside the "
759
- "gaze-guided mask. RoentGen-v2 receives that image for image-to-image "
760
- "restoration; unmasked pixels are kept from the source image. Research use only."
761
- )
762
- correction_prompt = gr.Textbox(
763
- value="Normal chest radiograph.",
764
- label="Disease / radiology description for regeneration",
765
- placeholder="Example: Right lower-lobe opacity. No pleural effusion.",
766
- )
767
- with gr.Row():
768
- noise_strength = gr.Slider(0.0, 1.0, value=0.35, step=0.05, label="Gaussian noise")
769
- mask_feather = gr.Slider(0, 20, value=4, step=1, label="Mask feather (pixels)")
770
- with gr.Row():
771
- diffusion_strength = gr.Slider(0.05, 1.0, value=0.35, step=0.05, label="Diffusion strength")
772
- diffusion_steps = gr.Slider(10, 75, value=30, step=1, label="Diffusion steps")
773
- diffusion_seed = gr.Number(value=42, precision=0, label="Seed (-1 = random)")
774
- correct_btn = gr.Button("✨ Noise attended region and correct", variant="secondary")
775
- one_click_btn = gr.Button(
776
- "▶ Segment → noise attended region → regenerate", variant="primary"
777
- )
778
- correction_status = gr.Textbox(
779
- label="Correction status", value="Run GazeRefine, then start correction.",
780
- interactive=False, lines=3,
781
- )
782
-
783
- # ── Right column: outputs ─────────────────────────────────────────────
784
- with gr.Column(scale=1):
785
- gaze_out = gr.Image(label="Gaze prior", height=260)
786
- with gr.Row():
787
- mask_overlay_out = gr.Image(label="Mask overlay", height=260)
788
- mask_only_out = gr.Image(label="Binary mask", height=260)
789
- with gr.Row():
790
- noised_out = gr.Image(label="Gaussian-noised attended region", height=260)
791
- corrected_out = gr.Image(label="RoentGen corrected image", height=260)
792
-
793
- # ── Event wiring ──────────────────────────────────────────────────────────
794
-
795
- _upload_outputs = [orig_image_state, points_state, image_name_state,
796
- upload_zone, image_panel, delete_btn, attention_mask_state]
797
-
798
- upload_zone.upload(on_file_upload, inputs=[upload_zone], outputs=_upload_outputs)
799
- upload_zone.change(on_file_upload, inputs=[upload_zone], outputs=_upload_outputs)
800
-
801
- image_panel.select(
802
- on_select,
803
- inputs=[orig_image_state, points_state, duration_slider],
804
- outputs=[points_state, image_panel, attention_mask_state],
805
- )
806
-
807
- clear_btn.click(
808
- on_clear,
809
- inputs=[orig_image_state],
810
- outputs=[points_state, image_panel, attention_mask_state],
811
- )
812
-
813
- delete_btn.click(
814
- on_delete,
815
- outputs=[orig_image_state, points_state, image_name_state,
816
- upload_zone, image_panel, delete_btn, attention_mask_state],
817
- )
818
-
819
- _fixfile_outputs = [fixfile_df_state, mapping_row, id_col_dd, x_col_dd, y_col_dd, dur_col_dd, apply_fix_btn]
820
-
821
- fixfile_upload.upload(on_fixfile_upload, inputs=[fixfile_upload], outputs=_fixfile_outputs)
822
- fixfile_upload.change(on_fixfile_upload, inputs=[fixfile_upload], outputs=_fixfile_outputs)
823
-
824
- apply_fix_btn.click(
825
- on_apply_fixfile,
826
- inputs=[fixfile_df_state, id_col_dd, x_col_dd, y_col_dd, dur_col_dd,
827
- orig_image_state, image_name_state],
828
- outputs=[points_state, image_panel, attention_mask_state],
829
- )
830
-
831
- run_btn.click(
832
- run,
833
- inputs=[orig_image_state, points_state, preset, threshold],
834
- outputs=[gaze_out, mask_overlay_out, mask_only_out, attention_mask_state,
835
- segmentation_status],
836
- )
837
 
838
- correct_btn.click(
839
- correct_attention_region,
840
- inputs=[orig_image_state, attention_mask_state, noise_strength, mask_feather,
841
- diffusion_strength, diffusion_steps, correction_prompt, diffusion_seed],
842
- outputs=[noised_out, corrected_out, correction_status],
843
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
844
 
845
- one_click_btn.click(
846
- segment_noise_and_regenerate,
847
- inputs=[orig_image_state, points_state, preset, threshold, correction_prompt,
848
- noise_strength, mask_feather, diffusion_strength, diffusion_steps,
849
- diffusion_seed],
850
- outputs=[gaze_out, mask_overlay_out, mask_only_out, attention_mask_state,
851
- noised_out, corrected_out, correction_status],
852
- )
853
-
854
- gr.Markdown(
855
- "Segmentation: frozen DINOv3 + gaze-anchored prototypes + recurrent "
856
- "foreground/background refinement. Correction: attention-local Gaussian noise "
857
- "followed by optional RoentGen-v2 image-to-image restoration."
858
- )
859
-
860
- demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
 
1
+ """GazeCorrect: gaze -> Gaussian attention/noise -> text-guided regeneration."""
2
+ from __future__ import annotations
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ # ZeroGPU must be imported before torch. Safe fallback for local/regular Spaces.
 
 
5
  try:
6
  import spaces
7
  except ImportError:
8
+ class _Spaces:
9
  @staticmethod
10
+ def GPU(function): return function
11
+ spaces = _Spaces()
12
+
13
+ import os
14
+ from functools import lru_cache
15
+
16
+ import gradio as gr
17
+ import numpy as np
18
+ import pandas as pd
19
+ import torch
20
+ from PIL import Image, ImageDraw, ImageFilter
21
+
22
+ MODEL_ID = "stanfordmimi/RoentGen-v2"
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ def draw_points(image, points):
26
+ if image is None: return None
27
+ output = image.convert("RGB").copy(); draw = ImageDraw.Draw(output)
28
+ radius = max(6, min(output.size) // 70)
29
+ for i, (x, y, weight) in enumerate(points):
30
+ r = radius * (0.6 + 0.6 * weight)
31
+ draw.ellipse((x-r, y-r, x+r, y+r), outline=(255, 55, 45), width=3)
32
+ draw.text((x+r+2, y-r), str(i + 1), fill=(255, 55, 45))
33
+ return output
34
 
35
+
36
+ def load_image(file):
37
+ if file is None: return None, [], gr.update(value=None, visible=False), "Upload an image."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  try:
39
+ path = file if isinstance(file, str) else file.name
40
+ image = Image.open(path).convert("RGB")
41
+ return image, [], gr.update(value=image, visible=True), "Click the image or upload gaze CSV."
 
42
  except Exception as exc:
43
+ return None, [], gr.update(value=None, visible=False), f"Could not read image: {exc}"
44
+
45
+
46
+ def click_gaze(image, points, weight, event: gr.SelectData):
47
+ if image is None: return points, gr.update()
48
+ x, y = event.index
49
+ points = points + [(float(x), float(y), float(weight))]
50
+ return points, draw_points(image, points)
51
+
52
 
53
+ def load_csv(file, image):
54
+ if image is None: return gr.update(), gr.update(), "Upload image first."
55
  try:
56
+ path = file if isinstance(file, str) else file.name
57
+ frame = pd.read_csv(path, sep=None, engine="python")
58
+ names = {str(c).strip().lower(): c for c in frame.columns}
59
+ x_col, y_col = names.get("x") or names.get("gaze_x"), names.get("y") or names.get("gaze_y")
60
+ if not x_col or not y_col: raise ValueError("CSV must contain x,y columns (optional duration).")
61
+ x, y = frame[x_col].astype(float).to_numpy(), frame[y_col].astype(float).to_numpy()
62
+ duration = frame[names["duration"]].astype(float).to_numpy() if "duration" in names else np.ones(len(x))
63
+ w, h = image.size
64
+ if len(x) and min(x) >= 0 and min(y) >= 0 and max(x) <= 1.05 and max(y) <= 1.05: x, y = x*w, y*h
65
+ duration = duration / max(float(duration.max()), 1e-8)
66
+ points = [(float(np.clip(a, 0, w-1)), float(np.clip(b, 0, h-1)), float(c)) for a,b,c in zip(x,y,duration)]
67
+ return points, draw_points(image, points), f"Loaded {len(points)} gaze points."
68
  except Exception as exc:
69
+ return gr.update(), gr.update(), f"Could not read CSV: {exc}"
 
 
 
 
 
 
 
 
70
 
71
 
72
+ def attention(image, points, spread):
73
+ w, h = image.size; yy, xx = np.mgrid[:h, :w]
74
+ sigma = max(1, min(w, h) * float(spread) / 100)
75
+ heat = np.zeros((h, w), dtype=np.float32)
76
+ for x, y, weight in points:
77
+ heat += max(weight, .05) * np.exp(-((xx-x)**2 + (yy-y)**2)/(2*sigma**2))
78
+ return heat / (heat.max() + 1e-8)
 
 
 
 
 
 
 
 
 
 
 
 
79
 
 
 
80
 
81
+ def preview(image, heat):
82
+ color = np.zeros((*heat.shape, 3), np.uint8); color[..., 0] = (heat*255).astype(np.uint8)
83
+ return Image.blend(image.convert("RGB"), Image.fromarray(color), .5)
84
+
85
+
86
+ def noisy_image(image, heat, degree, feather, seed):
87
+ mask = Image.fromarray((heat*255).astype(np.uint8), "L")
88
+ if feather: mask = mask.filter(ImageFilter.GaussianBlur(float(feather)))
89
+ alpha = np.asarray(mask, np.float32)[..., None]/255
90
+ source = np.asarray(image.convert("RGB"), np.float32)
91
+ noise = np.random.default_rng(seed).normal(0, 255*float(degree), source.shape)
92
+ return Image.fromarray(np.clip(source + alpha*noise, 0, 255).astype(np.uint8)), mask
93
+
94
+
95
+ def device_dtype():
96
+ return ("cuda", torch.float16) if torch.cuda.is_available() else ("cpu", torch.float32)
97
+
98
+
99
+ @lru_cache(maxsize=1)
100
+ def pipeline():
101
+ from diffusers import StableDiffusionImg2ImgPipeline
102
+ device, dtype = device_dtype()
103
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
104
+ return StableDiffusionImg2ImgPipeline.from_pretrained(MODEL_ID, torch_dtype=dtype, token=token).to(device)
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
 
107
+ @spaces.GPU
108
+ def generate(image, points, description, spread, degree, feather, strength, steps, seed, progress=gr.Progress()):
109
+ if image is None: return None, None, None, "Upload an image first."
110
+ if not points: return None, None, None, "Add clicked or CSV gaze points first."
111
+ seed = None if seed < 0 else int(seed)
112
  try:
113
+ progress(.1, desc="Creating gaze attention")
114
+ heat = attention(image, points, spread); gaze = preview(image, heat)
115
+ progress(.25, desc="Adding attention-weighted Gaussian noise")
116
+ noised, mask = noisy_image(image, heat, degree, feather, seed)
117
+ progress(.4, desc="Regenerating with RoentGen-v2")
118
+ device, _ = device_dtype(); generator = None if seed is None else torch.Generator(device=device).manual_seed(seed)
119
+ output = pipeline()(prompt=description.strip() or "Normal chest radiograph.", image=noised,
120
+ strength=float(strength), guidance_scale=3.5,
121
+ num_inference_steps=int(steps), generator=generator).images[0].convert("RGB")
122
+ corrected = Image.composite(output.resize(image.size), image.convert("RGB"), mask)
123
+ return gaze, noised, corrected, "Completed."
 
 
 
 
 
 
 
 
124
  except Exception as exc:
125
+ return None, None, None, "Generation failed. Accept RoentGen access and set HF_TOKEN. Error: " + str(exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
+ with gr.Blocks(title="GazeCorrect") as demo:
129
+ gr.Markdown("# GazeCorrect\nImage + gaze clicks/CSV + disease description → attention noise → corrected regenerated image. Use chest X-rays only; research use only.")
130
+ image_state, points_state = gr.State(None), gr.State([])
131
+ with gr.Row():
132
+ with gr.Column():
133
+ upload = gr.File(label="1. Upload chest X-ray", file_types=[".png", ".jpg", ".jpeg", ".webp", ".bmp"])
134
+ panel = gr.Image(label="2. Click gaze points", type="pil", visible=False, interactive=False)
135
+ csv_file = gr.File(label="Or upload gaze CSV (x,y,duration)", file_types=[".csv"])
136
+ gaze_status = gr.Textbox(label="Gaze status", interactive=False)
137
+ weight = gr.Slider(.1, 1, value=1, step=.1, label="Next click weight")
138
+ description = gr.Textbox(label="3. Disease / radiology description", placeholder="Example: Right lower-lobe opacity. No pleural effusion.")
139
+ with gr.Accordion("Settings", open=False):
140
+ spread = gr.Slider(1, 25, value=8, step=1, label="Attention spread (%)")
141
+ degree = gr.Slider(0, 1, value=.35, step=.05, label="Gaussian noise degree")
142
+ feather = gr.Slider(0, 20, value=4, step=1, label="Mask feather")
143
+ strength = gr.Slider(.05, 1, value=.35, step=.05, label="Regeneration strength")
144
+ steps = gr.Slider(10, 50, value=25, step=1, label="Diffusion steps")
145
+ seed = gr.Number(value=42, precision=0, label="Seed (-1 random)")
146
+ button = gr.Button("4. Generate", variant="primary")
147
+ status = gr.Textbox(label="Generation status", interactive=False, lines=3)
148
+ with gr.Column():
149
+ gaze_out = gr.Image(label="Gaze attention map")
150
+ noise_out = gr.Image(label="Attention-weighted Gaussian-noise image")
151
+ corrected_out = gr.Image(label="Corrected regenerated image")
152
+ upload.upload(load_image, upload, [image_state, points_state, panel, gaze_status])
153
+ panel.select(click_gaze, [image_state, points_state, weight], [points_state, panel])
154
+ csv_file.upload(load_csv, [csv_file, image_state], [points_state, panel, gaze_status])
155
+ button.click(generate, [image_state, points_state, description, spread, degree, feather, strength, steps, seed], [gaze_out, noise_out, corrected_out, status])
156
 
157
+ demo.queue().launch(server_name="0.0.0.0", server_port=7860, show_error=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,16 +1,9 @@
1
- torch>=2.1
2
- torchvision>=0.16
3
  gradio==4.44.1
4
- starlette>=0.37.2,<0.39
5
- timm>=1.0.0
6
  numpy
7
  pandas
8
  pillow
9
- matplotlib
10
- pydicom
11
- openpyxl
12
- huggingface-hub==0.24.0
13
- pydub==0.25.1
14
  diffusers>=0.30
15
  transformers>=4.44
16
  accelerate>=0.33
 
 
 
 
1
  gradio==4.44.1
2
+ torch>=2.1
 
3
  numpy
4
  pandas
5
  pillow
 
 
 
 
 
6
  diffusers>=0.30
7
  transformers>=4.44
8
  accelerate>=0.33
9
+ huggingface-hub>=0.24