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