Spaces:
Running on Zero
Running on Zero
Upload 41 files
Browse files- README.md +17 -26
- __pycache__/app.cpython-310.pyc +0 -0
- app.py +133 -836
- requirements.txt +2 -9
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 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:
|
| 12 |
---
|
| 13 |
|
| 14 |
-
#
|
| 15 |
|
| 16 |
-
Interactive demo
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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
|
| 25 |
-
2. Click
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 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 |
-
-
|
| 68 |
-
|
| 69 |
-
-
|
| 70 |
-
|
| 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 |
-
|
| 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
|
| 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
|
| 36 |
@staticmethod
|
| 37 |
-
def GPU(
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 533 |
-
def
|
| 534 |
-
|
| 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 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
)
|
| 555 |
except Exception as exc:
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
|
|
|
|
|
|
|
| 561 |
try:
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
|
|
|
| 573 |
except Exception as exc:
|
| 574 |
-
|
| 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 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 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 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
try:
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
)
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 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 |
-
|
| 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 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
|
| 845 |
-
|
| 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 |
-
|
| 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
|