""" 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())