hmgill commited on
Commit
e2f0449
Β·
verified Β·
1 Parent(s): 256afe7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +346 -363
app.py CHANGED
@@ -1,19 +1,17 @@
1
  """
2
  Glaucoma Visual Field Simulator v4
3
- ────────────────────────────────────
4
- Expected file format (XLSX / CSV / TSV):
5
- β€’ Optional header row β€” detected automatically if VF columns are non-numeric.
6
- Recognised header names: 'subject'|'id'|'patient' for the ID column,
7
- 'laterality'|'lat'|'eye' for the eye column.
8
- β€’ Two data rows per subject β€” one OD and one OS.
9
- β€’ Columns: subject_id | laterality | vf_0 … vf_60 (63 cols total)
10
- β€’ Laterality values: OD / OS / R / L / RIGHT / LEFT (case-insensitive)
11
- β€’ VF values: numeric dB; use βˆ’1, blank, or '/' for scotoma / blind-spot.
12
-
13
- If a subject has only one eye present (e.g. monocular data), the missing eye
14
- is treated as full sensitivity (30 dB everywhere) in the binocular simulation.
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
- # Built-in demo subjects (grouped as {subject_id: {OD: [...], OS: [...]}})
27
  # ════════════════════════════════════════════════════════════════════════════════
28
- BUILTIN = {
29
- "Demo β€” Sub 1 (OAG 46F)": {
30
- "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],
31
- "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],
32
- "info": "OAG Β· Age 46 Β· F Β· IOP 14.7/15.3 mmHg Β· RNFL 113/93 Β΅m",
33
- },
34
- "Demo β€” Sub 2 (OAG 57M, advanced loss)": {
35
- "OD": None,
36
- "OS": [25,29,23,6,24,27,19,6,19,22,20,20,7,3,16,4,1,4,15,4,16,-1,10,9,-1,-1,-1,-1,-1,-1,-1,1,-1,19,21,21,15,3,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,2,14,17,20,24,13,-1,-1,-1,-1,-1,23],
37
- "info": "OAG Β· Age 57 Β· M Β· IOP 15.5 mmHg Β· RNFL 62 Β΅m (OS only)",
38
- },
39
- "Demo β€” Sub 3 (ACG 41M, progressed β˜…)": {
40
- "OD": [29,29,26,27,27,29,25,29,29,29,27,29,25,25,29,27,27,29,29,29,29,-1,24,26,27,25,25,30,28,25,29,28,-1,21,20,21,20,21,22,21,20,22,21,41,22,21,22,21,23,23,23,20,21,22,20,20,14,18,16,21,18],
41
- "OS": None,
42
- "info": "ACG Β· Age 41 Β· M Β· IOP 17.0 mmHg Β· RNFL 89 Β΅m (OD only) Β· PROGRESSED",
43
- },
44
- "Demo β€” Normal (all 30 dB)": {
45
- "OD": [30] * 61,
46
- "OS": [30] * 61,
47
- "info": "Simulated β€” full sensitivity both eyes",
48
- },
49
- "Demo β€” Severe central scotoma": {
50
- "OD": [5 if i in {5,6,7,8,11,12,13,14,17,18,19,20,25,26,27,28,33,34,35,36} else 28 for i in range(61)],
51
- "OS": [5 if i in {5,6,7,8,11,12,13,14,17,18,19,20,25,26,27,28,33,34,35,36} else 28 for i in range(61)],
52
- "info": "Simulated β€” bilateral central scotoma",
53
- },
54
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
 
57
  # ════════════════════════════════════════════════════════════════════════════════
58
- # VF grid constants
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 = len(VF_GRID)
74
- NCOLS = len(VF_GRID[0])
75
- BS_OD = {21, 32}
76
- BS_OS = {20, 33}
77
- MAX_DB = 30.0
78
 
79
 
80
  # ════════════════════════════════════════════════════════════════════════════════
81
  # Colour helpers
82
  # ════════════════════════════════════════════════════════════════════════════════
83
  def sens_fill(v, is_bs=False):
84
- if is_bs: return (68, 68, 65)
85
- if v is None or v < 0: return (216, 90, 48)
86
- if v >= 25: return (8, 80, 65)
87
- if v >= 20: return (29, 158, 117)
88
- if v >= 15: return (159, 225, 203)
89
- if v >= 10: return (250, 199, 117)
90
- return (216, 90, 48)
91
 
92
  def sens_ink(v, is_bs=False):
93
- if is_bs: return (180, 178, 169)
94
- if v is None or v < 0: return (250, 236, 231)
95
- if v >= 25: return (225, 245, 238)
96
- if v >= 20: return (4, 52, 44)
97
- if v >= 15: return (4, 52, 44)
98
- if v >= 10: return (65, 36, 2)
99
- return (250, 236, 231)
100
 
101
 
102
  # ════════════════════════════════════════════════════════════════════════════════
@@ -116,28 +242,24 @@ def vf_points(laterality):
116
 
117
 
118
  # ════════════════════════════════════════════════════════════════════════════════
119
- # Sensitivity field builder
120
  # ════════════════════════════════════════════════════════════════════════════════
121
- def build_field(vf, laterality, W, H, fov_deg=36, sigma=30):
122
- """Gaussian-interpolate 61 VF points β†’ smooth 2-D sensitivity field [0,1]."""
123
- cx, cy = W / 2, H / 2
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 / MAX_DB, 1.0)
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
- Apply binocular VF loss.
158
- Left visual hemifield β†’ OD (right eye drives left VF).
159
- Right visual hemifield β†’ OS (left eye drives right VF).
160
- Soft cosine blend Β±15Β° around fixation.
161
- Missing eye β†’ full sensitivity on that side.
162
  """
163
  img = img_pil.convert("RGB")
164
  W, H = img.size
165
  arr = np.array(img, dtype=np.float32)
166
 
167
- fod = build_field(vf_od, "OD", W, H, sigma=sigma) if vf_od else _full_field(W, H)
168
- fos = build_field(vf_os, "OS", W, H, sigma=sigma) if vf_os else _full_field(W, H)
169
 
170
- cx = W / 2
171
- xn = (np.arange(W) - cx) / (W / 2) # βˆ’1 … +1
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 * fod + os_w * fos) / (od_w + os_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
- ppd = min(W, H) / (2 * 36)
 
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
- px = int(W/2 + xd * ppd)
193
- py = int(H/2 - yd * ppd)
194
- fg = sens_fill(vf[vi], is_bs) + (200,)
195
- od.ellipse([px-7,py-7,px+7,py+7], fill=fg, outline=(255,255,255,90))
 
 
196
  result = Image.alpha_composite(result.convert("RGBA"), overlay).convert("RGB")
197
 
198
  return result
199
 
200
 
201
  # ════════════════════════════════════════════════════════════════════════════════
202
- # VF sensitivity grid (one or two eyes side by side)
203
  # ════════════════════════════════════════════════════════════════════════════════
204
- def make_vf_grid(vf_od, vf_os, subject_label, info_str):
 
 
 
 
 
205
  CELL = 40
206
  PAD_X = 52
207
- PAD_TOP = 50
208
- GAP = 30
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
- draw.text((PAD_X, 10), str(subject_label)[:80], fill=(38, 38, 38))
223
- draw.text((PAD_X, 28), str(info_str)[:100], fill=(80, 80, 120))
 
224
 
225
- def draw_eye(vf, lat, ox):
226
  bs = BS_OD if lat == "OD" else BS_OS
227
 
228
- draw.text((ox + panel_w//2 - len(lat)*4, PAD_TOP - 20), lat, fill=(30,30,30))
 
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, PAD_TOP + r*CELL + CELL//2 - 7),
235
- lbl, fill=(150,150,150))
 
236
  for c in range(NCOLS):
237
- xd = ((c-4)*6) * (-1 if lat=="OS" else 1)
 
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; y0 = PAD_TOP + r*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
- draw.text((x0+CELL//2 - len(lbl)*3, y0+CELL//2-7), lbl,
259
- fill=sens_ink(v, is_bs))
260
 
261
- x = PAD_X
262
  if vf_od:
263
- draw_eye(vf_od, "OD", x); x += panel_w + GAP
 
264
  if vf_os:
265
- draw_eye(vf_os, "OS", x)
266
 
267
  # Legend
268
  tiers = [
269
- ("β‰₯25 dB",(8,80,65),(225,245,238)), ("20–24",(29,158,117),(4,52,44)),
270
- ("15–19",(159,225,203),(4,52,44)), ("10–14",(250,199,117),(65,36,2)),
271
- ("<10 dB",(216,90,48),(250,236,231)),("Scotoma",(216,90,48),(250,236,231)),
272
- ("BS",(68,68,65),(180,178,169)),
 
 
 
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
- # Known header synonyms for the two key columns
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
- # Global state
431
  # ════════════════════════════════════════════════════════════════════════════════
432
- _subjects: dict = {} # label β†’ {OD, OS, info}
433
-
434
- def _use_builtin():
435
- global _subjects
436
- _subjects = dict(BUILTIN)
 
 
437
 
438
- _use_builtin()
 
 
 
 
439
 
440
 
441
  # ════════════════════════════════════════════════════════════════════════════════
442
- # Default scene
443
  # ════════════════════════════════════════════════════════════════════════════════
444
- def create_default_scene():
 
 
 
 
 
445
  W, H = 640, 400
446
- img = Image.new("RGB", (W, H))
447
- draw = ImageDraw.Draw(img)
448
- for y in range(220):
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
- # Info banner
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 on_upload(filepath):
497
  global _subjects
498
  if filepath is None:
499
- _use_builtin()
500
  choices = list(_subjects.keys())
501
- return gr.update(choices=choices, value=choices[0]), "Using built-in demo subjects."
502
 
503
- loaded, msg = parse_file_to_subjects(filepath)
504
  if not loaded:
505
- _use_builtin()
506
  choices = list(_subjects.keys())
507
- return gr.update(choices=choices, value=choices[0]), f"⚠ {msg} Falling back to demo subjects."
508
 
509
  _subjects = loaded
510
  choices = list(_subjects.keys())
511
  return gr.update(choices=choices, value=choices[0]), msg
512
 
513
 
514
- def on_clear():
515
- _use_builtin()
 
516
  choices = list(_subjects.keys())
517
- return gr.update(choices=choices, value=choices[0]), "Cleared β€” using built-in demo subjects."
518
 
519
 
520
- def run_all(subject_label, input_image, blur_scotoma, show_dots, smoothing):
521
- sub = _subjects.get(subject_label)
522
- if sub is None:
523
- return None, None
524
 
525
- vf_od = sub["OD"]
526
- vf_os = sub["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 = make_banner(subject_label, info, scene.width * 2 + 8)
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
- # ── Grid ──
552
- grid = make_vf_grid(vf_od, vf_os, subject_label, info)
 
 
 
 
 
 
553
 
554
- return canvas, grid
 
 
 
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 an **XLSX or delimited file** to load your own VF data, "
566
- "or explore the built-in demo subjects.\n\n"
567
- "**Expected format** β€” one header row (`subject`, `laterality`, `vf_0` … `vf_60`), "
568
- "then **two rows per subject** (one OD, one OS). "
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
- # ── Controls ──────────────────────────────────────────────────────────
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 β€” see format above)",
580
- file_types=[".xlsx",".xls",".csv",".tsv",".txt"],
581
  type="filepath",
582
  )
583
  file_status = gr.Textbox(
584
- value="Using built-in demo subjects.",
585
- label="Status", interactive=False, lines=1,
 
 
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 (both eyes loaded automatically)",
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 Οƒ, px)")
610
  run_btn = gr.Button("β–Ά Run simulation", variant="primary")
611
 
612
- # ── Outputs ───────────────────────────────────────────────────────────
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 (OD + OS)", type="pil")
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 hemifield driven by OD, right by OS, "
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=on_upload, inputs=[vf_file], outputs=[subject_dd, file_status])
631
- clear_btn.click(fn=on_clear, inputs=[], outputs=[subject_dd, file_status])
632
- run_btn.click( fn=run_all, inputs=sim_inputs, outputs=[sim_out, grid_out])
633
- subject_dd.change(fn=run_all,inputs=sim_inputs, outputs=[sim_out, grid_out])
634
- demo.load( fn=run_all, inputs=sim_inputs, outputs=[sim_out, grid_out])
 
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__":