VFsim / app.py
hmgill's picture
Update app.py
2ccd97a verified
"""
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())