Update app.py
Browse files
app.py
CHANGED
|
@@ -1,19 +1,17 @@
|
|
| 1 |
"""
|
| 2 |
Glaucoma Visual Field Simulator v4
|
| 3 |
-
ββββββββββββββββββββββββββββββββββββ
|
| 4 |
-
|
| 5 |
-
β’ Optional header row β detected
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
β’
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
The app ships with built-in demo subjects from the GRAPE/PAPILA mini dataset.
|
| 17 |
"""
|
| 18 |
|
| 19 |
import gradio as gr
|
|
@@ -23,39 +21,167 @@ from scipy.ndimage import gaussian_filter
|
|
| 23 |
import os, csv
|
| 24 |
|
| 25 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
-
#
|
| 27 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
"
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
-
# VF
|
| 59 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
VF_GRID = [
|
| 61 |
[None,None,None,None,None,None,None,None,None],
|
|
@@ -70,33 +196,33 @@ VF_GRID = [
|
|
| 70 |
[None,None,51, 52, 53, 54, 55, 56, None],
|
| 71 |
[None,None,None,57, 58, 59, 60, None,None],
|
| 72 |
]
|
| 73 |
-
NROWS
|
| 74 |
-
NCOLS
|
| 75 |
-
BS_OD
|
| 76 |
-
BS_OS
|
| 77 |
-
|
| 78 |
|
| 79 |
|
| 80 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
# Colour helpers
|
| 82 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 83 |
def sens_fill(v, is_bs=False):
|
| 84 |
-
if is_bs:
|
| 85 |
-
if v is None or v < 0:
|
| 86 |
-
if v >= 25:
|
| 87 |
-
if v >= 20:
|
| 88 |
-
if v >= 15:
|
| 89 |
-
if v >= 10:
|
| 90 |
-
return
|
| 91 |
|
| 92 |
def sens_ink(v, is_bs=False):
|
| 93 |
-
if is_bs:
|
| 94 |
-
if v is None or v < 0:
|
| 95 |
-
if v >= 25:
|
| 96 |
-
if v >= 20:
|
| 97 |
-
if v >= 15:
|
| 98 |
-
if v >= 10:
|
| 99 |
-
return
|
| 100 |
|
| 101 |
|
| 102 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -116,28 +242,24 @@ def vf_points(laterality):
|
|
| 116 |
|
| 117 |
|
| 118 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 119 |
-
# Sensitivity field
|
| 120 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 121 |
-
def
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
ppd = min(W, H) / (2 * fov_deg)
|
| 125 |
val_map = np.zeros((H, W), dtype=np.float32)
|
| 126 |
wt_map = np.zeros((H, W), dtype=np.float32)
|
| 127 |
-
|
| 128 |
for xd, yd, vi, is_bs in vf_points(laterality):
|
| 129 |
v = vf[vi]
|
| 130 |
-
sens = 0.0 if (is_bs or v < 0) else min(v /
|
| 131 |
px = int(np.clip(cx + xd * ppd, 0, W - 1))
|
| 132 |
py = int(np.clip(cy - yd * ppd, 0, H - 1))
|
| 133 |
val_map[py, px] += sens
|
| 134 |
wt_map [py, px] += 1.0
|
| 135 |
-
|
| 136 |
vs = gaussian_filter(val_map, sigma=sigma)
|
| 137 |
ws = gaussian_filter(wt_map, sigma=sigma)
|
| 138 |
with np.errstate(invalid="ignore", divide="ignore"):
|
| 139 |
field = np.where(ws > 1e-6, vs / ws, 1.0)
|
| 140 |
-
|
| 141 |
Y, X = np.mgrid[0:H, 0:W]
|
| 142 |
outside = np.sqrt((X - cx)**2 + (Y - cy)**2) > fov_deg * ppd * 1.05
|
| 143 |
field[outside] = 1.0
|
|
@@ -147,65 +269,68 @@ def build_field(vf, laterality, W, H, fov_deg=36, sigma=30):
|
|
| 147 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 148 |
# Binocular scene simulation
|
| 149 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
-
_FULL = None # sentinel for "eye not available β full sensitivity"
|
| 151 |
-
|
| 152 |
-
def _full_field(W, H):
|
| 153 |
-
return np.ones((H, W), dtype=np.float32)
|
| 154 |
-
|
| 155 |
def apply_vf_binocular(img_pil, vf_od, vf_os, blur_scotoma, show_dots, sigma):
|
| 156 |
"""
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
Soft
|
| 161 |
-
|
| 162 |
"""
|
| 163 |
img = img_pil.convert("RGB")
|
| 164 |
W, H = img.size
|
| 165 |
arr = np.array(img, dtype=np.float32)
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 169 |
|
| 170 |
-
cx = W / 2
|
| 171 |
-
xn = (np.arange(W) - cx) / (W / 2)
|
| 172 |
-
od_w = np.clip(-xn + 0.5, 0, 1)[None, :] * np.ones((H, 1))
|
| 173 |
-
os_w = np.clip( xn + 0.5, 0, 1)[None, :] * np.ones((H, 1))
|
| 174 |
-
bino = (od_w *
|
| 175 |
|
| 176 |
if blur_scotoma:
|
| 177 |
blur_amt = np.clip(1.0 - bino, 0, 1)
|
| 178 |
blurred = np.array(img.filter(ImageFilter.GaussianBlur(radius=14)), dtype=np.float32)
|
| 179 |
-
arr = arr * (1 - blur_amt[:,:,None]) + blurred * blur_amt[:,:,None]
|
| 180 |
|
| 181 |
-
arr = np.clip(arr * bino[:,:,None], 0, 255).astype(np.uint8)
|
| 182 |
result = Image.fromarray(arr)
|
| 183 |
|
| 184 |
if show_dots:
|
| 185 |
-
overlay = Image.new("RGBA", (W, H), (0,0,0,0))
|
| 186 |
od = ImageDraw.Draw(overlay)
|
| 187 |
-
|
|
|
|
| 188 |
for vf, lat in [(vf_od, "OD"), (vf_os, "OS")]:
|
| 189 |
if vf is None:
|
| 190 |
continue
|
| 191 |
for xd, yd, vi, is_bs in vf_points(lat):
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
| 196 |
result = Image.alpha_composite(result.convert("RGBA"), overlay).convert("RGB")
|
| 197 |
|
| 198 |
return result
|
| 199 |
|
| 200 |
|
| 201 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 202 |
-
# VF sensitivity grid
|
| 203 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 204 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
CELL = 40
|
| 206 |
PAD_X = 52
|
| 207 |
-
PAD_TOP =
|
| 208 |
-
GAP =
|
| 209 |
LEG_H = 58
|
| 210 |
AXIS_GAP = 6
|
| 211 |
|
|
@@ -213,40 +338,53 @@ def make_vf_grid(vf_od, vf_os, subject_label, info_str):
|
|
| 213 |
panel_h = NROWS * CELL
|
| 214 |
n_eyes = (1 if vf_od else 0) + (1 if vf_os else 0)
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
total_w = PAD_X * 2 + n_eyes * panel_w + (n_eyes - 1) * GAP
|
| 217 |
total_h = PAD_TOP + panel_h + 24 + LEG_H
|
| 218 |
|
| 219 |
canvas = Image.new("RGB", (total_w, total_h), (248, 248, 250))
|
| 220 |
draw = ImageDraw.Draw(canvas)
|
| 221 |
|
| 222 |
-
|
| 223 |
-
draw.text((PAD_X,
|
|
|
|
| 224 |
|
| 225 |
-
def
|
| 226 |
bs = BS_OD if lat == "OD" else BS_OS
|
| 227 |
|
| 228 |
-
|
|
|
|
| 229 |
|
|
|
|
| 230 |
for r in range(NROWS):
|
| 231 |
yd = (4 - r) * 6
|
| 232 |
if yd % 12 == 0:
|
| 233 |
lbl = f"{yd:+d}Β°"
|
| 234 |
-
draw.text((ox - len(lbl)*6 - AXIS_GAP - 2,
|
| 235 |
-
|
|
|
|
| 236 |
for c in range(NCOLS):
|
| 237 |
-
|
|
|
|
| 238 |
if xd % 12 == 0:
|
| 239 |
draw.text((ox + c*CELL + 2, PAD_TOP + panel_h + AXIS_GAP),
|
| 240 |
f"{xd:+d}Β°", fill=(150,150,150))
|
| 241 |
|
|
|
|
| 242 |
fx = ox + 4*CELL + CELL//2
|
| 243 |
fy = PAD_TOP + 4*CELL + CELL//2
|
| 244 |
draw.line([(fx-9,fy),(fx+9,fy)], fill=(180,50,50), width=2)
|
| 245 |
draw.line([(fx,fy-9),(fx,fy+9)], fill=(180,50,50), width=2)
|
| 246 |
|
|
|
|
| 247 |
for r, row in enumerate(VF_GRID):
|
| 248 |
for c, vi in enumerate(row):
|
| 249 |
-
x0 = ox + c*CELL
|
|
|
|
| 250 |
x1, y1 = x0+CELL-2, y0+CELL-2
|
| 251 |
if vi is None:
|
| 252 |
draw.rectangle([x0,y0,x1,y1], fill=(238,238,240), outline=(220,220,222))
|
|
@@ -254,279 +392,114 @@ def make_vf_grid(vf_od, vf_os, subject_label, info_str):
|
|
| 254 |
is_bs = vi in bs
|
| 255 |
v = vf[vi]
|
| 256 |
draw.rectangle([x0,y0,x1,y1], fill=sens_fill(v,is_bs), outline=(255,255,255))
|
| 257 |
-
lbl = "BS" if is_bs else ("β" if v<0 else str(int(v)))
|
| 258 |
-
|
| 259 |
-
|
| 260 |
|
| 261 |
-
|
| 262 |
if vf_od:
|
| 263 |
-
|
|
|
|
| 264 |
if vf_os:
|
| 265 |
-
|
| 266 |
|
| 267 |
# Legend
|
| 268 |
tiers = [
|
| 269 |
-
("β₯25 dB",(8,80,65),(225,245,238)),
|
| 270 |
-
("
|
| 271 |
-
("
|
| 272 |
-
("
|
|
|
|
|
|
|
|
|
|
| 273 |
]
|
| 274 |
leg_y = PAD_TOP + panel_h + 24 + 4
|
| 275 |
sw = (total_w - PAD_X*2) // len(tiers)
|
| 276 |
for i, (lbl, bg, fg) in enumerate(tiers):
|
| 277 |
lx = PAD_X + i*sw
|
| 278 |
-
draw.rectangle([lx,leg_y,lx+sw-3,leg_y+24], fill=bg)
|
| 279 |
-
draw.text((lx+4,leg_y+5), lbl, fill=fg)
|
| 280 |
draw.text((PAD_X, leg_y+32),
|
| 281 |
"Fixation cross = (0Β°,0Β°) Β· Axis = degrees from fixation Β· BS = blind spot",
|
| 282 |
fill=(165,165,165))
|
| 283 |
-
return canvas
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½ββββββββββββββββββ
|
| 287 |
-
# File parser β groups rows by subject, expects OD + OS pair
|
| 288 |
-
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 289 |
-
_LAT_MAP = {"OD":"OD","R":"OD","RIGHT":"OD","RE":"OD",
|
| 290 |
-
"OS":"OS","L":"OS","LEFT":"OS","LE":"OS"}
|
| 291 |
|
| 292 |
-
|
| 293 |
-
_ID_NAMES = {"subject","id","patient","sub","patient_id","subject_id"}
|
| 294 |
-
_LAT_NAMES = {"laterality","lat","eye","side"}
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
def _parse_vf_value(v):
|
| 298 |
-
"""Convert a cell to float; return -1.0 for missing/scotoma."""
|
| 299 |
-
if v is None or str(v).strip() in ("", "/", "NA", "na", "NaN"):
|
| 300 |
-
return -1.0
|
| 301 |
-
try:
|
| 302 |
-
return float(v)
|
| 303 |
-
except (ValueError, TypeError):
|
| 304 |
-
return -1.0
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
def _read_raw_rows(filepath):
|
| 308 |
-
"""Return list-of-lists from XLSX or delimited file."""
|
| 309 |
-
ext = os.path.splitext(filepath)[1].lower()
|
| 310 |
-
if ext in {".xlsx", ".xls"}:
|
| 311 |
-
import openpyxl
|
| 312 |
-
wb = openpyxl.load_workbook(filepath, data_only=True)
|
| 313 |
-
ws = wb.active
|
| 314 |
-
return [list(row) for row in ws.iter_rows(values_only=True)
|
| 315 |
-
if any(v is not None for v in row)]
|
| 316 |
-
else:
|
| 317 |
-
with open(filepath, newline="", encoding="utf-8-sig") as f:
|
| 318 |
-
sample = f.read(4096)
|
| 319 |
-
counts = {d: sample.count(d) for d in [",","\t",";"," "]}
|
| 320 |
-
delim = max(counts, key=counts.get)
|
| 321 |
-
with open(filepath, newline="", encoding="utf-8-sig") as f:
|
| 322 |
-
return [r for r in csv.reader(f, delimiter=delim) if any(c.strip() for c in r)]
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
def parse_file_to_subjects(filepath):
|
| 326 |
-
"""
|
| 327 |
-
Parse file into a dict: subject_label β {"OD": [...], "OS": [...], "info": str}
|
| 328 |
-
|
| 329 |
-
Returns (subjects_dict, status_message).
|
| 330 |
-
"""
|
| 331 |
-
if filepath is None:
|
| 332 |
-
return {}, "No file provided."
|
| 333 |
-
|
| 334 |
-
try:
|
| 335 |
-
raw = _read_raw_rows(filepath)
|
| 336 |
-
except Exception as e:
|
| 337 |
-
return {}, f"Could not read file: {e}"
|
| 338 |
-
|
| 339 |
-
if not raw:
|
| 340 |
-
return {}, "File appears to be empty."
|
| 341 |
-
|
| 342 |
-
# ββ Detect header row ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 343 |
-
first = [str(v).strip().lower() if v is not None else "" for v in raw[0]]
|
| 344 |
-
has_header = any(f in _ID_NAMES or f in _LAT_NAMES for f in first)
|
| 345 |
-
|
| 346 |
-
if has_header:
|
| 347 |
-
header = first
|
| 348 |
-
data_rows = raw[1:]
|
| 349 |
-
# Find column indices
|
| 350 |
-
id_col = next((i for i,h in enumerate(header) if h in _ID_NAMES), 0)
|
| 351 |
-
lat_col = next((i for i,h in enumerate(header) if h in _LAT_NAMES), 1)
|
| 352 |
-
# VF columns: everything that isn't id or lat (look for vf_* or numeric-named cols)
|
| 353 |
-
vf_cols = [i for i,h in enumerate(header)
|
| 354 |
-
if i not in {id_col, lat_col} and len(header) - i <= 61 + 5]
|
| 355 |
-
# Simpler: just take the 61 cols after lat_col
|
| 356 |
-
vf_start = lat_col + 1
|
| 357 |
-
else:
|
| 358 |
-
data_rows = raw
|
| 359 |
-
id_col = 0
|
| 360 |
-
lat_col = 1
|
| 361 |
-
vf_start = 2
|
| 362 |
-
|
| 363 |
-
# ββ Group rows by subject ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 364 |
-
from collections import defaultdict
|
| 365 |
-
grouped = defaultdict(dict) # {subject_id: {"OD": vf_list, "OS": vf_list}}
|
| 366 |
-
skipped = []
|
| 367 |
-
|
| 368 |
-
for ri, row in enumerate(data_rows, start=2 if has_header else 1):
|
| 369 |
-
# Pad row if short
|
| 370 |
-
row = list(row) + [None] * max(0, vf_start + 61 - len(row))
|
| 371 |
-
|
| 372 |
-
sid = str(row[id_col]).strip() if row[id_col] is not None else f"row{ri}"
|
| 373 |
-
lat_raw = str(row[lat_col]).strip().upper() if row[lat_col] is not None else ""
|
| 374 |
-
lat = _LAT_MAP.get(lat_raw)
|
| 375 |
-
|
| 376 |
-
if lat is None:
|
| 377 |
-
skipped.append(f"Row {ri}: unrecognised laterality '{lat_raw}' β skipped.")
|
| 378 |
-
continue
|
| 379 |
-
|
| 380 |
-
vf_raw = row[vf_start : vf_start + 61]
|
| 381 |
-
if len(vf_raw) < 61:
|
| 382 |
-
skipped.append(f"Row {ri} (ID={sid}): only {len(vf_raw)} VF values β need 61. Skipped.")
|
| 383 |
-
continue
|
| 384 |
-
|
| 385 |
-
vf = [_parse_vf_value(v) for v in vf_raw]
|
| 386 |
-
|
| 387 |
-
if lat in grouped[sid]:
|
| 388 |
-
skipped.append(f"Row {ri} (ID={sid}): duplicate {lat} β overwriting previous entry.")
|
| 389 |
-
grouped[sid][lat] = vf
|
| 390 |
-
|
| 391 |
-
if not grouped:
|
| 392 |
-
msg = "No valid rows parsed."
|
| 393 |
-
if skipped:
|
| 394 |
-
msg += "\n" + "\n".join(skipped[:5])
|
| 395 |
-
return {}, msg
|
| 396 |
-
|
| 397 |
-
# ββ Build subject dict ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 398 |
-
subjects = {}
|
| 399 |
-
for sid, eyes in grouped.items():
|
| 400 |
-
vf_od = eyes.get("OD")
|
| 401 |
-
vf_os = eyes.get("OS")
|
| 402 |
-
eyes_present = " + ".join(k for k in ["OD","OS"] if eyes.get(k) is not None)
|
| 403 |
-
|
| 404 |
-
def _mean(vf, lat):
|
| 405 |
-
if vf is None: return None
|
| 406 |
-
bs = BS_OD if lat=="OD" else BS_OS
|
| 407 |
-
vals = [v for i,v in enumerate(vf) if i not in bs and v>=0]
|
| 408 |
-
return round(np.mean(vals),1) if vals else None
|
| 409 |
-
|
| 410 |
-
parts = []
|
| 411 |
-
m = _mean(vf_od,"OD")
|
| 412 |
-
if m is not None: parts.append(f"OD mean {m} dB")
|
| 413 |
-
m = _mean(vf_os,"OS")
|
| 414 |
-
if m is not None: parts.append(f"OS mean {m} dB")
|
| 415 |
-
info = f"ID {sid} Β· {eyes_present} Β· " + " Β· ".join(parts)
|
| 416 |
-
|
| 417 |
-
subjects[f"{sid}"] = {"OD": vf_od, "OS": vf_os, "info": info}
|
| 418 |
-
|
| 419 |
-
n_ok = len(subjects)
|
| 420 |
-
n_skip = len(skipped)
|
| 421 |
-
msg = f"β Loaded {n_ok} subject(s)"
|
| 422 |
-
if n_skip:
|
| 423 |
-
msg += f" ({n_skip} row(s) skipped β see console)"
|
| 424 |
-
for s in skipped: print("[VF parser]", s)
|
| 425 |
-
|
| 426 |
-
return subjects, msg
|
| 427 |
|
| 428 |
|
| 429 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 430 |
-
#
|
| 431 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
| 437 |
|
| 438 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
|
| 440 |
|
| 441 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 442 |
-
# Default scene
|
| 443 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 444 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
W, H = 640, 400
|
| 446 |
-
img = Image.new("RGB", (W, H))
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
t = y/220
|
| 450 |
-
draw.line([(0,y),(W,y)], fill=(int(120+t*40),int(180-t*50),int(230-t*30)))
|
| 451 |
-
for y in range(220, H):
|
| 452 |
-
t = (y-220)/(H-220)
|
| 453 |
-
draw.line([(0,y),(W,y)], fill=(int(70+t*25),int(110+t*15),55))
|
| 454 |
-
draw.polygon([(240,400),(400,400),(340,215),(300,215)], fill=(75,75,75))
|
| 455 |
-
for i in range(6):
|
| 456 |
-
y1=235+i*28; w=2+i*2
|
| 457 |
-
draw.rectangle([320-w,y1,320+w,y1+14], fill=(240,240,240))
|
| 458 |
-
draw.rectangle([15,110,215,395], fill=(175,135,95))
|
| 459 |
-
draw.rectangle([15, 90,215,115], fill=(155,115,75))
|
| 460 |
-
for wx in [35,80,125,170]:
|
| 461 |
-
for wy in [135,195,255,315]:
|
| 462 |
-
draw.rectangle([wx,wy,wx+28,wy+38], fill=(145,190,225))
|
| 463 |
-
draw.rectangle([430,75,625,395], fill=(155,155,165))
|
| 464 |
-
draw.rectangle([430,55,625, 80], fill=(135,135,145))
|
| 465 |
-
for wx in [445,490,540,582]:
|
| 466 |
-
for wy in [95,155,215,275,335]:
|
| 467 |
-
draw.rectangle([wx,wy,wx+32,wy+42], fill=(135,180,215))
|
| 468 |
-
for tx,ty in [(150,155),(475,148),(500,162)]:
|
| 469 |
-
draw.ellipse([tx-28,ty-45,tx+28,ty+22], fill=(28,95,28))
|
| 470 |
-
draw.rectangle([tx-5,ty+18,tx+5,ty+58], fill=(75,45,18))
|
| 471 |
-
for px,py in [(265,318),(312,298),(375,312)]:
|
| 472 |
-
draw.ellipse([px-9,py-32,px+9,py-14], fill=(55,38,28))
|
| 473 |
-
draw.rectangle([px-7,py-14,px+7,py+12], fill=(75,55,120))
|
| 474 |
-
draw.rectangle([px-11,py+12,px-4,py+32], fill=(58,48,78))
|
| 475 |
-
draw.rectangle([px+4, py+12,px+11,py+32], fill=(58,48,78))
|
| 476 |
-
draw.ellipse([558,28,608,78], fill=(255,238,90))
|
| 477 |
-
return img.filter(ImageFilter.SMOOTH)
|
| 478 |
-
|
| 479 |
-
DEFAULT_SCENE = create_default_scene()
|
| 480 |
|
|
|
|
| 481 |
|
| 482 |
-
#
|
| 483 |
-
|
| 484 |
-
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 485 |
-
def make_banner(subject_label, info_str, W):
|
| 486 |
-
panel = Image.new("RGB", (W, 72), (245,245,248))
|
| 487 |
-
draw = ImageDraw.Draw(panel)
|
| 488 |
-
draw.text((14, 10), f"Subject: {subject_label}"[:100], fill=(30,30,30))
|
| 489 |
-
draw.text((14, 34), str(info_str)[:110], fill=(55,75,140))
|
| 490 |
-
return panel
|
| 491 |
|
| 492 |
|
| 493 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 494 |
# Gradio callbacks
|
| 495 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 496 |
-
def
|
| 497 |
global _subjects
|
| 498 |
if filepath is None:
|
| 499 |
-
|
| 500 |
choices = list(_subjects.keys())
|
| 501 |
-
return gr.update(choices=choices, value=choices[0]), "
|
| 502 |
|
| 503 |
-
loaded, msg =
|
| 504 |
if not loaded:
|
| 505 |
-
|
| 506 |
choices = list(_subjects.keys())
|
| 507 |
-
return gr.update(choices=choices, value=choices[0]), f"β {msg} Falling back to
|
| 508 |
|
| 509 |
_subjects = loaded
|
| 510 |
choices = list(_subjects.keys())
|
| 511 |
return gr.update(choices=choices, value=choices[0]), msg
|
| 512 |
|
| 513 |
|
| 514 |
-
def
|
| 515 |
-
|
|
|
|
| 516 |
choices = list(_subjects.keys())
|
| 517 |
-
return gr.update(choices=choices, value=choices[0]), "Cleared β
|
| 518 |
|
| 519 |
|
| 520 |
-
def
|
| 521 |
-
|
| 522 |
-
if
|
| 523 |
-
return None
|
| 524 |
|
| 525 |
-
vf_od =
|
| 526 |
-
vf_os =
|
| 527 |
-
info = sub.get("info", "")
|
| 528 |
|
| 529 |
-
# ββ Scene ββ
|
| 530 |
if input_image is None:
|
| 531 |
scene = DEFAULT_SCENE.copy()
|
| 532 |
elif isinstance(input_image, np.ndarray):
|
|
@@ -536,7 +509,7 @@ def run_all(subject_label, input_image, blur_scotoma, show_dots, smoothing):
|
|
| 536 |
scene = scene.resize((640, 400), Image.LANCZOS)
|
| 537 |
|
| 538 |
simulated = apply_vf_binocular(scene, vf_od, vf_os, blur_scotoma, show_dots, smoothing)
|
| 539 |
-
banner =
|
| 540 |
|
| 541 |
W, H = scene.size
|
| 542 |
canvas = Image.new("RGB", (W*2+8, H+banner.height+4), (220,220,225))
|
|
@@ -544,45 +517,55 @@ def run_all(subject_label, input_image, blur_scotoma, show_dots, smoothing):
|
|
| 544 |
canvas.paste(simulated, (W+8, 0))
|
| 545 |
canvas.paste(banner, (0, H+4))
|
| 546 |
d = ImageDraw.Draw(canvas)
|
| 547 |
-
for lx, txt in [(6,"Normal vision"),(W+14,"Simulated VF loss")]:
|
| 548 |
-
d.rectangle([lx,4,lx+156,20], fill=(0,0,0))
|
| 549 |
-
d.text((lx+5,5), txt, fill=(255,255,255))
|
| 550 |
|
| 551 |
-
|
| 552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
|
| 554 |
-
|
|
|
|
|
|
|
|
|
|
| 555 |
|
| 556 |
|
| 557 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 558 |
-
# UI
|
| 559 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 560 |
_init_choices = list(_subjects.keys())
|
| 561 |
|
| 562 |
with gr.Blocks(title="Glaucoma VF Simulator") as demo:
|
| 563 |
gr.Markdown(
|
| 564 |
"# Glaucoma Visual Field Simulator\n"
|
| 565 |
-
"Upload
|
| 566 |
-
"or
|
| 567 |
-
"
|
| 568 |
-
"
|
| 569 |
-
"Laterality: `OD`/`OS`/`R`/`L`. "
|
| 570 |
-
"VF values in dB; use `β1` or blank for scotoma / blind spot."
|
| 571 |
)
|
| 572 |
|
| 573 |
with gr.Row():
|
| 574 |
-
# ββ
|
| 575 |
with gr.Column(scale=1, min_width=310):
|
|
|
|
| 576 |
with gr.Group():
|
| 577 |
gr.Markdown("### Data source")
|
| 578 |
vf_file = gr.File(
|
| 579 |
-
label="Upload VF file (XLSX / CSV / TSV
|
| 580 |
-
file_types=[".xlsx",".xls",".csv",".tsv",".txt"],
|
| 581 |
type="filepath",
|
| 582 |
)
|
| 583 |
file_status = gr.Textbox(
|
| 584 |
-
value="
|
| 585 |
-
label="Status",
|
|
|
|
|
|
|
| 586 |
)
|
| 587 |
clear_btn = gr.Button("β Clear / reset to demo subjects",
|
| 588 |
size="sm", variant="secondary")
|
|
@@ -592,7 +575,7 @@ with gr.Blocks(title="Glaucoma VF Simulator") as demo:
|
|
| 592 |
subject_dd = gr.Dropdown(
|
| 593 |
choices=_init_choices,
|
| 594 |
value=_init_choices[0],
|
| 595 |
-
label="Select subject
|
| 596 |
interactive=True,
|
| 597 |
)
|
| 598 |
|
|
@@ -600,38 +583,38 @@ with gr.Blocks(title="Glaucoma VF Simulator") as demo:
|
|
| 600 |
label="Scene β upload a photo or keep the default",
|
| 601 |
value=np.array(DEFAULT_SCENE),
|
| 602 |
type="numpy",
|
| 603 |
-
sources=["upload","clipboard"],
|
| 604 |
)
|
| 605 |
with gr.Accordion("Simulation options", open=True):
|
| 606 |
blur_cb = gr.Checkbox(value=True, label="Blur scotoma regions (diffusion)")
|
| 607 |
dots_cb = gr.Checkbox(value=False, label="Show VF test-point dots on scene")
|
| 608 |
smooth_sl = gr.Slider(10, 60, value=28, step=2,
|
| 609 |
-
label="Field smoothness (Gaussian Ο
|
| 610 |
run_btn = gr.Button("βΆ Run simulation", variant="primary")
|
| 611 |
|
| 612 |
-
# ββ
|
| 613 |
with gr.Column(scale=2):
|
| 614 |
with gr.Tabs():
|
| 615 |
with gr.TabItem("Vision simulation"):
|
| 616 |
sim_out = gr.Image(label="Normal vs Simulated VF loss", type="pil")
|
| 617 |
with gr.TabItem("VF sensitivity grid"):
|
| 618 |
-
grid_out = gr.Image(label="Annotated Humphrey 24-2 grid
|
| 619 |
|
| 620 |
gr.Markdown(
|
| 621 |
"**Colour scale** β "
|
| 622 |
"π© β₯25 dB Β· π’ 20β24 Β· π©΅ 15β19 Β· π‘ 10β14 Β· π <10 dB Β· π΄ scotoma Β· β« blind spot \n"
|
| 623 |
-
"*Binocular model: left visual
|
| 624 |
-
"soft blend at fixation. Missing eye β full sensitivity on that side. "
|
| 625 |
"Data: GRAPE/PAPILA baseline cohort (mini).*"
|
| 626 |
)
|
| 627 |
|
| 628 |
sim_inputs = [subject_dd, image_in, blur_cb, dots_cb, smooth_sl]
|
| 629 |
|
| 630 |
-
vf_file.change(fn=
|
| 631 |
-
clear_btn.click(fn=
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
|
|
|
| 635 |
|
| 636 |
|
| 637 |
if __name__ == "__main__":
|
|
|
|
| 1 |
"""
|
| 2 |
Glaucoma Visual Field Simulator v4
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 4 |
+
File format (XLSX / CSV / TSV):
|
| 5 |
+
β’ Optional header row β auto-detected if first cell is non-numeric.
|
| 6 |
+
β’ Expected columns:
|
| 7 |
+
col 0 subject / patient ID
|
| 8 |
+
col 1 laterality (OD | OS | R | L)
|
| 9 |
+
col 2β¦ 61 VF sensitivity values (dB; β1 or "/" = scotoma/blind spot)
|
| 10 |
+
β’ TWO rows per subject, one for OD and one for OS.
|
| 11 |
+
If only one eye is present the simulation runs monocularly.
|
| 12 |
+
|
| 13 |
+
The dropdown lists subjects (not individual rows).
|
| 14 |
+
Both eyes are combined into a binocular simulation and a side-by-side VF grid.
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
import gradio as gr
|
|
|
|
| 21 |
import os, csv
|
| 22 |
|
| 23 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
# Default dataset β loaded from the bundled example file at startup
|
| 25 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
|
| 27 |
+
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 28 |
+
DEFAULT_VF_FILE = os.path.join(_SCRIPT_DIR, "glaucoma_vf_example.xlsx")
|
| 29 |
+
|
| 30 |
+
# Module-level store: subject_id β {id, info, OD, OS}
|
| 31 |
+
_subjects: dict = {}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _load_demo():
|
| 35 |
+
"""Load subjects from the bundled example XLSX; hard-coded fallback if missing."""
|
| 36 |
+
global _subjects
|
| 37 |
+
if os.path.exists(DEFAULT_VF_FILE):
|
| 38 |
+
loaded, _ = parse_uploaded_file(DEFAULT_VF_FILE)
|
| 39 |
+
if loaded:
|
| 40 |
+
_subjects = loaded
|
| 41 |
+
return
|
| 42 |
+
# Fallback: Subject 1 OD+OS from GRAPE mini
|
| 43 |
+
_subjects = {
|
| 44 |
+
"1": {
|
| 45 |
+
"id": "1",
|
| 46 |
+
"info": "OD mean 19.8 dB Β· scotoma 2/61 | OS mean 23.7 dB Β· scotoma 2/61",
|
| 47 |
+
"OD": [21,22,20,23,24,25,14,25,25,20,18,21,16,18,18,23,22,23,25,24,26,-1,14,18,17,14,14,24,22,18,21,23,-1,21,18,19,19,20,22,25,17,14,14,16,16,16,18,19,20,21,19,20,22,21,23,26,14,13,19,20,21],
|
| 48 |
+
"OS": [24,26,23,26,26,27,23,26,28,26,24,26,22,22,22,24,25,28,27,27,27,-1,22,22,23,22,22,28,25,23,29,25,-1,21,20,20,20,21,21,22,21,22,23,26,26,22,21,22,25,27,23,21,22,21,21,21,23,25,22,25,22],
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
+
# File parser
|
| 55 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
def _norm_lat(raw):
|
| 57 |
+
s = str(raw).strip().upper()
|
| 58 |
+
return "OD" if s in {"OD", "R", "RIGHT", "RE"} else "OS"
|
| 59 |
+
|
| 60 |
+
_LAT_VALUES = {"OD", "OS", "R", "L", "RIGHT", "LEFT", "RE", "LE"}
|
| 61 |
+
|
| 62 |
+
def _row_is_header(row):
|
| 63 |
+
"""
|
| 64 |
+
True if the first row is a column-name header, not a data row.
|
| 65 |
+
We check column 1 (the laterality column): if it holds a recognised
|
| 66 |
+
laterality code the row is data; if it holds anything else (e.g. the
|
| 67 |
+
string "laterality") it is a header. This is robust to non-numeric
|
| 68 |
+
patient IDs such as "P001" or "sub_A".
|
| 69 |
+
"""
|
| 70 |
+
if not row or len(row) < 2:
|
| 71 |
+
return False
|
| 72 |
+
lat_cell = str(row[1]).strip().upper() if row[1] is not None else ""
|
| 73 |
+
return lat_cell not in _LAT_VALUES
|
| 74 |
+
|
| 75 |
+
def parse_uploaded_file(filepath):
|
| 76 |
+
"""
|
| 77 |
+
Parse XLSX / CSV / TSV into a dict of subjects.
|
| 78 |
+
|
| 79 |
+
Returns (subjects_dict, status_message)
|
| 80 |
+
subjects_dict: { subject_id: {id, info, OD, OS} }
|
| 81 |
+
"""
|
| 82 |
+
if filepath is None:
|
| 83 |
+
return {}, "No file provided."
|
| 84 |
+
|
| 85 |
+
ext = os.path.splitext(filepath)[1].lower()
|
| 86 |
+
raw_rows = []
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
if ext in {".xlsx", ".xls"}:
|
| 90 |
+
import openpyxl
|
| 91 |
+
wb = openpyxl.load_workbook(filepath, data_only=True)
|
| 92 |
+
ws = wb.active
|
| 93 |
+
for row in ws.iter_rows(values_only=True):
|
| 94 |
+
if all(v is None for v in row):
|
| 95 |
+
continue
|
| 96 |
+
raw_rows.append(list(row))
|
| 97 |
+
else:
|
| 98 |
+
with open(filepath, newline="", encoding="utf-8-sig") as f:
|
| 99 |
+
sample = f.read(4096)
|
| 100 |
+
delim = max([",", "\t", ";", " "], key=lambda d: sample.count(d))
|
| 101 |
+
with open(filepath, newline="", encoding="utf-8-sig") as f:
|
| 102 |
+
for row in csv.reader(f, delimiter=delim):
|
| 103 |
+
if row:
|
| 104 |
+
raw_rows.append(row)
|
| 105 |
+
except Exception as e:
|
| 106 |
+
return {}, f"Could not read file: {e}"
|
| 107 |
+
|
| 108 |
+
if not raw_rows:
|
| 109 |
+
return {}, "File appears to be empty."
|
| 110 |
+
|
| 111 |
+
# Skip header row if detected (checks laterality column, not ID column)
|
| 112 |
+
data_rows = raw_rows[1:] if _row_is_header(raw_rows[0]) else raw_rows
|
| 113 |
+
|
| 114 |
+
if not data_rows:
|
| 115 |
+
return {}, "Only a header row found β no data."
|
| 116 |
+
|
| 117 |
+
subjects = {} # subject_id β {id, info, OD, OS}
|
| 118 |
+
skipped = []
|
| 119 |
+
|
| 120 |
+
for ri, raw in enumerate(data_rows, start=2):
|
| 121 |
+
cells = [c for c in raw if c is not None and str(c).strip() not in ("", "None")]
|
| 122 |
+
if len(cells) < 63:
|
| 123 |
+
skipped.append(f"Row {ri}: {len(cells)} cols (need 63: subject + laterality + 61 VF). Skipped.")
|
| 124 |
+
continue
|
| 125 |
+
|
| 126 |
+
sid = str(cells[0]).strip()
|
| 127 |
+
lat = _norm_lat(cells[1])
|
| 128 |
+
|
| 129 |
+
vf_raw = cells[2:63] # exactly 61 values
|
| 130 |
+
try:
|
| 131 |
+
vf = [float(v) if str(v).strip() not in ("-1", "/", "", "None") or str(v).strip() == "-1"
|
| 132 |
+
else -1.0
|
| 133 |
+
for v in vf_raw]
|
| 134 |
+
# Normalise: anything non-numeric β -1
|
| 135 |
+
vf_clean = []
|
| 136 |
+
for v in vf_raw:
|
| 137 |
+
sv = str(v).strip()
|
| 138 |
+
if sv in ("/", "", "None"):
|
| 139 |
+
vf_clean.append(-1.0)
|
| 140 |
+
else:
|
| 141 |
+
try:
|
| 142 |
+
vf_clean.append(float(sv))
|
| 143 |
+
except ValueError:
|
| 144 |
+
vf_clean.append(-1.0)
|
| 145 |
+
vf = vf_clean
|
| 146 |
+
except Exception as e:
|
| 147 |
+
skipped.append(f"Row {ri} (ID={sid}): VF parse error β {e}. Skipped.")
|
| 148 |
+
continue
|
| 149 |
+
|
| 150 |
+
if sid not in subjects:
|
| 151 |
+
subjects[sid] = {"id": sid, "info": f"Subject {sid}", "OD": None, "OS": None}
|
| 152 |
+
subjects[sid][lat] = vf
|
| 153 |
+
|
| 154 |
+
if not subjects:
|
| 155 |
+
msg = "No valid subjects found."
|
| 156 |
+
if skipped:
|
| 157 |
+
msg += "\n" + "\n".join(skipped[:5])
|
| 158 |
+
return {}, msg
|
| 159 |
+
|
| 160 |
+
# Build info strings
|
| 161 |
+
for sid, s in subjects.items():
|
| 162 |
+
eyes = []
|
| 163 |
+
for lat in ("OD", "OS"):
|
| 164 |
+
vf = s[lat]
|
| 165 |
+
if vf is None:
|
| 166 |
+
eyes.append(f"{lat}: β")
|
| 167 |
+
else:
|
| 168 |
+
valid = [v for v in vf if v >= 0]
|
| 169 |
+
mean = f"{np.mean(valid):.1f}" if valid else "N/A"
|
| 170 |
+
scot = sum(1 for v in vf if v < 0)
|
| 171 |
+
eyes.append(f"{lat} mean {mean} dB Β· scotoma {scot}/61")
|
| 172 |
+
s["info"] = " | ".join(eyes)
|
| 173 |
+
|
| 174 |
+
if skipped:
|
| 175 |
+
print(f"[VF parser] {len(skipped)} rows skipped:")
|
| 176 |
+
for m in skipped:
|
| 177 |
+
print(" ", m)
|
| 178 |
+
|
| 179 |
+
n_eyes = sum(1 for s in subjects.values() for lat in ("OD","OS") if s[lat] is not None)
|
| 180 |
+
return subjects, f"β Loaded {len(subjects)} subject(s), {n_eyes} eye(s) total."
|
| 181 |
|
| 182 |
|
| 183 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 184 |
+
# VF layout
|
| 185 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 186 |
VF_GRID = [
|
| 187 |
[None,None,None,None,None,None,None,None,None],
|
|
|
|
| 196 |
[None,None,51, 52, 53, 54, 55, 56, None],
|
| 197 |
[None,None,None,57, 58, 59, 60, None,None],
|
| 198 |
]
|
| 199 |
+
NROWS = len(VF_GRID)
|
| 200 |
+
NCOLS = len(VF_GRID[0])
|
| 201 |
+
BS_OD = {21, 32}
|
| 202 |
+
BS_OS = {20, 33}
|
| 203 |
+
MAX_SENS = 30.0
|
| 204 |
|
| 205 |
|
| 206 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 207 |
# Colour helpers
|
| 208 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 209 |
def sens_fill(v, is_bs=False):
|
| 210 |
+
if is_bs: return (68, 68, 65)
|
| 211 |
+
if v is None or v < 0: return (216, 90, 48)
|
| 212 |
+
if v >= 25: return (8, 80, 65)
|
| 213 |
+
if v >= 20: return (29, 158, 117)
|
| 214 |
+
if v >= 15: return (159, 225, 203)
|
| 215 |
+
if v >= 10: return (250, 199, 117)
|
| 216 |
+
return (216, 90, 48)
|
| 217 |
|
| 218 |
def sens_ink(v, is_bs=False):
|
| 219 |
+
if is_bs: return (180, 178, 169)
|
| 220 |
+
if v is None or v < 0: return (250, 236, 231)
|
| 221 |
+
if v >= 25: return (225, 245, 238)
|
| 222 |
+
if v >= 20: return (4, 52, 44)
|
| 223 |
+
if v >= 15: return (4, 52, 44)
|
| 224 |
+
if v >= 10: return (65, 36, 2)
|
| 225 |
+
return (250, 236, 231)
|
| 226 |
|
| 227 |
|
| 228 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 242 |
|
| 243 |
|
| 244 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 245 |
+
# Sensitivity field
|
| 246 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 247 |
+
def build_sensitivity_field(vf, laterality, W, H, fov_deg=36, sigma=30):
|
| 248 |
+
cx, cy = W / 2, H / 2
|
| 249 |
+
ppd = min(W, H) / (2 * fov_deg)
|
|
|
|
| 250 |
val_map = np.zeros((H, W), dtype=np.float32)
|
| 251 |
wt_map = np.zeros((H, W), dtype=np.float32)
|
|
|
|
| 252 |
for xd, yd, vi, is_bs in vf_points(laterality):
|
| 253 |
v = vf[vi]
|
| 254 |
+
sens = 0.0 if (is_bs or v < 0) else min(v / MAX_SENS, 1.0)
|
| 255 |
px = int(np.clip(cx + xd * ppd, 0, W - 1))
|
| 256 |
py = int(np.clip(cy - yd * ppd, 0, H - 1))
|
| 257 |
val_map[py, px] += sens
|
| 258 |
wt_map [py, px] += 1.0
|
|
|
|
| 259 |
vs = gaussian_filter(val_map, sigma=sigma)
|
| 260 |
ws = gaussian_filter(wt_map, sigma=sigma)
|
| 261 |
with np.errstate(invalid="ignore", divide="ignore"):
|
| 262 |
field = np.where(ws > 1e-6, vs / ws, 1.0)
|
|
|
|
| 263 |
Y, X = np.mgrid[0:H, 0:W]
|
| 264 |
outside = np.sqrt((X - cx)**2 + (Y - cy)**2) > fov_deg * ppd * 1.05
|
| 265 |
field[outside] = 1.0
|
|
|
|
| 269 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 270 |
# Binocular scene simulation
|
| 271 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
def apply_vf_binocular(img_pil, vf_od, vf_os, blur_scotoma, show_dots, sigma):
|
| 273 |
"""
|
| 274 |
+
Binocular blend:
|
| 275 |
+
Left visual field (x < centre) β driven by OD (right eye, temporal field)
|
| 276 |
+
Right visual field (x > centre) β driven by OS (left eye, temporal field)
|
| 277 |
+
Soft hemifield crossfade Β±Β½ image-width around fixation.
|
| 278 |
+
If one eye is absent its field defaults to 1.0 (no loss) on its side.
|
| 279 |
"""
|
| 280 |
img = img_pil.convert("RGB")
|
| 281 |
W, H = img.size
|
| 282 |
arr = np.array(img, dtype=np.float32)
|
| 283 |
|
| 284 |
+
f_od = build_sensitivity_field(vf_od, "OD", W, H, sigma=sigma) if vf_od else np.ones((H, W), np.float32)
|
| 285 |
+
f_os = build_sensitivity_field(vf_os, "OS", W, H, sigma=sigma) if vf_os else np.ones((H, W), np.float32)
|
| 286 |
|
| 287 |
+
cx = W // 2
|
| 288 |
+
xn = (np.arange(W) - cx) / (W / 2) # β1 β¦ +1
|
| 289 |
+
od_w = np.clip(-xn + 0.5, 0, 1)[None, :] * np.ones((H, 1)) # higher left
|
| 290 |
+
os_w = np.clip( xn + 0.5, 0, 1)[None, :] * np.ones((H, 1)) # higher right
|
| 291 |
+
bino = (od_w * f_od + os_w * f_os) / (od_w + os_w)
|
| 292 |
|
| 293 |
if blur_scotoma:
|
| 294 |
blur_amt = np.clip(1.0 - bino, 0, 1)
|
| 295 |
blurred = np.array(img.filter(ImageFilter.GaussianBlur(radius=14)), dtype=np.float32)
|
| 296 |
+
arr = arr * (1 - blur_amt[:, :, None]) + blurred * blur_amt[:, :, None]
|
| 297 |
|
| 298 |
+
arr = np.clip(arr * bino[:, :, None], 0, 255).astype(np.uint8)
|
| 299 |
result = Image.fromarray(arr)
|
| 300 |
|
| 301 |
if show_dots:
|
| 302 |
+
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
| 303 |
od = ImageDraw.Draw(overlay)
|
| 304 |
+
fov_deg = 36
|
| 305 |
+
ppd = min(W, H) / (2 * fov_deg)
|
| 306 |
for vf, lat in [(vf_od, "OD"), (vf_os, "OS")]:
|
| 307 |
if vf is None:
|
| 308 |
continue
|
| 309 |
for xd, yd, vi, is_bs in vf_points(lat):
|
| 310 |
+
v = vf[vi]
|
| 311 |
+
px = int(W / 2 + xd * ppd)
|
| 312 |
+
py = int(H / 2 - yd * ppd)
|
| 313 |
+
r = 7
|
| 314 |
+
fg = sens_fill(v, is_bs) + (200,)
|
| 315 |
+
od.ellipse([px-r, py-r, px+r, py+r], fill=fg, outline=(255,255,255,90))
|
| 316 |
result = Image.alpha_composite(result.convert("RGBA"), overlay).convert("RGB")
|
| 317 |
|
| 318 |
return result
|
| 319 |
|
| 320 |
|
| 321 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 322 |
+
# VF sensitivity grid β both eyes side by side
|
| 323 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 324 |
+
def make_vf_grid_panel(subject):
|
| 325 |
+
vf_od = subject["OD"]
|
| 326 |
+
vf_os = subject["OS"]
|
| 327 |
+
sid = subject["id"]
|
| 328 |
+
info = subject["info"]
|
| 329 |
+
|
| 330 |
CELL = 40
|
| 331 |
PAD_X = 52
|
| 332 |
+
PAD_TOP = 54
|
| 333 |
+
GAP = 32
|
| 334 |
LEG_H = 58
|
| 335 |
AXIS_GAP = 6
|
| 336 |
|
|
|
|
| 338 |
panel_h = NROWS * CELL
|
| 339 |
n_eyes = (1 if vf_od else 0) + (1 if vf_os else 0)
|
| 340 |
|
| 341 |
+
if n_eyes == 0:
|
| 342 |
+
canvas = Image.new("RGB", (420, 80), (248, 248, 250))
|
| 343 |
+
ImageDraw.Draw(canvas).text((14, 28), "No VF data available.", fill=(80, 80, 80))
|
| 344 |
+
return canvas
|
| 345 |
+
|
| 346 |
total_w = PAD_X * 2 + n_eyes * panel_w + (n_eyes - 1) * GAP
|
| 347 |
total_h = PAD_TOP + panel_h + 24 + LEG_H
|
| 348 |
|
| 349 |
canvas = Image.new("RGB", (total_w, total_h), (248, 248, 250))
|
| 350 |
draw = ImageDraw.Draw(canvas)
|
| 351 |
|
| 352 |
+
# Header
|
| 353 |
+
draw.text((PAD_X, 8), f"Subject: {sid}", fill=(38, 38, 38))
|
| 354 |
+
draw.text((PAD_X, 26), info[:total_w // 7], fill=(80, 80, 120))
|
| 355 |
|
| 356 |
+
def draw_one(vf, lat, ox):
|
| 357 |
bs = BS_OD if lat == "OD" else BS_OS
|
| 358 |
|
| 359 |
+
# Eye label
|
| 360 |
+
draw.text((ox + panel_w // 2 - len(lat) * 4, PAD_TOP - 20), lat, fill=(30, 30, 30))
|
| 361 |
|
| 362 |
+
# Y axis
|
| 363 |
for r in range(NROWS):
|
| 364 |
yd = (4 - r) * 6
|
| 365 |
if yd % 12 == 0:
|
| 366 |
lbl = f"{yd:+d}Β°"
|
| 367 |
+
draw.text((ox - len(lbl)*6 - AXIS_GAP - 2,
|
| 368 |
+
PAD_TOP + r*CELL + CELL//2 - 7), lbl, fill=(150,150,150))
|
| 369 |
+
# X axis
|
| 370 |
for c in range(NCOLS):
|
| 371 |
+
xr = (c - 4) * 6
|
| 372 |
+
xd = -xr if lat == "OS" else xr
|
| 373 |
if xd % 12 == 0:
|
| 374 |
draw.text((ox + c*CELL + 2, PAD_TOP + panel_h + AXIS_GAP),
|
| 375 |
f"{xd:+d}Β°", fill=(150,150,150))
|
| 376 |
|
| 377 |
+
# Fixation cross
|
| 378 |
fx = ox + 4*CELL + CELL//2
|
| 379 |
fy = PAD_TOP + 4*CELL + CELL//2
|
| 380 |
draw.line([(fx-9,fy),(fx+9,fy)], fill=(180,50,50), width=2)
|
| 381 |
draw.line([(fx,fy-9),(fx,fy+9)], fill=(180,50,50), width=2)
|
| 382 |
|
| 383 |
+
# Cells
|
| 384 |
for r, row in enumerate(VF_GRID):
|
| 385 |
for c, vi in enumerate(row):
|
| 386 |
+
x0 = ox + c*CELL
|
| 387 |
+
y0 = PAD_TOP + r*CELL
|
| 388 |
x1, y1 = x0+CELL-2, y0+CELL-2
|
| 389 |
if vi is None:
|
| 390 |
draw.rectangle([x0,y0,x1,y1], fill=(238,238,240), outline=(220,220,222))
|
|
|
|
| 392 |
is_bs = vi in bs
|
| 393 |
v = vf[vi]
|
| 394 |
draw.rectangle([x0,y0,x1,y1], fill=sens_fill(v,is_bs), outline=(255,255,255))
|
| 395 |
+
lbl = "BS" if is_bs else ("β" if v < 0 else str(int(v)))
|
| 396 |
+
lw = len(lbl) * 6
|
| 397 |
+
draw.text((x0+CELL//2-lw//2, y0+CELL//2-7), lbl, fill=sens_ink(v,is_bs))
|
| 398 |
|
| 399 |
+
x_cur = PAD_X
|
| 400 |
if vf_od:
|
| 401 |
+
draw_one(vf_od, "OD", x_cur)
|
| 402 |
+
x_cur += panel_w + GAP
|
| 403 |
if vf_os:
|
| 404 |
+
draw_one(vf_os, "OS", x_cur)
|
| 405 |
|
| 406 |
# Legend
|
| 407 |
tiers = [
|
| 408 |
+
("β₯25 dB", (8,80,65), (225,245,238)),
|
| 409 |
+
("20β24", (29,158,117), (4,52,44)),
|
| 410 |
+
("15β19", (159,225,203),(4,52,44)),
|
| 411 |
+
("10β14", (250,199,117),(65,36,2)),
|
| 412 |
+
("<10 dB", (216,90,48), (250,236,231)),
|
| 413 |
+
("Scotoma", (216,90,48), (250,236,231)),
|
| 414 |
+
("BS", (68,68,65), (180,178,169)),
|
| 415 |
]
|
| 416 |
leg_y = PAD_TOP + panel_h + 24 + 4
|
| 417 |
sw = (total_w - PAD_X*2) // len(tiers)
|
| 418 |
for i, (lbl, bg, fg) in enumerate(tiers):
|
| 419 |
lx = PAD_X + i*sw
|
| 420 |
+
draw.rectangle([lx, leg_y, lx+sw-3, leg_y+24], fill=bg)
|
| 421 |
+
draw.text((lx+4, leg_y+5), lbl, fill=fg)
|
| 422 |
draw.text((PAD_X, leg_y+32),
|
| 423 |
"Fixation cross = (0Β°,0Β°) Β· Axis = degrees from fixation Β· BS = blind spot",
|
| 424 |
fill=(165,165,165))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
|
| 426 |
+
return canvas
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
|
| 428 |
|
| 429 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 430 |
+
# Info banner
|
| 431 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 432 |
+
def make_info_banner(subject, W):
|
| 433 |
+
sid = subject["id"]
|
| 434 |
+
info = subject["info"]
|
| 435 |
+
panel = Image.new("RGB", (W, 72), (245, 245, 248))
|
| 436 |
+
draw = ImageDraw.Draw(panel)
|
| 437 |
+
draw.text((14, 10), f"Subject: {sid}"[:100], fill=(30, 30, 30))
|
| 438 |
+
draw.text((14, 34), info[:110], fill=(55, 75, 140))
|
| 439 |
|
| 440 |
+
n_od = subject["OD"] is not None
|
| 441 |
+
n_os = subject["OS"] is not None
|
| 442 |
+
mode = "Binocular (OD + OS)" if (n_od and n_os) else ("OD only" if n_od else "OS only")
|
| 443 |
+
draw.text((14, 54), f"Mode: {mode}", fill=(100, 100, 100))
|
| 444 |
+
return panel
|
| 445 |
|
| 446 |
|
| 447 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 448 |
+
# Default street scene
|
| 449 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 450 |
+
def load_default_scene():
|
| 451 |
+
"""Load placeholder_scene.jpg from the app directory; generate a fallback if missing."""
|
| 452 |
+
scene_path = os.path.join(_SCRIPT_DIR, "placeholder_scene.jpg")
|
| 453 |
+
if os.path.exists(scene_path):
|
| 454 |
+
return Image.open(scene_path).convert("RGB")
|
| 455 |
+
# Minimal fallback β plain grey gradient so the app still launches
|
| 456 |
W, H = 640, 400
|
| 457 |
+
img = Image.new("RGB", (W, H), (180, 180, 180))
|
| 458 |
+
ImageDraw.Draw(img).text((20, 180), "Place placeholder_scene.jpg here", fill=(80, 80, 80))
|
| 459 |
+
return img
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
|
| 461 |
+
DEFAULT_SCENE = load_default_scene()
|
| 462 |
|
| 463 |
+
# Populate _subjects now that parse_uploaded_file is defined
|
| 464 |
+
_load_demo()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
|
| 466 |
|
| 467 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 468 |
# Gradio callbacks
|
| 469 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 470 |
+
def on_file_upload(filepath):
|
| 471 |
global _subjects
|
| 472 |
if filepath is None:
|
| 473 |
+
_load_demo()
|
| 474 |
choices = list(_subjects.keys())
|
| 475 |
+
return gr.update(choices=choices, value=choices[0]), "Loaded default example (glaucoma_vf_example.xlsx)."
|
| 476 |
|
| 477 |
+
loaded, msg = parse_uploaded_file(filepath)
|
| 478 |
if not loaded:
|
| 479 |
+
_load_demo()
|
| 480 |
choices = list(_subjects.keys())
|
| 481 |
+
return gr.update(choices=choices, value=choices[0]), f"β {msg} Falling back to default example."
|
| 482 |
|
| 483 |
_subjects = loaded
|
| 484 |
choices = list(_subjects.keys())
|
| 485 |
return gr.update(choices=choices, value=choices[0]), msg
|
| 486 |
|
| 487 |
|
| 488 |
+
def on_clear_file():
|
| 489 |
+
global _subjects
|
| 490 |
+
_load_demo()
|
| 491 |
choices = list(_subjects.keys())
|
| 492 |
+
return gr.update(choices=choices, value=choices[0]), "Cleared β loaded default example (glaucoma_vf_example.xlsx)."
|
| 493 |
|
| 494 |
|
| 495 |
+
def simulate(subject_id, input_image, blur_scotoma, show_dots, smoothing):
|
| 496 |
+
subject = _subjects.get(subject_id)
|
| 497 |
+
if subject is None:
|
| 498 |
+
return None
|
| 499 |
|
| 500 |
+
vf_od = subject["OD"]
|
| 501 |
+
vf_os = subject["OS"]
|
|
|
|
| 502 |
|
|
|
|
| 503 |
if input_image is None:
|
| 504 |
scene = DEFAULT_SCENE.copy()
|
| 505 |
elif isinstance(input_image, np.ndarray):
|
|
|
|
| 509 |
scene = scene.resize((640, 400), Image.LANCZOS)
|
| 510 |
|
| 511 |
simulated = apply_vf_binocular(scene, vf_od, vf_os, blur_scotoma, show_dots, smoothing)
|
| 512 |
+
banner = make_info_banner(subject, scene.width * 2 + 8)
|
| 513 |
|
| 514 |
W, H = scene.size
|
| 515 |
canvas = Image.new("RGB", (W*2+8, H+banner.height+4), (220,220,225))
|
|
|
|
| 517 |
canvas.paste(simulated, (W+8, 0))
|
| 518 |
canvas.paste(banner, (0, H+4))
|
| 519 |
d = ImageDraw.Draw(canvas)
|
| 520 |
+
for lx, txt in [(6, "Normal vision"), (W+14, "Simulated VF loss")]:
|
| 521 |
+
d.rectangle([lx, 4, lx+156, 20], fill=(0,0,0))
|
| 522 |
+
d.text((lx+5, 5), txt, fill=(255,255,255))
|
| 523 |
|
| 524 |
+
return canvas
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def vf_grid(subject_id):
|
| 528 |
+
subject = _subjects.get(subject_id)
|
| 529 |
+
if subject is None:
|
| 530 |
+
return None
|
| 531 |
+
return make_vf_grid_panel(subject)
|
| 532 |
|
| 533 |
+
|
| 534 |
+
def run_all(subject_id, input_image, blur_scotoma, show_dots, smoothing):
|
| 535 |
+
return simulate(subject_id, input_image, blur_scotoma, show_dots, smoothing), \
|
| 536 |
+
vf_grid(subject_id)
|
| 537 |
|
| 538 |
|
| 539 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 540 |
+
# Gradio UI
|
| 541 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 542 |
_init_choices = list(_subjects.keys())
|
| 543 |
|
| 544 |
with gr.Blocks(title="Glaucoma VF Simulator") as demo:
|
| 545 |
gr.Markdown(
|
| 546 |
"# Glaucoma Visual Field Simulator\n"
|
| 547 |
+
"Upload a file to load your own subjects, or explore the built-in demo data.\n\n"
|
| 548 |
+
"**File format** β XLSX or delimited (CSV / TSV), **with or without a header row**: \n"
|
| 549 |
+
"`subject` Β· `laterality` (OD/OS) Β· `vf_0` β¦ `vf_60` \n"
|
| 550 |
+
"Two rows per subject (one OD, one OS). A subject with only one eye runs monocularly."
|
|
|
|
|
|
|
| 551 |
)
|
| 552 |
|
| 553 |
with gr.Row():
|
| 554 |
+
# ββ Left: controls ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 555 |
with gr.Column(scale=1, min_width=310):
|
| 556 |
+
|
| 557 |
with gr.Group():
|
| 558 |
gr.Markdown("### Data source")
|
| 559 |
vf_file = gr.File(
|
| 560 |
+
label="Upload VF file (XLSX / CSV / TSV)",
|
| 561 |
+
file_types=[".xlsx", ".xls", ".csv", ".tsv", ".txt"],
|
| 562 |
type="filepath",
|
| 563 |
)
|
| 564 |
file_status = gr.Textbox(
|
| 565 |
+
value="Loaded default example (glaucoma_vf_example.xlsx).",
|
| 566 |
+
label="Status",
|
| 567 |
+
interactive=False,
|
| 568 |
+
lines=1,
|
| 569 |
)
|
| 570 |
clear_btn = gr.Button("β Clear / reset to demo subjects",
|
| 571 |
size="sm", variant="secondary")
|
|
|
|
| 575 |
subject_dd = gr.Dropdown(
|
| 576 |
choices=_init_choices,
|
| 577 |
value=_init_choices[0],
|
| 578 |
+
label="Select subject",
|
| 579 |
interactive=True,
|
| 580 |
)
|
| 581 |
|
|
|
|
| 583 |
label="Scene β upload a photo or keep the default",
|
| 584 |
value=np.array(DEFAULT_SCENE),
|
| 585 |
type="numpy",
|
| 586 |
+
sources=["upload", "clipboard"],
|
| 587 |
)
|
| 588 |
with gr.Accordion("Simulation options", open=True):
|
| 589 |
blur_cb = gr.Checkbox(value=True, label="Blur scotoma regions (diffusion)")
|
| 590 |
dots_cb = gr.Checkbox(value=False, label="Show VF test-point dots on scene")
|
| 591 |
smooth_sl = gr.Slider(10, 60, value=28, step=2,
|
| 592 |
+
label="Field smoothness (Gaussian Ο px)")
|
| 593 |
run_btn = gr.Button("βΆ Run simulation", variant="primary")
|
| 594 |
|
| 595 |
+
# ββ Right: outputs ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 596 |
with gr.Column(scale=2):
|
| 597 |
with gr.Tabs():
|
| 598 |
with gr.TabItem("Vision simulation"):
|
| 599 |
sim_out = gr.Image(label="Normal vs Simulated VF loss", type="pil")
|
| 600 |
with gr.TabItem("VF sensitivity grid"):
|
| 601 |
+
grid_out = gr.Image(label="Annotated Humphrey 24-2 grid (OD + OS)", type="pil")
|
| 602 |
|
| 603 |
gr.Markdown(
|
| 604 |
"**Colour scale** β "
|
| 605 |
"π© β₯25 dB Β· π’ 20β24 Β· π©΅ 15β19 Β· π‘ 10β14 Β· π <10 dB Β· π΄ scotoma Β· β« blind spot \n"
|
| 606 |
+
"*Binocular model: left visual field driven by OD, right by OS, soft crossfade at fixation. "
|
|
|
|
| 607 |
"Data: GRAPE/PAPILA baseline cohort (mini).*"
|
| 608 |
)
|
| 609 |
|
| 610 |
sim_inputs = [subject_dd, image_in, blur_cb, dots_cb, smooth_sl]
|
| 611 |
|
| 612 |
+
vf_file.change(fn=on_file_upload, inputs=[vf_file], outputs=[subject_dd, file_status])
|
| 613 |
+
clear_btn.click(fn=on_clear_file, inputs=[], outputs=[subject_dd, file_status])
|
| 614 |
+
|
| 615 |
+
run_btn.click(fn=run_all, inputs=sim_inputs, outputs=[sim_out, grid_out])
|
| 616 |
+
subject_dd.change(fn=run_all, inputs=sim_inputs, outputs=[sim_out, grid_out])
|
| 617 |
+
demo.load(fn=run_all, inputs=sim_inputs, outputs=[sim_out, grid_out])
|
| 618 |
|
| 619 |
|
| 620 |
if __name__ == "__main__":
|