| """ |
| 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 |
|
|
| |
| |
| |
|
|
| _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) |
| DEFAULT_VF_FILE = os.path.join(_SCRIPT_DIR, "glaucoma_vf_example.xlsx") |
|
|
| |
| _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 |
| |
| _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], |
| } |
| } |
|
|
|
|
| |
| |
| |
| 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." |
|
|
| |
| 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 = {} |
| 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")] |
|
|
| |
| |
| |
| 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 |
|
|
| |
| 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_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 |
|
|
|
|
| |
| |
| |
| 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) |
|
|
|
|
| |
| |
| |
| 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) |
|
|
|
|
| |
| |
| |
| 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 = [] |
| pts_val = [] |
|
|
| 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) |
|
|
| |
| cols = np.arange(W, dtype=np.float32) |
| rows = np.arange(H, dtype=np.float32) |
| grid_c, grid_r = np.meshgrid(cols, rows) |
|
|
| |
| |
| field_nn = griddata(pts_xy, pts_val, |
| (grid_c, grid_r), method="nearest").astype(np.float32) |
|
|
| |
| field = gaussian_filter(field_nn, sigma=sigma) |
| return np.clip(field, 0.0, 1.0) |
|
|
|
|
| |
| |
| |
| 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) |
| """ |
| |
| |
| |
| DARK_DEPTH = 0.35 |
|
|
| 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) |
|
|
| |
| |
| |
| |
| |
| 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) |
| |
|
|
| |
| if blur_scotoma: |
| blurred = np.array(img.filter(ImageFilter.GaussianBlur(radius=14)), dtype=np.float32) |
| arr = arr * (1.0 - loss[:, :, None]) + blurred * loss[:, :, None] |
|
|
| |
| 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] |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| draw.text((ox + panel_w // 2 - len(lat) * 4, PAD_TOP - 20), lat, fill=(30, 30, 30)) |
|
|
| |
| 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)) |
| |
| 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)) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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") |
| |
| 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() |
|
|
| |
| _load_demo() |
|
|
|
|
| |
| |
| |
| 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) |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
| _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(): |
| |
| 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") |
|
|
| |
| 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()) |