| |
| """ |
| DEFER-RL Radiologist Reader Study - Gradio app (deployable as a Hugging Face Space). |
| |
| Design goal: judging one case needs ZERO scrolling-to-understand and ZERO guessing. |
| Every rating control sits directly under the image it refers to, every scale legend is |
| printed inline, every rating value has a hover tooltip, and each image has its own |
| display-only zoom / brightness / contrast strip. |
| |
| Study adaptation |
| ---------------- |
| Each CASE shows, for one patient study: |
| * LEFT cell of every row = the REFERENCE imaging (always visible for comparison). |
| * RIGHT cell, ONE ROW PER ITEM = an anonymized deferral-system DECISION panel |
| (DEFER-RL plus baselines). Order is randomized per (annotator, case); names hidden. |
| * FINAL ROW = the reference-standard / ground-truth panel. |
| The reader rates each panel's decision; the backend DERIVES best/worst/rankings. |
| """ |
|
|
| import os, io, json, base64, random, hashlib, datetime, threading, pathlib |
| import numpy as np |
| from PIL import Image, ImageDraw, ImageFont |
| import gradio as gr |
|
|
| |
| N_PER_PAGE_NOTE = "" |
| SCHEMA_VERSION = "deferrl-reader-1" |
| DATA_DIR = pathlib.Path(os.environ.get("DATA_DIR", "data")) |
| RESP_DIR = pathlib.Path(os.environ.get("RESP_DIR", "responses_local")) |
| DATASET_REPO = os.environ.get("DATASET_REPO", "").strip() |
| HF_TOKEN = os.environ.get("HF_TOKEN", "").strip() |
| COMMIT_EVERY_MIN= float(os.environ.get("COMMIT_EVERY_MIN", "1")) |
| MAX_ITEMS = int(os.environ.get("MAX_ITEMS", "5")) |
| RESP_DIR.mkdir(parents=True, exist_ok=True) |
| DATA_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| |
| |
| |
| try: |
| ANNOTATORS = json.loads(os.environ.get("ANNOTATORS", "")) or {"demo": "demo"} |
| except Exception: |
| ANNOTATORS = {"demo": "demo"} |
|
|
| |
| LIKERT = [ |
| ("1", "Clearly inappropriate: a decision a careful radiologist would not make here"), |
| ("2", "Probably inappropriate"), |
| ("3", "Equivocal / borderline"), |
| ("4", "Probably appropriate"), |
| ("5", "Clearly appropriate: the decision a careful radiologist would endorse"), |
| ] |
| LIKERT_ADEQ = [ |
| ("1", "Clearly inadequate: key evidence was ignored or never gathered"), |
| ("2", "Probably inadequate"), |
| ("3", "Equivocal"), |
| ("4", "Probably adequate"), |
| ("5", "Clearly adequate: the evidence gathered was sufficient and on point"), |
| ] |
| LIKERT_SOUND = [ |
| ("1", "Clearly unsound: the committed reading is wrong on a clinically important point"), |
| ("2", "Probably unsound"), |
| ("3", "Equivocal"), |
| ("4", "Probably sound"), |
| ("5", "Clearly sound: the committed reading is clinically correct"), |
| ] |
| YESNO = [("yes", "Yes"), ("no", "No")] |
| YPN = [("yes", "Yes"), ("partial", "Partial"), ("no", "No")] |
|
|
| |
| REQ_ITEM_DIMS_TRUST = ["appropriateness", "evidence", "soundness", "misleading"] |
| REQ_ITEM_DIMS_DEFER = ["appropriateness", "evidence", "misleading"] |
| REQ_CASE_DIMS = ["unsafe_to_autoread", "reference_adequate"] |
|
|
| |
| def _font(sz): |
| try: |
| return ImageFont.truetype("DejaVuSans.ttf", sz) |
| except Exception: |
| return ImageFont.load_default() |
|
|
| def make_placeholder(text, seed, w=360, h=300): |
| """A clearly-labelled grayscale CT/MRI-like placeholder so the Space runs and is |
| obviously recognizable as sample data before real cases are loaded.""" |
| rng = np.random.default_rng(seed) |
| base = rng.normal(70, 16, (h, w)).clip(0, 255) |
| yy, xx = np.mgrid[0:h, 0:w] |
| cx, cy = int(rng.integers(w // 4, 3 * w // 4)), int(rng.integers(h // 3, 3 * h // 4)) |
| r = int(rng.integers(26, 52)) |
| blob = np.exp(-(((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * r * r))) * rng.integers(80, 150) |
| img = (base + blob).clip(0, 255).astype("uint8") |
| im = Image.fromarray(img, "L").convert("RGB") |
| d = ImageDraw.Draw(im) |
| |
| d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=(255, 205, 70), width=3) |
| |
| lines = text.split("\n") |
| bh = 19 * len(lines) + 10 |
| d.rectangle([0, 0, w, bh], fill=(18, 20, 32)) |
| d.text((8, 6), text, fill=(255, 255, 255), font=_font(16)) |
| |
| d.rectangle([0, h - 22, w, h], fill=(60, 20, 20)) |
| d.text((8, h - 20), "SYNTHETIC SAMPLE - replace with real data", fill=(255, 190, 190), font=_font(12)) |
| d.rectangle([0, 0, w - 1, h - 1], outline=(150, 150, 150)) |
| return im |
|
|
| def synth_cases(n=6, k=4): |
| """Build a sample manifest + images. Real runs replace data/cases.json + data/images/.""" |
| imgdir = DATA_DIR / "images"; imgdir.mkdir(parents=True, exist_ok=True) |
| systems = ["defer_rl", "atcxr", "conformal_l2d", "always_defer", "chow_rule"][:k] |
| cohorts = ["LIDC-IDRI chest CT", "NLST LongCT", "Duke Breast MRI"] |
| cases = [] |
| for i in range(n): |
| cid = f"C{i+1:03d}" |
| coh = cohorts[i % len(cohorts)] |
| make_placeholder(f"{cid} REFERENCE\n{coh}", seed=i * 100 + 1).save(imgdir / f"{cid}_ref.png") |
| make_placeholder(f"{cid} REFERENCE STANDARD", seed=i * 100 + 2).save(imgdir / f"{cid}_gt.png") |
| items = [] |
| for j, s in enumerate(systems): |
| action = "Defer" if (i + j) % 3 == 0 or s == "always_defer" else "Trust" |
| reading = "(routed to radiologist)" if action == "Defer" else \ |
| ("No suspicious finding. BI-RADS 1." if (i + j) % 2 else "Indeterminate nodule, recommend follow-up.") |
| fn = f"{cid}_{s}.png" |
| make_placeholder(f"{cid} panel\n{action}", seed=i * 100 + 10 + j).save(imgdir / fn) |
| trail = [] |
| for t in range(3): |
| tfn = f"{cid}_{s}_t{t}.png" |
| make_placeholder(f"step {t+1}", seed=i * 1000 + j * 10 + t, w=120, h=100).save(imgdir / tfn) |
| trail.append(f"images/{tfn}") |
| items.append({"item_id": s, "action": action, "reading": reading, |
| "image": f"images/{fn}", "trail": trail}) |
| cases.append({ |
| "case_id": cid, "cohort": coh, |
| "reference_image": f"images/{cid}_ref.png", |
| "show_trail": (i % 2 == 0), |
| "ground_truth": {"image": f"images/{cid}_gt.png", |
| "text": f"Reference standard for {cid}: 8 mm spiculated nodule, right upper lobe; " |
| f"path-confirmed malignant." if i % 2 else |
| f"Reference standard for {cid}: no malignant finding; benign granuloma."}, |
| "items": items, |
| }) |
| (DATA_DIR / "cases.json").write_text(json.dumps(cases, indent=2), encoding="utf-8") |
| return cases |
|
|
| def load_cases(): |
| f = DATA_DIR / "cases.json" |
| if f.exists(): |
| return json.loads(f.read_text(encoding="utf-8")) |
| return synth_cases() |
|
|
| CASES = load_cases() |
| N_CASES = len(CASES) |
| CASE_BY_ID = {c["case_id"]: c for c in CASES} |
|
|
| import functools |
| DISPLAY_MAX = int(os.environ.get("DISPLAY_MAX", "800")) |
|
|
| @functools.lru_cache(maxsize=2048) |
| def b64img(rel_path): |
| """Downscaled PNG as base64 (cached, so a reference reused across rows is encoded once).""" |
| p = DATA_DIR / rel_path |
| try: |
| im = Image.open(p).convert("RGB") |
| except Exception: |
| im = make_placeholder("missing image", 0) |
| if max(im.size) > DISPLAY_MAX: |
| im.thumbnail((DISPLAY_MAX, DISPLAY_MAX)) |
| buf = io.BytesIO(); im.save(buf, "PNG") |
| return base64.b64encode(buf.getvalue()).decode("ascii") |
|
|
| |
| |
| |
| _LOCK = threading.Lock() |
|
|
| def _resp_path(annotator): |
| safe = "".join(ch for ch in annotator if ch.isalnum() or ch in "._-") or "anon" |
| return RESP_DIR / f"{safe}.jsonl" |
|
|
| def append_records(annotator, records): |
| with _LOCK: |
| with open(_resp_path(annotator), "a", encoding="utf-8") as fh: |
| for r in records: |
| fh.write(json.dumps(r, ensure_ascii=False) + "\n") |
|
|
| def read_records(annotator): |
| p = _resp_path(annotator) |
| if not p.exists(): |
| return [] |
| out = [] |
| with open(p, encoding="utf-8") as fh: |
| for line in fh: |
| line = line.strip() |
| if line: |
| try: out.append(json.loads(line)) |
| except Exception: pass |
| return out |
|
|
| def latest_values(annotator, case_id): |
| """Collapse the append-only log to the most recent value per (item_id, dimension).""" |
| vals = {} |
| for r in read_records(annotator): |
| if r.get("case_id") != case_id: |
| continue |
| vals[(r.get("item_id"), r.get("dimension"))] = (r.get("value"), r.get("ts", "")) |
| out = {} |
| for (iid, dim), (val, _ts) in vals.items(): |
| out.setdefault(iid, {})[dim] = val |
| return out |
|
|
| def case_complete(annotator, case, values=None): |
| v = values if values is not None else latest_values(annotator, case["case_id"]) |
| cl = v.get("__case__", {}) |
| if any(cl.get(d) in (None, "") for d in REQ_CASE_DIMS): |
| return False |
| for it in case["items"]: |
| req = REQ_ITEM_DIMS_TRUST if it["action"] == "Trust" else REQ_ITEM_DIMS_DEFER |
| got = v.get(it["item_id"], {}) |
| if any(got.get(d) in (None, "") for d in req): |
| return False |
| return True |
|
|
| def progress_counts(annotator): |
| done = sum(1 for c in CASES if case_complete(annotator, c)) |
| return done |
|
|
| def first_unfinished(annotator): |
| for i, c in enumerate(CASES): |
| if not case_complete(annotator, c): |
| return i |
| return 0 |
|
|
| |
| SCHEDULER = None |
| if DATASET_REPO and HF_TOKEN: |
| try: |
| from huggingface_hub import CommitScheduler |
| SCHEDULER = CommitScheduler( |
| repo_id=DATASET_REPO, repo_type="dataset", folder_path=str(RESP_DIR), |
| path_in_repo="responses", every=COMMIT_EVERY_MIN, token=HF_TOKEN, private=True, |
| squash_history=False, |
| ) |
| print(f"[storage] CommitScheduler -> {DATASET_REPO} every {COMMIT_EVERY_MIN} min") |
| except Exception as e: |
| print(f"[storage] CommitScheduler disabled ({e}); responses stay local under {RESP_DIR}") |
| else: |
| print(f"[storage] No DATASET_REPO/HF_TOKEN set; responses stay local under {RESP_DIR}") |
|
|
| |
| def presented_items(annotator, case): |
| seed = int(hashlib.sha256(f"{annotator}|{case['case_id']}".encode()).hexdigest(), 16) % (2**32) |
| rng = random.Random(seed) |
| items = list(case["items"]) |
| rng.shuffle(items) |
| return items |
|
|
| |
| def _esc(s): return (str(s).replace("&", "&").replace("<", "<").replace(">", ">")) |
|
|
| def img_cell(uid, b64, caption, h=210): |
| return f""" |
| <div class="cell"> |
| <div class="cap">{_esc(caption)}</div> |
| <div class="imgwrap" style="height:{h}px"> |
| <img id="{uid}" class="dimg" src="data:image/png;base64,{b64}" draggable="false"/> |
| </div> |
| <div class="vctrl"> |
| <button type="button" title="Zoom in" onclick="dzoom('{uid}',0.2)">+</button> |
| <button type="button" title="Zoom out" onclick="dzoom('{uid}',-0.2)">−</button> |
| <button type="button" title="Reset view (display only)" onclick="dreset('{uid}')">↻</button> |
| <span class="vlab" title="Brightness (display only)">☀</span> |
| <input id="{uid}-b" type="range" min="40" max="200" value="100" |
| title="Brightness (display only)" oninput="dset('{uid}','b',this.value)"/> |
| <span class="vlab" title="Contrast (display only)">◐</span> |
| <input id="{uid}-c" type="range" min="40" max="200" value="100" |
| title="Contrast (display only)" oninput="dset('{uid}','c',this.value)"/> |
| </div> |
| </div>""" |
|
|
| def trail_html(uid_base, trail): |
| if not trail: |
| return "" |
| thumbs = "".join( |
| f'<img class="trailthumb" src="data:image/png;base64,{b64img(t)}" title="Evidence step {i+1}"/>' |
| for i, t in enumerate(trail)) |
| return f'<div class="trail"><span class="traillab">Evidence trail the system examined:</span>{thumbs}</div>' |
|
|
| def radio_group(item_id, dim, label, legend, options, prefill, qlevel="item"): |
| pre = (prefill or {}).get(item_id, {}).get(dim) if qlevel == "item" else (prefill or {}).get("__case__", {}).get(dim) |
| opts = "" |
| for val, tip in options: |
| chk = "checked" if str(pre) == str(val) else "" |
| opts += (f'<label class="ropt" title="{_esc(tip)}">' |
| f'<input type="radio" data-qlevel="{qlevel}" data-item="{_esc(item_id)}" ' |
| f'data-dim="{_esc(dim)}" value="{_esc(val)}" {chk}/>{_esc(val)}</label>') |
| return (f'<div class="rgroup"><div class="rlabel">{label}' |
| f'<span class="legend">{legend}</span></div><div class="ropts">{opts}</div></div>') |
|
|
| LETTERS = "ABCDEFGH" |
|
|
| def render_case_html(annotator, idx, prefill=None, error=""): |
| case = CASES[idx] |
| pres = presented_items(annotator, case) |
| ref_b64 = b64img(case["reference_image"]) |
| gt_b64 = b64img(case["ground_truth"]["image"]) |
| done = progress_counts(annotator) |
|
|
| err = f'<div class="errbox">{_esc(error)}</div>' if error else "" |
|
|
| intro = f""" |
| <div class="intro"> |
| <div class="provenance"> |
| <b>What you are looking at.</b> The lettered panels below are decisions produced by several |
| automated systems on this same {_esc(case.get('cohort','imaging'))} study. Their order is shuffled and |
| their identities are hidden. At least one panel is a simple reference baseline (for example, a system |
| that always defers, or one that uses only a confidence threshold). Each panel states the system's |
| <b>action</b> — <b>Trust</b> (it commits an automated reading) or <b>Defer</b> (it routes the case |
| to a radiologist) — the evidence it examined, and, when it chose Trust, the reading it produced. |
| You are rating each panel's decision on its own; you are never asked to pick a best or worst panel. |
| </div> |
| <div class="resume">You can stop and resume at any time — your answers are saved when you press |
| <b>Save & Next</b>, and you will return to the first case you have not finished.</div> |
| </div>""" |
|
|
| |
| caselevel = f""" |
| <div class="caselevel"> |
| <div class="clhead">Your own read first (before scoring the panels)</div> |
| {radio_group("__case__","unsafe_to_autoread", |
| "Should this case <u>not</u> be auto-read? ", |
| "(Is this a study where an automated reading should not be trusted, and a radiologist must see it?)", |
| YPN, prefill, qlevel="case")} |
| {radio_group("__case__","reference_adequate", |
| "Is the reference imaging adequate to judge this case? ", |
| "(yes / partial / no)", YPN, prefill, qlevel="case")} |
| </div>""" |
|
|
| rows = "" |
| for p, it in enumerate(pres): |
| letter = LETTERS[p] |
| ref_uid = f"ref-{idx}-{p}" |
| it_uid = f"it-{idx}-{p}" |
| action = it["action"] |
| if action == "Trust": |
| actline = (f'<div class="action"><span class="trust">Action: TRUST</span> — committed reading: ' |
| f'<i>{_esc(it["reading"])}</i></div>') |
| else: |
| actline = '<div class="action"><span class="defer">Action: DEFER</span> — routed to a radiologist</div>' |
| trail = trail_html(it_uid, it.get("trail", [])) if case.get("show_trail") else \ |
| '<div class="trail muted">(evidence trail hidden for this case)</div>' |
|
|
| |
| if action == "Trust": |
| sound = radio_group(it["item_id"], "soundness", |
| "Was the committed reading clinically sound? ", |
| "(1 clearly unsound · 3 equivocal · 5 clearly sound)", |
| LIKERT_SOUND, prefill) |
| else: |
| sound = ('<div class="rgroup nabox"><div class="rlabel">Reading soundness</div>' |
| '<div class="na">Not applicable — this panel deferred and committed no reading.</div></div>') |
|
|
| mislead = (f'<div class="rgroup"><div class="rlabel">Was this decision ' |
| f'<span class="def">misleading</span>?</div>' |
| f'<div class="defbox"><b>misleading = the decision looks confident and plausible but is ' |
| f'clinically wrong.</b> Example: the system <b>Trusts</b> a “no nodule” reading on a ' |
| f'study that in fact shows an 8 mm spiculated nodule; or it <b>Defers</b> an obviously ' |
| f'benign study as if it were dangerous.</div>' |
| + radio_group(it["item_id"], "misleading", "", "", YESNO, prefill) + '</div>') |
|
|
| rows += f""" |
| <div class="row"> |
| {img_cell(ref_uid, ref_b64, "REFERENCE imaging (compare against this)", 210)} |
| <div class="cell itemcell"> |
| {img_cell(it_uid, b64img(it["image"]), f"Panel {letter}", 210)} |
| {trail} |
| {actline} |
| {radio_group(it["item_id"], "appropriateness", |
| "Was this decision appropriate? ", |
| "(1 clearly inappropriate · 2 probably · 3 equivocal · 4 probably appropriate · 5 clearly appropriate)", |
| LIKERT, prefill)} |
| {radio_group(it["item_id"], "evidence", |
| "Was the evidence gathering adequate/justified? ", |
| "(1 clearly inadequate · 3 equivocal · 5 clearly adequate)", |
| LIKERT_ADEQ, prefill)} |
| {sound} |
| {mislead} |
| </div> |
| </div>""" |
|
|
| gtrow = f""" |
| <div class="row gtrow"> |
| <div class="cell muted"><div class="cap">—</div></div> |
| <div class="cell"> |
| {img_cell(f"gt-{idx}", gt_b64, "Reference standard (revealed to help your soundness judgement)", 200)} |
| <div class="gttext">{_esc(case["ground_truth"]["text"])}</div> |
| </div> |
| </div>""" |
|
|
| header = (f'<div class="chead"><span>Case <b>{_esc(case["case_id"])}</b> ' |
| f'· {_esc(case.get("cohort",""))}</span>' |
| f'<span class="prog">Completed {done} / {N_CASES}</span></div>') |
|
|
| return (f'<div id="case_form" data-case-id="{_esc(case["case_id"])}">' |
| f'{header}{err}{intro}{caselevel}' |
| f'<div class="tablehdr"><div>Reference</div><div>System decision to rate</div></div>' |
| f'{rows}{gtrow}</div>') |
|
|
| |
| def build_records(annotator, case, collected): |
| ts = datetime.datetime.utcnow().isoformat() + "Z" |
| common = dict(schema_version=SCHEMA_VERSION, ts=ts, annotator=annotator, |
| case_id=case["case_id"], case_condition_show_trail=bool(case.get("show_trail"))) |
| recs = [] |
| |
| for d in REQ_CASE_DIMS: |
| recs.append({**common, "item_id": "__case__", "dimension": d, |
| "value": collected.get("case_level", {}).get(d)}) |
| |
| pres = presented_items(annotator, case) |
| pos = {it["item_id"]: i for i, it in enumerate(pres)} |
| for it in case["items"]: |
| iid = it["item_id"] |
| got = collected.get("items", {}).get(iid, {}) |
| dims = REQ_ITEM_DIMS_TRUST if it["action"] == "Trust" else REQ_ITEM_DIMS_DEFER |
| for d in dims: |
| recs.append({**common, "item_id": iid, "dimension": d, "value": got.get(d), |
| "presented_pos": pos.get(iid), "item_action": it["action"]}) |
| return recs |
|
|
| def validate(case, collected): |
| missing = [] |
| cl = collected.get("case_level", {}) |
| for d in REQ_CASE_DIMS: |
| if cl.get(d) in (None, ""): |
| missing.append(f"case-level: {d}") |
| pres_letter = {it["item_id"]: i for i, it in enumerate(case["items"])} |
| for it in case["items"]: |
| got = collected.get("items", {}).get(it["item_id"], {}) |
| dims = REQ_ITEM_DIMS_TRUST if it["action"] == "Trust" else REQ_ITEM_DIMS_DEFER |
| for d in dims: |
| if got.get(d) in (None, ""): |
| missing.append(f"a panel: {d}") |
| return missing |
|
|
| def collected_to_prefill(collected): |
| pf = {"__case__": dict(collected.get("case_level", {}))} |
| for iid, dims in collected.get("items", {}).items(): |
| pf[iid] = dict(dims) |
| return pf |
|
|
| def save_and_next(collected_json, idx, request: gr.Request): |
| annotator = request.username if request and request.username else "anon" |
| idx = int(idx) |
| case = CASES[idx] |
| try: |
| collected = json.loads(collected_json) if collected_json else {} |
| except Exception: |
| collected = {} |
| missing = validate(case, collected) |
| if missing: |
| uniq = [] |
| for m in missing: |
| if m not in uniq: |
| uniq.append(m) |
| msg = "Please complete every rating before saving. Missing: " + "; ".join(uniq[:6]) |
| if len(uniq) > 6: |
| msg += " ..." |
| html = render_case_html(annotator, idx, prefill=collected_to_prefill(collected), error=msg) |
| return html, f"Completed {progress_counts(annotator)} / {N_CASES}", idx |
| |
| append_records(annotator, build_records(annotator, case, collected)) |
| nxt = idx |
| for j in list(range(idx + 1, N_CASES)) + list(range(0, idx + 1)): |
| if not case_complete(annotator, CASES[j]): |
| nxt = j; break |
| else: |
| nxt = min(idx + 1, N_CASES - 1) |
| pf = latest_values(annotator, CASES[nxt]["case_id"]) |
| html = render_case_html(annotator, nxt, prefill=pf) |
| return html, f"Completed {progress_counts(annotator)} / {N_CASES}", nxt |
|
|
| def go_prev(idx, request: gr.Request): |
| annotator = request.username if request and request.username else "anon" |
| idx = max(0, int(idx) - 1) |
| pf = latest_values(annotator, CASES[idx]["case_id"]) |
| return render_case_html(annotator, idx, prefill=pf), f"Completed {progress_counts(annotator)} / {N_CASES}", idx |
|
|
| def on_load(request: gr.Request): |
| annotator = request.username if request and request.username else "anon" |
| idx = first_unfinished(annotator) |
| pf = latest_values(annotator, CASES[idx]["case_id"]) |
| who = f"Signed in as <b>{_esc(annotator)}</b>" |
| return (render_case_html(annotator, idx, prefill=pf), |
| f"Completed {progress_counts(annotator)} / {N_CASES}", idx, who) |
|
|
| |
| HEAD_JS = """ |
| <script> |
| // ---- per-image display controls (display only; never touch stored data) ---- |
| window.dxf = function(im){var z=im.dataset.zoom||1,b=im.dataset.b||100,c=im.dataset.c||100; |
| im.style.transform='scale('+z+')'; im.style.filter='brightness('+b+'%) contrast('+c+'%)';}; |
| window.dzoom = function(id,d){var im=document.getElementById(id); if(!im)return; |
| var z=parseFloat(im.dataset.zoom||1)+d; z=Math.max(0.3,Math.min(6,z)); im.dataset.zoom=z; dxf(im);}; |
| window.dset = function(id,k,v){var im=document.getElementById(id); if(!im)return; im.dataset[k]=v; dxf(im);}; |
| window.dreset = function(id){var im=document.getElementById(id); if(!im)return; |
| im.dataset.zoom=1; im.dataset.b=100; im.dataset.c=100; dxf(im); |
| var b=document.getElementById(id+'-b'); if(b)b.value=100; |
| var c=document.getElementById(id+'-c'); if(c)c.value=100;}; |
| // ---- harvest all answers from the case form into one JSON string ---- |
| window.collectAnswers = function(){ |
| var form=document.getElementById('case_form'); |
| if(!form) return JSON.stringify({}); |
| var ans={case_id:form.getAttribute('data-case-id'), case_level:{}, items:{}}; |
| form.querySelectorAll('input[type=radio]:checked').forEach(function(inp){ |
| var dim=inp.getAttribute('data-dim'), iid=inp.getAttribute('data-item'), |
| lvl=inp.getAttribute('data-qlevel'); |
| if(lvl==='case'){ ans.case_level[dim]=inp.value; } |
| else { if(!ans.items[iid]) ans.items[iid]={}; ans.items[iid][dim]=inp.value; } |
| }); |
| return JSON.stringify(ans); |
| }; |
| </script> |
| """ |
|
|
| CSS = """ |
| :root{ --gap:10px; } |
| #case_form{ max-width:1180px; margin:0 auto; } |
| .chead{ display:flex; justify-content:space-between; align-items:center; font-size:15px; |
| padding:6px 4px; border-bottom:2px solid #444; margin-bottom:6px;} |
| .chead .prog{ font-weight:700; } |
| .errbox{ background:#fde8e8; border:1px solid #e02424; color:#9b1c1c; padding:8px 10px; |
| border-radius:6px; margin:8px 0; font-weight:600;} |
| .intro{ font-size:12.5px; line-height:1.45; margin:6px 0 10px; } |
| .provenance{ background:#f3f6ff; border:1px solid #c9d6ff; padding:8px 10px; border-radius:6px;} |
| .resume{ color:#444; margin-top:5px; } |
| .caselevel{ background:#fffaf0; border:1px solid #f0d9a8; border-radius:6px; padding:8px 10px; margin-bottom:10px;} |
| .clhead{ font-weight:700; margin-bottom:4px; } |
| .tablehdr{ display:grid; grid-template-columns:1fr 1.3fr; gap:var(--gap); font-weight:700; |
| border-bottom:1px solid #999; padding:3px 2px; position:sticky; top:0; background:var(--body-background-fill,#fff); z-index:5;} |
| .row{ display:grid; grid-template-columns:1fr 1.3fr; gap:var(--gap); padding:8px 0; |
| border-bottom:1px solid #ddd; align-items:start; } |
| .gtrow{ background:#f7f7f7; } |
| .cell{ min-width:0; } |
| .itemcell{ } |
| .cap{ font-size:11.5px; font-weight:700; color:#333; margin-bottom:3px; } |
| .imgwrap{ overflow:hidden; border:1px solid #bbb; border-radius:4px; background:#000; |
| display:flex; align-items:center; justify-content:center; } |
| .dimg{ width:100%; height:100%; object-fit:contain; transform-origin:center center; |
| transition:transform .05s linear; user-select:none; } |
| .vctrl{ display:flex; align-items:center; gap:4px; margin:4px 0 2px; flex-wrap:wrap; font-size:12px;} |
| .vctrl button{ width:24px; height:22px; cursor:pointer; border:1px solid #999; border-radius:4px; background:#eee;} |
| .vctrl input[type=range]{ width:78px; } |
| .vlab{ font-size:13px; } |
| .trail{ font-size:11px; margin:4px 0; display:flex; align-items:center; gap:4px; flex-wrap:wrap;} |
| .traillab{ color:#555; } |
| .trailthumb{ height:42px; border:1px solid #aaa; border-radius:3px; } |
| .trail.muted{ color:#999; font-style:italic; } |
| .action{ font-size:12.5px; margin:5px 0; } |
| .trust{ color:#0b6b2e; font-weight:700; } |
| .defer{ color:#9a4d00; font-weight:700; } |
| .rgroup{ margin:5px 0; } |
| .rlabel{ font-size:12.5px; font-weight:600; } |
| .legend{ font-weight:400; color:#555; font-size:11px; margin-left:4px; } |
| .ropts{ display:flex; gap:6px; flex-wrap:wrap; margin-top:2px; } |
| .ropt{ display:inline-flex; align-items:center; gap:2px; font-size:12.5px; cursor:pointer; |
| border:1px solid #ccc; border-radius:4px; padding:1px 6px; } |
| .ropt:hover{ background:#eef3ff; } |
| .def{ color:#c00; font-weight:800; } |
| .defbox{ color:#c00; font-size:11px; background:#fff5f5; border:1px solid #f3b4b4; |
| border-radius:5px; padding:4px 6px; margin:3px 0; line-height:1.35;} |
| .nabox .na{ font-size:11.5px; color:#777; font-style:italic; } |
| .gttext{ font-size:12px; margin-top:4px; } |
| .muted{ color:#999; } |
| """ |
|
|
| |
| def auth_fn(username, password): |
| return username in ANNOTATORS and ANNOTATORS[username] == password |
|
|
| |
| with gr.Blocks(title="DEFER-RL Reader Study") as demo: |
| gr.Markdown("## DEFER-RL Radiologist Reader Study") |
| with gr.Row(): |
| who_md = gr.Markdown("") |
| prog_md = gr.Markdown("") |
| logout = gr.Button("Log out", link="/logout", size="sm") |
|
|
| case_html = gr.HTML() |
| collected_box = gr.Textbox(visible=False) |
| idx_state = gr.State(0) |
|
|
| with gr.Row(): |
| prev_btn = gr.Button("◀ Previous", size="sm") |
| save_btn = gr.Button("Save & Next ▶", variant="primary") |
|
|
| |
| save_btn.click( |
| fn=save_and_next, |
| inputs=[collected_box, idx_state], |
| outputs=[case_html, prog_md, idx_state], |
| js="(j, idx) => [window.collectAnswers(), idx]", |
| ) |
| prev_btn.click(fn=go_prev, inputs=[idx_state], outputs=[case_html, prog_md, idx_state]) |
|
|
| demo.load(fn=on_load, inputs=None, outputs=[case_html, prog_md, idx_state, who_md]) |
|
|
| if __name__ == "__main__": |
| |
| demo.queue().launch( |
| auth=auth_fn, |
| auth_message="DEFER-RL reader study. Enter your assigned annotator name and password.", |
| css=CSS, head=HEAD_JS, theme=gr.themes.Soft(), |
| ) |
|
|