File size: 32,318 Bytes
797fb82 40737bc e2f0449 797fb82 e2f0449 797fb82 e2f0449 40737bc e2f0449 40737bc e2f0449 40737bc e2f0449 40737bc e2f0449 40737bc e2f0449 40737bc e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 40737bc e2f0449 40737bc 797fb82 e2f0449 40737bc 797fb82 2ccd97a 94da8bd 2ccd97a 94da8bd e2f0449 2ccd97a 797fb82 2ccd97a 797fb82 2ccd97a 797fb82 e2f0449 797fb82 94da8bd 797fb82 2ccd97a 94da8bd 2ccd97a ddbe8d1 94da8bd 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 40737bc 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 40737bc 797fb82 40737bc 797fb82 40737bc 797fb82 e2f0449 40737bc e2f0449 40737bc 797fb82 40737bc e2f0449 40737bc 797fb82 e2f0449 797fb82 40737bc 797fb82 40737bc e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 e2f0449 797fb82 40737bc e2f0449 797fb82 40737bc 797fb82 e2f0449 797fb82 e2f0449 797fb82 94da8bd 797fb82 e2f0449 797fb82 e2f0449 797fb82 40737bc 797fb82 e2f0449 40737bc 797fb82 e2f0449 40737bc 797fb82 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 | """
Visual Field Simulator v5
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
File format (XLSX / CSV / TSV):
β’ Optional header row β auto-detected if first cell is non-numeric.
β’ Expected columns:
col 0 subject / patient ID
col 1 laterality (OD | OS | R | L)
col 2β¦ 61 VF sensitivity values (dB; β1 or "/" = scotoma/blind spot)
β’ TWO rows per subject, one for OD and one for OS.
If only one eye is present the simulation runs monocularly.
The dropdown lists subjects (not individual rows).
Both eyes are combined into a binocular simulation and a side-by-side VF grid.
"""
import gradio as gr
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
from scipy.ndimage import gaussian_filter
import os, csv
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Default dataset β loaded from the bundled example file at startup
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_VF_FILE = os.path.join(_SCRIPT_DIR, "glaucoma_vf_example.xlsx")
# Module-level store: subject_id β {id, info, OD, OS}
_subjects: dict = {}
def _load_demo():
"""Load subjects from the bundled example XLSX; hard-coded fallback if missing."""
global _subjects
if os.path.exists(DEFAULT_VF_FILE):
loaded, _ = parse_uploaded_file(DEFAULT_VF_FILE)
if loaded:
_subjects = loaded
return
# Fallback: Subject 1 OD+OS from GRAPE mini
_subjects = {
"1": {
"id": "1",
"info": "OD mean 19.8 dB Β· scotoma 2/61 | OS mean 23.7 dB Β· scotoma 2/61",
"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],
"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],
}
}
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# File parser
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _norm_lat(raw):
s = str(raw).strip().upper()
return "OD" if s in {"OD", "R", "RIGHT", "RE"} else "OS"
_LAT_VALUES = {"OD", "OS", "R", "L", "RIGHT", "LEFT", "RE", "LE"}
def _row_is_header(row):
"""
True if the first row is a column-name header, not a data row.
We check column 1 (the laterality column): if it holds a recognised
laterality code the row is data; if it holds anything else (e.g. the
string "laterality") it is a header. This is robust to non-numeric
patient IDs such as "P001" or "sub_A".
"""
if not row or len(row) < 2:
return False
lat_cell = str(row[1]).strip().upper() if row[1] is not None else ""
return lat_cell not in _LAT_VALUES
def parse_uploaded_file(filepath):
"""
Parse XLSX / CSV / TSV into a dict of subjects.
Returns (subjects_dict, status_message)
subjects_dict: { subject_id: {id, info, OD, OS} }
"""
if filepath is None:
return {}, "No file provided."
ext = os.path.splitext(filepath)[1].lower()
raw_rows = []
try:
if ext in {".xlsx", ".xls"}:
import openpyxl
wb = openpyxl.load_workbook(filepath, data_only=True)
ws = wb.active
for row in ws.iter_rows(values_only=True):
if all(v is None for v in row):
continue
raw_rows.append(list(row))
else:
with open(filepath, newline="", encoding="utf-8-sig") as f:
sample = f.read(4096)
delim = max([",", "\t", ";", " "], key=lambda d: sample.count(d))
with open(filepath, newline="", encoding="utf-8-sig") as f:
for row in csv.reader(f, delimiter=delim):
if row:
raw_rows.append(row)
except Exception as e:
return {}, f"Could not read file: {e}"
if not raw_rows:
return {}, "File appears to be empty."
# Skip header row if detected (checks laterality column, not ID column)
data_rows = raw_rows[1:] if _row_is_header(raw_rows[0]) else raw_rows
if not data_rows:
return {}, "Only a header row found β no data."
subjects = {} # subject_id β {id, info, OD, OS}
skipped = []
for ri, raw in enumerate(data_rows, start=2):
cells = [c for c in raw if c is not None and str(c).strip() not in ("", "None")]
# Detect optional diagnosis column at position 2
# Layout A (with diagnosis): subject | laterality | diagnosis | vf_0..vf_60 β 64 cols
# Layout B (without): subject | laterality | vf_0..vf_60 β 63 cols
if len(cells) >= 64:
sid = str(cells[0]).strip()
lat = _norm_lat(cells[1])
diagnosis = str(cells[2]).strip() if cells[2] is not None else ""
vf_raw = cells[3:64]
elif len(cells) >= 63:
sid = str(cells[0]).strip()
lat = _norm_lat(cells[1])
diagnosis = ""
vf_raw = cells[2:63]
else:
skipped.append(
f"Row {ri}: {len(cells)} cols "
f"(need 63+ : subject + laterality + [diagnosis] + 61 VF). Skipped."
)
continue
vf_clean = []
for v in vf_raw:
sv = str(v).strip()
if sv in ("/", "", "None"):
vf_clean.append(-1.0)
else:
try:
vf_clean.append(float(sv))
except ValueError:
vf_clean.append(-1.0)
if len(vf_clean) < 61:
skipped.append(f"Row {ri} (ID={sid}): only {len(vf_clean)} VF values. Skipped.")
continue
if sid not in subjects:
subjects[sid] = {"id": sid, "info": "", "diagnosis": diagnosis, "OD": None, "OS": None}
elif diagnosis and not subjects[sid].get("diagnosis"):
subjects[sid]["diagnosis"] = diagnosis
subjects[sid][lat] = vf_clean[:61]
if not subjects:
msg = "No valid subjects found."
if skipped:
msg += "\n" + "\n".join(skipped[:5])
return {}, msg
# Build info strings
for sid, s in subjects.items():
diag = s.get("diagnosis", "")
eyes = []
for lat in ("OD", "OS"):
vf = s[lat]
if vf is None:
eyes.append(f"{lat}: β")
else:
valid = [v for v in vf if v >= 0]
mean = f"{np.mean(valid):.1f}" if valid else "N/A"
scot = sum(1 for v in vf if v < 0)
eyes.append(f"{lat} mean {mean} dB Β· scotoma {scot}/61")
prefix = f"Dx: {diag} | " if diag else ""
s["info"] = prefix + " | ".join(eyes)
if skipped:
print(f"[VF parser] {len(skipped)} rows skipped:")
for m in skipped:
print(" ", m)
n_eyes = sum(1 for s in subjects.values() for lat in ("OD","OS") if s[lat] is not None)
return subjects, f"β Loaded {len(subjects)} subject(s), {n_eyes} eye(s) total."
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# VF layout
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
VF_GRID = [
[None,None,None,None,None,None,None,None,None],
[None,None,0, 1, None,2, 3, None,None],
[None,4, 5, 6, None,7, 8, 9, None],
[10, 11, 12, None,None,None,13, 14, 15 ],
[16, 17, 18, 19, None,20, 21, 22, 23 ],
[None,24, 25, 26, 27, 28, 29, 30, 31 ],
[None,32, 33, 34, 35, 36, 37, 38, 39 ],
[None,None,40, 41, 42, None,None,None,None],
[None,43, 44, 45, 46, 47, 48, 49, 50 ],
[None,None,51, 52, 53, 54, 55, 56, None],
[None,None,None,57, 58, 59, 60, None,None],
]
NROWS = len(VF_GRID)
NCOLS = len(VF_GRID[0])
BS_OD = {21, 32}
BS_OS = {20, 33}
MAX_SENS = 30.0
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Colour helpers
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def sens_fill(v, is_bs=False):
if is_bs: return (68, 68, 65)
if v is None or v < 0: return (216, 90, 48)
if v >= 25: return (8, 80, 65)
if v >= 20: return (29, 158, 117)
if v >= 15: return (159, 225, 203)
if v >= 10: return (250, 199, 117)
return (216, 90, 48)
def sens_ink(v, is_bs=False):
if is_bs: return (180, 178, 169)
if v is None or v < 0: return (250, 236, 231)
if v >= 25: return (225, 245, 238)
if v >= 20: return (4, 52, 44)
if v >= 15: return (4, 52, 44)
if v >= 10: return (65, 36, 2)
return (250, 236, 231)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# VF geometry
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def vf_points(laterality):
bs = BS_OD if laterality == "OD" else BS_OS
for r, row in enumerate(VF_GRID):
for c, vi in enumerate(row):
if vi is None:
continue
x_deg = (c - 4) * 6
if laterality == "OS":
x_deg = -x_deg
y_deg = (4 - r) * 6
yield (x_deg, y_deg, vi, vi in bs)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Sensitivity field
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def build_sensitivity_field(vf, laterality, W, H, fov_deg=36, sigma=30):
"""
Gaussian-interpolate sparse VF points into a full-image sensitivity field [0,1].
No circular clipping β the field fills the entire frame.
Strategy: stamp each test point onto val_map / wt_map, then use
scipy.interpolate.griddata (nearest-neighbour) to fill every pixel
before applying a Gaussian smoothing pass. This guarantees that
corners and edges inherit the nearest real measurement rather than
defaulting to 1.0 (no loss).
"""
from scipy.interpolate import griddata
cx, cy = W / 2, H / 2
ppd = min(W, H) / (2 * fov_deg)
pts_xy = [] # (col, row) pixel coords of each test point
pts_val = [] # sensitivity value at that point
for xd, yd, vi, is_bs in vf_points(laterality):
v = vf[vi]
sens = 0.0 if (is_bs or v < 0) else min(v / MAX_SENS, 1.0)
px = cx + xd * ppd
py = cy - yd * ppd
pts_xy.append((px, py))
pts_val.append(sens)
pts_xy = np.array(pts_xy, dtype=np.float32)
pts_val = np.array(pts_val, dtype=np.float32)
# Grid of all pixel coordinates
cols = np.arange(W, dtype=np.float32)
rows = np.arange(H, dtype=np.float32)
grid_c, grid_r = np.meshgrid(cols, rows)
# Nearest-neighbour fill gives every pixel the value of its closest
# test point β no white corners.
field_nn = griddata(pts_xy, pts_val,
(grid_c, grid_r), method="nearest").astype(np.float32)
# Gaussian smooth to produce soft gradients
field = gaussian_filter(field_nn, sigma=sigma)
return np.clip(field, 0.0, 1.0)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Binocular scene simulation
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def apply_vf_binocular(img_pil, vf_od, vf_os, blur_scotoma, show_dots, sigma):
"""
Binocular simulation with combined desaturation + darkening.
The raw sensitivity field from Gaussian interpolation never reaches exactly
1.0 even in nominally-normal regions. To ensure the original image is
reproduced pixel-perfect where there is no field loss, the raw loss value
is remapped through a threshold function:
- sensitivity >= NORMAL_THRESH β loss = 0 (untouched)
- sensitivity <= 0 β loss = 1 (full effect)
- in between β smooth ramp
Effects applied in loss regions:
1. Blur β optional, simulates diffuse scotoma perception
2. Desaturate β blend toward greyscale (ITU-R BT.601 luma)
3. Darken β scale brightness down to DARK_DEPTH at full loss
Binocular blend:
Left visual field (x < centre) β OD (right eye, temporal)
Right visual field (x > centre) β OS (left eye, temporal)
"""
# Sensitivity threshold above which a pixel is treated as fully normal.
# Gaussian smoothing means raw bino rarely hits 1.0 exactly; 0.90 is a
# safe ceiling that leaves genuinely-normal regions completely untouched.
DARK_DEPTH = 0.35 # brightness of a full scotoma (0 = black, 1 = no darkening)
img = img_pil.convert("RGB")
W, H = img.size
arr = np.array(img, dtype=np.float32)
f_od = build_sensitivity_field(vf_od, "OD", W, H, sigma=sigma) if vf_od else np.ones((H, W), np.float32)
f_os = build_sensitivity_field(vf_os, "OS", W, H, sigma=sigma) if vf_os else np.ones((H, W), np.float32)
cx = W // 2
xn = (np.arange(W) - cx) / (W / 2)
od_w = np.clip(-xn + 0.5, 0, 1)[None, :] * np.ones((H, 1))
os_w = np.clip( xn + 0.5, 0, 1)[None, :] * np.ones((H, 1))
bino = (od_w * f_od + os_w * f_os) / (od_w + os_w) # 0 = lost, 1 = normal
# Compute loss relative to this subject's own best-seeing pixel.
# A subject with uniformly mild loss should not look like they have
# loss everywhere β only regions below their personal peak get the effect.
# REL_FLOOR: top fraction of the subject's sensitivity range treated as
# "normal" (suppresses Gaussian tail bleed around the peak).
REL_FLOOR = 0.15
bino_max = float(bino.max())
bino_min = float(bino.min())
bino_range = max(bino_max - bino_min, 1e-6)
rel_loss = np.clip((bino_max - bino) / bino_range, 0.0, 1.0)
loss = np.where(rel_loss < REL_FLOOR, 0.0,
(rel_loss - REL_FLOOR) / (1.0 - REL_FLOOR)).astype(np.float32)
# pixels at/near the subject's peak sensitivity β loss = 0 β pixel-perfect
# ββ Blur ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if blur_scotoma:
blurred = np.array(img.filter(ImageFilter.GaussianBlur(radius=14)), dtype=np.float32)
arr = arr * (1.0 - loss[:, :, None]) + blurred * loss[:, :, None]
# ββ Desaturation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
luma = (arr[:, :, 0] * 0.299 +
arr[:, :, 1] * 0.587 +
arr[:, :, 2] * 0.114)
grey = np.stack([luma, luma, luma], axis=2)
arr = arr * (1.0 - loss[:, :, None]) + grey * loss[:, :, None]
# ββ Darkening βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
dark_scale = 1.0 - loss * (1.0 - DARK_DEPTH)
arr = arr * dark_scale[:, :, None]
arr = np.clip(arr, 0, 255).astype(np.uint8)
result = Image.fromarray(arr)
if show_dots:
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
od = ImageDraw.Draw(overlay)
fov_deg = 36
ppd = min(W, H) / (2 * fov_deg)
for vf, lat in [(vf_od, "OD"), (vf_os, "OS")]:
if vf is None:
continue
for xd, yd, vi, is_bs in vf_points(lat):
v = vf[vi]
px = int(W / 2 + xd * ppd)
py = int(H / 2 - yd * ppd)
r = 7
fg = sens_fill(v, is_bs) + (200,)
od.ellipse([px-r, py-r, px+r, py+r], fill=fg, outline=(255,255,255,90))
result = Image.alpha_composite(result.convert("RGBA"), overlay).convert("RGB")
return result
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# VF sensitivity grid β both eyes side by side
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def make_vf_grid_panel(subject):
vf_od = subject["OD"]
vf_os = subject["OS"]
sid = subject["id"]
info = subject["info"]
CELL = 40
PAD_X = 52
PAD_TOP = 54
GAP = 32
LEG_H = 58
AXIS_GAP = 6
panel_w = NCOLS * CELL
panel_h = NROWS * CELL
n_eyes = (1 if vf_od else 0) + (1 if vf_os else 0)
if n_eyes == 0:
canvas = Image.new("RGB", (420, 80), (248, 248, 250))
ImageDraw.Draw(canvas).text((14, 28), "No VF data available.", fill=(80, 80, 80))
return canvas
total_w = PAD_X * 2 + n_eyes * panel_w + (n_eyes - 1) * GAP
total_h = PAD_TOP + panel_h + 24 + LEG_H
canvas = Image.new("RGB", (total_w, total_h), (248, 248, 250))
draw = ImageDraw.Draw(canvas)
# Header
draw.text((PAD_X, 8), f"Subject: {sid}", fill=(38, 38, 38))
draw.text((PAD_X, 26), info[:total_w // 7], fill=(80, 80, 120))
def draw_one(vf, lat, ox):
bs = BS_OD if lat == "OD" else BS_OS
# Eye label
draw.text((ox + panel_w // 2 - len(lat) * 4, PAD_TOP - 20), lat, fill=(30, 30, 30))
# Y axis
for r in range(NROWS):
yd = (4 - r) * 6
if yd % 12 == 0:
lbl = f"{yd:+d}Β°"
draw.text((ox - len(lbl)*6 - AXIS_GAP - 2,
PAD_TOP + r*CELL + CELL//2 - 7), lbl, fill=(150,150,150))
# X axis
for c in range(NCOLS):
xr = (c - 4) * 6
xd = -xr if lat == "OS" else xr
if xd % 12 == 0:
draw.text((ox + c*CELL + 2, PAD_TOP + panel_h + AXIS_GAP),
f"{xd:+d}Β°", fill=(150,150,150))
# Fixation cross
fx = ox + 4*CELL + CELL//2
fy = PAD_TOP + 4*CELL + CELL//2
draw.line([(fx-9,fy),(fx+9,fy)], fill=(180,50,50), width=2)
draw.line([(fx,fy-9),(fx,fy+9)], fill=(180,50,50), width=2)
# Cells
for r, row in enumerate(VF_GRID):
for c, vi in enumerate(row):
x0 = ox + c*CELL
y0 = PAD_TOP + r*CELL
x1, y1 = x0+CELL-2, y0+CELL-2
if vi is None:
draw.rectangle([x0,y0,x1,y1], fill=(238,238,240), outline=(220,220,222))
continue
is_bs = vi in bs
v = vf[vi]
draw.rectangle([x0,y0,x1,y1], fill=sens_fill(v,is_bs), outline=(255,255,255))
lbl = "BS" if is_bs else ("β" if v < 0 else str(int(v)))
lw = len(lbl) * 6
draw.text((x0+CELL//2-lw//2, y0+CELL//2-7), lbl, fill=sens_ink(v,is_bs))
x_cur = PAD_X
if vf_od:
draw_one(vf_od, "OD", x_cur)
x_cur += panel_w + GAP
if vf_os:
draw_one(vf_os, "OS", x_cur)
# Legend
tiers = [
("β₯25 dB", (8,80,65), (225,245,238)),
("20β24", (29,158,117), (4,52,44)),
("15β19", (159,225,203),(4,52,44)),
("10β14", (250,199,117),(65,36,2)),
("<10 dB", (216,90,48), (250,236,231)),
("Scotoma", (216,90,48), (250,236,231)),
("BS", (68,68,65), (180,178,169)),
]
leg_y = PAD_TOP + panel_h + 24 + 4
sw = (total_w - PAD_X*2) // len(tiers)
for i, (lbl, bg, fg) in enumerate(tiers):
lx = PAD_X + i*sw
draw.rectangle([lx, leg_y, lx+sw-3, leg_y+24], fill=bg)
draw.text((lx+4, leg_y+5), lbl, fill=fg)
draw.text((PAD_X, leg_y+32),
"Fixation cross = (0Β°,0Β°) Β· Axis = degrees from fixation Β· BS = blind spot",
fill=(165,165,165))
return canvas
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Info banner
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def make_info_banner(subject, W):
sid = subject["id"]
info = subject["info"]
panel = Image.new("RGB", (W, 72), (245, 245, 248))
draw = ImageDraw.Draw(panel)
draw.text((14, 10), f"Subject: {sid}"[:100], fill=(30, 30, 30))
draw.text((14, 34), info[:110], fill=(55, 75, 140))
n_od = subject["OD"] is not None
n_os = subject["OS"] is not None
mode = "Binocular (OD + OS)" if (n_od and n_os) else ("OD only" if n_od else "OS only")
draw.text((14, 54), f"Mode: {mode}", fill=(100, 100, 100))
return panel
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Default street scene
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def load_default_scene():
"""Load placeholder_scene.jpg from the app directory; generate a fallback if missing."""
scene_path = os.path.join(_SCRIPT_DIR, "placeholder_scene.jpg")
if os.path.exists(scene_path):
return Image.open(scene_path).convert("RGB")
# Minimal fallback β plain grey gradient so the app still launches
W, H = 640, 400
img = Image.new("RGB", (W, H), (180, 180, 180))
ImageDraw.Draw(img).text((20, 180), "Place placeholder_scene.jpg here", fill=(80, 80, 80))
return img
DEFAULT_SCENE = load_default_scene()
# Populate _subjects now that parse_uploaded_file is defined
_load_demo()
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Gradio callbacks
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def on_file_upload(filepath):
global _subjects
if filepath is None:
_load_demo()
choices = list(_subjects.keys())
return gr.update(choices=choices, value=choices[0]), "Loaded default example (the default example."
loaded, msg = parse_uploaded_file(filepath)
if not loaded:
_load_demo()
choices = list(_subjects.keys())
return gr.update(choices=choices, value=choices[0]), f"β {msg} Falling back to default example."
_subjects = loaded
choices = list(_subjects.keys())
return gr.update(choices=choices, value=choices[0]), msg
def on_clear_file():
global _subjects
_load_demo()
choices = list(_subjects.keys())
return gr.update(choices=choices, value=choices[0]), "Cleared β loaded default example (the default example."
def _prep_scene(input_image):
"""Resolve and resize the input scene to 640Γ400."""
if input_image is None:
scene = DEFAULT_SCENE.copy()
elif isinstance(input_image, np.ndarray):
scene = Image.fromarray(input_image).convert("RGB")
else:
scene = input_image.convert("RGB")
return scene.resize((640, 400), Image.LANCZOS)
def run_all(subject_id, input_image, blur_scotoma, show_dots, smoothing):
"""
Returns:
slider_pair β (original PIL, simulated PIL) for gr.ImageSlider
grid_img β annotated VF sensitivity grid with info banner
"""
subject = _subjects.get(subject_id)
if subject is None:
return None, None
vf_od = subject["OD"]
vf_os = subject["OS"]
scene = _prep_scene(input_image)
simulated = apply_vf_binocular(scene, vf_od, vf_os, blur_scotoma, show_dots, smoothing)
# Build grid panel with info banner above it
banner = make_info_banner(subject, make_vf_grid_panel(subject).width)
grid = make_vf_grid_panel(subject)
W_g = grid.width
combo = Image.new("RGB", (W_g, banner.height + 4 + grid.height), (220, 220, 225))
combo.paste(banner, (0, 0))
combo.paste(grid, (0, banner.height + 4))
return (scene, simulated), combo
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Gradio UI
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_init_choices = list(_subjects.keys())
with gr.Blocks(title="Visual Field Simulator") as demo:
gr.Markdown(
"# Visual Field Simulator\n"
"Upload a file to load your own data, or explore the built-in example.\n\n"
"**File format** β XLSX or delimited (CSV / TSV), **with or without a header row**: \n"
"`subject` Β· `laterality` (OD/OS) Β· `vf_0` β¦ `vf_60` \n"
"Two rows per subject (one OD, one OS). A subject with only one eye runs monocularly."
)
with gr.Row():
# ββ Left: controls ββββββββββββββββββββββββββββββββββββββββββββββββββββ
with gr.Column(scale=1, min_width=310):
with gr.Group():
gr.Markdown("### Data source")
vf_file = gr.File(
label="Upload VF file (XLSX / CSV / TSV)",
file_types=[".xlsx", ".xls", ".csv", ".tsv", ".txt"],
type="filepath",
)
file_status = gr.Textbox(
value="Loaded default example (the default example.",
label="Status",
interactive=False,
lines=1,
)
clear_btn = gr.Button("β Clear / reset to default example",
size="sm", variant="secondary")
with gr.Group():
gr.Markdown("### Subject")
subject_dd = gr.Dropdown(
choices=_init_choices,
value=_init_choices[0],
label="Select subject",
interactive=True,
)
image_in = gr.Image(
label="Scene β upload a photo or keep the default",
value=np.array(DEFAULT_SCENE),
type="numpy",
sources=["upload", "clipboard"],
)
with gr.Accordion("Simulation options", open=True):
blur_cb = gr.Checkbox(value=True, label="Blur loss regions (in addition to desaturation)")
dots_cb = gr.Checkbox(value=False, label="Show VF test-point dots on scene")
smooth_sl = gr.Slider(10, 60, value=28, step=2,
label="Field smoothness (Gaussian Ο px)")
run_btn = gr.Button("βΆ Run simulation", variant="primary")
# ββ Right: outputs ββββββββββββββββββββββββββββββββββββββββββββββββββββ
with gr.Column(scale=2):
slider_out = gr.ImageSlider(
label="Original β Simulated VF loss",
type="pil",
show_label=True,
)
grid_out = gr.Image(label="VF sensitivity grid", type="pil")
gr.Markdown(
"**Colour scale** β "
"π© β₯25 dB Β· π’ 20β24 Β· π©΅ 15β19 Β· π‘ 10β14 Β· π <10 dB Β· π΄ scotoma Β· β« blind spot \n"
"*Binocular model: left visual field driven by OD, right by OS, soft crossfade at fixation. "
"*"
)
sim_inputs = [subject_dd, image_in, blur_cb, dots_cb, smooth_sl]
vf_file.change(fn=on_file_upload, inputs=[vf_file], outputs=[subject_dd, file_status])
clear_btn.click(fn=on_clear_file, inputs=[], outputs=[subject_dd, file_status])
run_btn.click(fn=run_all, inputs=sim_inputs, outputs=[slider_out, grid_out])
subject_dd.change(fn=run_all, inputs=sim_inputs, outputs=[slider_out, grid_out])
demo.load(fn=run_all, inputs=sim_inputs, outputs=[slider_out, grid_out])
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860, share=False,
theme=gr.themes.Soft()) |