DrSyedFaizan commited on
Commit
74cc258
·
verified ·
1 Parent(s): 4ec95a7

Upload folder using huggingface_hub

Browse files
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # NEVER commit secrets or local response data
2
+ Keys.txt
3
+ keys.txt
4
+ *.token
5
+ .env
6
+ local_responses/
7
+ __pycache__/
8
+ *.pyc
9
+ # real patient images should come from a PRIVATE CASES_DATASET, not the repo:
10
+ data/img/*_real*
README.md CHANGED
@@ -1,13 +1,67 @@
1
  ---
2
- title: Grace Reader Study
3
- emoji: 🦀
4
- colorFrom: yellow
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: GRACE Reader Study
3
+ emoji: 🩺
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 4.44.1
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # GRACE Reader Study annotation app
13
+
14
+ Blinded, resume-safe radiologist reader study for the GRACE project. One case per screen,
15
+ no scrolling: reference CXR on the left, each anonymized system's output on the right with its
16
+ rating controls directly beneath the image it refers to. Scale legends and subjective-term
17
+ definitions are printed inline; per-image zoom / brightness / contrast are display-only.
18
+
19
+ ## What a reader does per case
20
+ For each anonymized item: **answer correctness** (Correct / Incorrect / Indeterminate),
21
+ **decision appropriateness** (1-5), **grounding relevance** (Relevant / Partial / Not relevant),
22
+ optional note. Plus one case-level question: is the reference (ground-truth) annotation acceptable.
23
+ No best/worst ranking is asked; rankings are derived in the backend from per-item scores.
24
+
25
+ ## Run locally
26
+ ```bash
27
+ pip install -r requirements.txt
28
+ python build_cases_example.py # generates data/cases.json + placeholder images (demo)
29
+ python app.py # http://127.0.0.1:7860
30
+ ```
31
+ Demo credentials (when READER_CREDENTIALS is unset): `reader1 / changeme`, `reader2 / changeme`.
32
+
33
+ ## Deploy as a Hugging Face Space
34
+ 1. Create a **Gradio** Space.
35
+ 2. Upload `app.py`, `requirements.txt`, `README.md`, and (for a demo) the `data/` folder.
36
+ For the real study do NOT commit patient images: put `cases.json` + images in a **private
37
+ dataset** and set `CASES_DATASET` instead (see below).
38
+ 3. In **Settings -> Variables and secrets**, add these **secrets** (never commit them):
39
+ - `HF_TOKEN` = your HF token with **write** permission (the value in `Keys.txt`; do not paste it into any file).
40
+ - `READER_CREDENTIALS` = JSON, e.g. `{"Dr Kotak":"<pw1>","Dr B":"<pw2>","Resident C":"<pw3>"}`.
41
+ - `RESPONSE_DATASET` = e.g. `DrSyedFaizan/grace-reader-responses` (created automatically, private).
42
+ - `CASES_DATASET` (optional) = e.g. `DrSyedFaizan/grace-reader-cases` (private; pulled at boot).
43
+ - `APP_SECRET` (optional) = any random string that signs resume tokens.
44
+
45
+ ## Data flow and storage
46
+ - Responses stream to the private dataset `RESPONSE_DATASET` on every **Save & Next**, as
47
+ `responses/<reader>.jsonl`, and to a local backup under `local_responses/` (so an HF write
48
+ hiccup never loses data).
49
+ - **Robust schema:** one append-only record per `(annotator, case_id, item_id)` with a
50
+ `dims` dict of value-per-dimension, plus a `__case__` record for case-level answers. Later
51
+ changes to layout or wording can never overwrite or invalidate saved annotations; reads take
52
+ the latest record per key.
53
+
54
+ ## Auth / session / resume
55
+ - First visit always lands on the login page; the app is gated by per-reader credentials.
56
+ - On login the reader resumes at their first unfinished case and sees a live `X / N` counter
57
+ (their own completed count, not a reset to 1). Item order is shuffled deterministically per
58
+ `(reader, case)` so a resumed case looks identical.
59
+ - Closing the tab does NOT log out (a signed token is kept in `localStorage`); use the explicit
60
+ **Logout** button to end the session.
61
+
62
+ ## Preparing the real cases.json
63
+ Run `build_cases_example.py` and read its docstring for the exact schema. Each item's `item_id`
64
+ is the TRUE system id (used only in the backend for the blinded GRACE-vs-baseline comparison and
65
+ derived rankings); the reader never sees it. Keep `items` per case small (2-3) so the no-scroll
66
+ goal holds. Enrich the case set per `../reader_protocol.md` (near-boundary, deferrals, errors,
67
+ rare pathology, easy anchors).
app.py ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GRACE Reader Study - expert radiologist annotation app (Gradio / Hugging Face Space).
3
+
4
+ Design goal: annotating one case requires ZERO scrolling and ZERO guessing.
5
+ Everything needed to judge and score a case is visible at once:
6
+ - two-column layout, one row per rated item (left = reference CXR, right = the item to score);
7
+ - every rating control sits directly below the image it refers to;
8
+ - full scale legends printed inline, subjective definitions in bold red with a worked example;
9
+ - per-image zoom / brightness / contrast controls (display-only, never touch stored data);
10
+ - no best/worst ranking asked; rankings are DERIVED in the backend from per-item scores.
11
+
12
+ Secrets/config come from environment variables (set them as HF Space secrets):
13
+ HF_TOKEN : HF token with write permission (for the private response dataset).
14
+ RESPONSE_DATASET : private dataset repo id for responses (default DrSyedFaizan/grace-reader-responses).
15
+ CASES_DATASET : optional private dataset holding cases.json + images (snapshot_download at boot).
16
+ READER_CREDENTIALS : JSON string {"reader_name": "password", ...}. If unset, a demo login is used (warned).
17
+ APP_SECRET : secret string used to sign resume tokens (defaults derived from HF_TOKEN).
18
+
19
+ NEVER commit Keys.txt or any token into the Space repo (see .gitignore).
20
+ """
21
+
22
+ import os
23
+ import io
24
+ import json
25
+ import time
26
+ import base64
27
+ import random
28
+ import hashlib
29
+ import hmac
30
+ from pathlib import Path
31
+
32
+ import gradio as gr
33
+ from PIL import Image
34
+
35
+ # ----------------------------------------------------------------------------- config
36
+ APP_DIR = Path(__file__).parent
37
+ DATA_DIR = APP_DIR / "data"
38
+ LOCAL_BACKUP_DIR = APP_DIR / "local_responses"
39
+ LOCAL_BACKUP_DIR.mkdir(exist_ok=True)
40
+
41
+ HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
42
+ RESPONSE_DATASET = os.environ.get("RESPONSE_DATASET", "DrSyedFaizan/grace-reader-responses").strip()
43
+ CASES_DATASET = os.environ.get("CASES_DATASET", "").strip()
44
+ APP_SECRET = os.environ.get("APP_SECRET", "").strip() or ("grace-" + hashlib.sha256(HF_TOKEN.encode()).hexdigest()[:16] if HF_TOKEN else "grace-dev-secret")
45
+
46
+ MAX_ITEMS = 6 # max anonymized items rated per case; keep cases small (2-3) to preserve no-scroll
47
+ DISPLAY_MAX_W = 820 # px, display-copy width cap (does not affect stored data)
48
+ SCHEMA_VERSION = 1
49
+
50
+ try:
51
+ _creds = json.loads(os.environ.get("READER_CREDENTIALS", "").strip() or "{}")
52
+ except Exception:
53
+ _creds = {}
54
+ if not _creds:
55
+ _creds = {"reader1": "changeme", "reader2": "changeme"}
56
+ print("[WARN] READER_CREDENTIALS not set - using demo credentials. Set the secret before the real study.")
57
+ READERS = {str(k): str(v) for k, v in _creds.items()}
58
+
59
+ # ----------------------------------------------------------------------------- HF api
60
+ try:
61
+ from huggingface_hub import HfApi
62
+ _api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
63
+ except Exception as e: # library missing at import time
64
+ _api = None
65
+ print(f"[WARN] huggingface_hub unavailable: {e}")
66
+
67
+
68
+ def _ensure_response_dataset():
69
+ if not _api:
70
+ return
71
+ try:
72
+ _api.create_repo(RESPONSE_DATASET, repo_type="dataset", private=True, exist_ok=True)
73
+ except Exception as e:
74
+ print(f"[WARN] could not ensure response dataset: {e}")
75
+
76
+
77
+ # ----------------------------------------------------------------------------- cases
78
+ def _maybe_pull_cases():
79
+ """If CASES_DATASET is set, snapshot it into ./data (cases.json + images)."""
80
+ if not CASES_DATASET or not _api:
81
+ return
82
+ try:
83
+ from huggingface_hub import snapshot_download
84
+ snapshot_download(CASES_DATASET, repo_type="dataset", local_dir=str(DATA_DIR),
85
+ token=HF_TOKEN, local_dir_use_symlinks=False)
86
+ print(f"[info] pulled cases from {CASES_DATASET}")
87
+ except Exception as e:
88
+ print(f"[WARN] could not pull CASES_DATASET: {e}")
89
+
90
+
91
+ def load_cases():
92
+ _maybe_pull_cases()
93
+ cj = DATA_DIR / "cases.json"
94
+ if not cj.exists():
95
+ print("[WARN] no data/cases.json found - run build_cases_example.py to generate a demo set.")
96
+ return []
97
+ with open(cj, "r", encoding="utf-8") as f:
98
+ data = json.load(f)
99
+ return data.get("cases", [])
100
+
101
+
102
+ CASES = load_cases()
103
+ N_CASES = len(CASES)
104
+ CASE_BY_ID = {c["case_id"]: c for c in CASES}
105
+
106
+ _IMG_CACHE = {}
107
+
108
+
109
+ def img_data_uri(rel_path):
110
+ """Load an image, resize a display copy, return a base64 data URI (cached)."""
111
+ if not rel_path:
112
+ return ""
113
+ if rel_path in _IMG_CACHE:
114
+ return _IMG_CACHE[rel_path]
115
+ p = DATA_DIR / rel_path
116
+ try:
117
+ im = Image.open(p).convert("RGB")
118
+ if im.width > DISPLAY_MAX_W:
119
+ h = int(im.height * DISPLAY_MAX_W / im.width)
120
+ im = im.resize((DISPLAY_MAX_W, h))
121
+ buf = io.BytesIO()
122
+ im.save(buf, format="PNG")
123
+ uri = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
124
+ except Exception as e:
125
+ print(f"[WARN] image load failed {p}: {e}")
126
+ uri = ""
127
+ _IMG_CACHE[rel_path] = uri
128
+ return uri
129
+
130
+
131
+ # ----------------------------------------------------------------------------- tokens/auth
132
+ def make_token(name):
133
+ return hmac.new(APP_SECRET.encode(), name.encode(), hashlib.sha256).hexdigest()
134
+
135
+
136
+ def valid_token(name, tok):
137
+ return bool(name) and bool(tok) and hmac.compare_digest(make_token(name), tok)
138
+
139
+
140
+ # ----------------------------------------------------------------------------- storage
141
+ def _resp_path(annotator):
142
+ return f"responses/{annotator}.jsonl"
143
+
144
+
145
+ def load_existing_responses(annotator):
146
+ """Return list of records for this annotator (from HF dataset, else local backup)."""
147
+ records = []
148
+ if _api:
149
+ try:
150
+ from huggingface_hub import hf_hub_download
151
+ fp = hf_hub_download(RESPONSE_DATASET, _resp_path(annotator), repo_type="dataset", token=HF_TOKEN)
152
+ with open(fp, "r", encoding="utf-8") as f:
153
+ records = [json.loads(l) for l in f if l.strip()]
154
+ return records
155
+ except Exception:
156
+ pass
157
+ lb = LOCAL_BACKUP_DIR / f"{annotator}.jsonl"
158
+ if lb.exists():
159
+ with open(lb, "r", encoding="utf-8") as f:
160
+ records = [json.loads(l) for l in f if l.strip()]
161
+ return records
162
+
163
+
164
+ def completed_case_ids(records):
165
+ return {r["case_id"] for r in records if r.get("item_id") == "__case__"}
166
+
167
+
168
+ def save_records(annotator, new_records):
169
+ """Append records (append-only, keyed by (annotator, case_id, item_id, dimension))
170
+ and stream the full per-annotator file to the private dataset + a local backup."""
171
+ existing = load_existing_responses(annotator)
172
+ existing.extend(new_records)
173
+ payload = "\n".join(json.dumps(r, ensure_ascii=False) for r in existing) + "\n"
174
+
175
+ # local backup first (never lose data even if HF write fails)
176
+ with open(LOCAL_BACKUP_DIR / f"{annotator}.jsonl", "w", encoding="utf-8") as f:
177
+ f.write(payload)
178
+
179
+ if _api:
180
+ try:
181
+ _api.upload_file(
182
+ path_or_fileobj=payload.encode("utf-8"),
183
+ path_in_repo=_resp_path(annotator),
184
+ repo_id=RESPONSE_DATASET,
185
+ repo_type="dataset",
186
+ commit_message=f"responses {annotator} {int(time.time())}",
187
+ )
188
+ except Exception as e:
189
+ print(f"[WARN] HF upload failed (kept local backup): {e}")
190
+ return existing
191
+
192
+
193
+ # ----------------------------------------------------------------------------- ordering
194
+ def item_order(annotator, case):
195
+ """Deterministic per-(annotator, case) shuffle so resume shows the same order."""
196
+ items = list(case.get("items", []))
197
+ seed = int(hashlib.sha256(f"{annotator}|{case['case_id']}".encode()).hexdigest(), 16) % (2**32)
198
+ rnd = random.Random(seed)
199
+ rnd.shuffle(items)
200
+ return items
201
+
202
+
203
+ def first_unfinished(records):
204
+ done = completed_case_ids(records)
205
+ for i, c in enumerate(CASES):
206
+ if c["case_id"] not in done:
207
+ return i
208
+ return N_CASES # all done
209
+
210
+
211
+ # ----------------------------------------------------------------------------- UI text
212
+ def image_html(dom_id, uri):
213
+ """One image with its own display-only zoom/brightness/contrast strip."""
214
+ if not uri:
215
+ return f"<div class='imgcell'><div class='imgbox empty'>[no image]</div></div>"
216
+ return f"""
217
+ <div class='imgcell'>
218
+ <div class='imgbox'><img id='{dom_id}' src='{uri}' data-bright='1' data-contrast='1' data-zoom='1'></div>
219
+ <div class='strip'>
220
+ <button type='button' onclick="graceZoom('{dom_id}',-0.25)" title='zoom out'>&minus;</button>
221
+ <span class='lbl'>zoom</span>
222
+ <button type='button' onclick="graceZoom('{dom_id}',0.25)" title='zoom in'>+</button>
223
+ <label class='lbl'>bright<input type='range' min='0.3' max='2.5' step='0.05' value='1'
224
+ oninput="graceSet('{dom_id}','bright',this.value)"></label>
225
+ <label class='lbl'>contrast<input type='range' min='0.3' max='2.5' step='0.05' value='1'
226
+ oninput="graceSet('{dom_id}','contrast',this.value)"></label>
227
+ </div>
228
+ </div>"""
229
+
230
+
231
+ APPROP_LEGEND = ("**Decision appropriateness (1-5):** "
232
+ "1 = clearly inappropriate, 2 = probably inappropriate, 3 = borderline / unsure, "
233
+ "4 = probably appropriate, 5 = clearly appropriate.")
234
+ GROUND_DEF = ("<span class='reddef'><b>Grounding relevance</b> = does the highlighted region sit on the "
235
+ "actual finding? <b>Example:</b> a right-lower-lobe opacity with the highlight on the right "
236
+ "lower lobe = <b>Relevant</b>; the same case with the highlight on the heart border = "
237
+ "<b>Not relevant</b>.</span>")
238
+ APPROP_DEF = ("<span class='reddef'><b>Appropriate</b> = the system's choice to answer vs defer was the safe, "
239
+ "correct call for this image. <b>Example:</b> a subtle, ambiguous nodule where the system "
240
+ "<b>defers to a radiologist</b> = appropriate; an obvious large opacity it needlessly defers = "
241
+ "inappropriate.</span>")
242
+
243
+ INTRO_DEFAULT = (
244
+ "The item(s) below come from anonymized automated reading systems, shown in random order with all "
245
+ "identifying names hidden. For each item you see the system's <b>answer</b> to the clinical question, "
246
+ "whether it chose to <b>answer</b> or <b>defer to a radiologist</b>, and a <b>highlight</b> of the image "
247
+ "region it relied on. The final row shows the <b>reference region</b> (ground-truth annotation) for "
248
+ "comparison. You cannot tell which system produced which item, and that is intentional.")
249
+
250
+
251
+ # ----------------------------------------------------------------------------- head (JS + CSS)
252
+ HEAD = """
253
+ <style>
254
+ .imgcell { display:flex; flex-direction:column; gap:4px; }
255
+ .imgbox { overflow:auto; max-height:270px; border:1px solid #d0d5dd; border-radius:6px; background:#0b0b0b; }
256
+ .imgbox.empty { display:flex; align-items:center; justify-content:center; color:#999; height:120px; background:#f3f4f6; }
257
+ .imgbox img { display:block; max-width:100%; }
258
+ .strip { display:flex; align-items:center; gap:8px; flex-wrap:wrap; font-size:12px; }
259
+ .strip button { width:26px; height:24px; font-weight:700; cursor:pointer; }
260
+ .strip .lbl { color:#475467; }
261
+ .strip input[type=range] { width:90px; vertical-align:middle; }
262
+ .reddef { color:#c0261c; font-size:13px; display:block; margin:2px 0 6px; }
263
+ .legend { font-size:13px; color:#344054; }
264
+ .refcol { border-right:2px dashed #cbd5e1; padding-right:8px; }
265
+ .colhead { font-weight:700; font-size:13px; color:#101828; margin-bottom:2px; }
266
+ .answerbox { background:#eef2ff; border:1px solid #c7d2fe; border-radius:6px; padding:6px 8px; font-size:13px; margin:4px 0; }
267
+ </style>
268
+ <script>
269
+ window.graceApplyFilter = function(id){
270
+ var img = document.getElementById(id); if(!img) return;
271
+ var b = img.dataset.bright||1, c = img.dataset.contrast||1, z = img.dataset.zoom||1;
272
+ img.style.filter = 'brightness('+b+') contrast('+c+')';
273
+ img.style.transform = 'scale('+z+')'; img.style.transformOrigin = 'top left';
274
+ };
275
+ window.graceSet = function(id, kind, val){
276
+ var img = document.getElementById(id); if(!img) return;
277
+ img.dataset[kind] = val; window.graceApplyFilter(id);
278
+ };
279
+ window.graceZoom = function(id, delta){
280
+ var img = document.getElementById(id); if(!img) return;
281
+ var z = parseFloat(img.dataset.zoom||1) + delta;
282
+ if(z<0.2) z=0.2; if(z>6) z=6; img.dataset.zoom=z; window.graceApplyFilter(id);
283
+ };
284
+ // best-effort hover tooltips on the rating radios (meaning is also always shown inline)
285
+ window.graceTooltips = function(){
286
+ var map = {
287
+ 'Correct':'model answer matches the reference / your read',
288
+ 'Incorrect':'model answer does not match',
289
+ 'Indeterminate':'cannot tell from this image',
290
+ 'Relevant':'highlight sits on the actual finding',
291
+ 'Partial':'highlight partly overlaps the finding',
292
+ 'Not relevant':'highlight is on the wrong region',
293
+ '1':'clearly inappropriate','2':'probably inappropriate','3':'borderline / unsure',
294
+ '4':'probably appropriate','5':'clearly appropriate',
295
+ 'yes':'reference annotation is acceptable','partial':'reference partly acceptable','no':'reference not acceptable'
296
+ };
297
+ document.querySelectorAll('label').forEach(function(l){
298
+ var t=(l.textContent||'').trim(); if(map[t]) l.title=map[t];
299
+ });
300
+ };
301
+ setInterval(function(){ try{ window.graceTooltips(); }catch(e){} }, 1500);
302
+ </script>
303
+ """
304
+
305
+ # ----------------------------------------------------------------------------- app
306
+ def build_app():
307
+ with gr.Blocks(title="GRACE Reader Study", head=HEAD, theme=gr.themes.Soft()) as demo:
308
+ annotator_state = gr.State("")
309
+ index_state = gr.State(0)
310
+ order_state = gr.State([]) # true item_ids in shown order for current case
311
+
312
+ # hidden helpers for localStorage resume
313
+ user_ls = gr.Textbox(visible=False)
314
+ tok_ls = gr.Textbox(visible=False)
315
+ tok_out = gr.Textbox(visible=False) # token produced on login, written to localStorage
316
+
317
+ # ------------------------------------------------------------- LOGIN VIEW
318
+ with gr.Column(visible=True) as login_col:
319
+ gr.Markdown("## GRACE Reader Study - sign in")
320
+ gr.Markdown("Enter your reader name and password. You can stop and resume anytime; "
321
+ "closing the tab does **not** sign you out.")
322
+ name_in = gr.Textbox(label="Reader name", placeholder="your name")
323
+ pass_in = gr.Textbox(label="Password", type="password")
324
+ login_btn = gr.Button("Sign in", variant="primary")
325
+ login_msg = gr.Markdown("")
326
+
327
+ # ------------------------------------------------------------- STUDY VIEW
328
+ with gr.Column(visible=False) as study_col:
329
+ with gr.Row():
330
+ progress_md = gr.Markdown("0 / 0")
331
+ logout_btn = gr.Button("Logout", scale=0)
332
+ intro_html = gr.HTML("") # per-case provenance note (shown before the resume line)
333
+ gr.Markdown("You can stop and resume anytime. Your answers save the moment you press "
334
+ "**Save & Next**.")
335
+ question_md = gr.Markdown("")
336
+
337
+ # header row
338
+ with gr.Row():
339
+ gr.Markdown("<div class='colhead refcol'>REFERENCE image (for comparison)</div>")
340
+ gr.Markdown("<div class='colhead'>ITEM to score</div>")
341
+
342
+ gr.Markdown(f"<div class='legend'>{APPROP_LEGEND}</div>")
343
+ gr.HTML(APPROP_DEF)
344
+ gr.HTML(GROUND_DEF)
345
+
346
+ item_rows, ref_htmls, item_htmls, ans_mds = [], [], [], []
347
+ corr_rs, appr_rs, grnd_rs, note_tbs = [], [], [], []
348
+ for i in range(MAX_ITEMS):
349
+ with gr.Group(visible=False) as row:
350
+ with gr.Row():
351
+ with gr.Column(scale=1):
352
+ ref_htmls.append(gr.HTML("", elem_classes="refcol"))
353
+ with gr.Column(scale=1):
354
+ item_htmls.append(gr.HTML(""))
355
+ ans_mds.append(gr.Markdown(""))
356
+ corr_rs.append(gr.Radio(["Correct", "Incorrect", "Indeterminate"],
357
+ label="Answer correctness"))
358
+ appr_rs.append(gr.Radio(["1", "2", "3", "4", "5"],
359
+ label="Decision appropriateness (1-5)"))
360
+ grnd_rs.append(gr.Radio(["Relevant", "Partial", "Not relevant"],
361
+ label="Grounding relevance"))
362
+ note_tbs.append(gr.Textbox(label="Note (optional)", lines=1))
363
+ item_rows.append(row)
364
+
365
+ # ground-truth / reference-ROI row + case-level question
366
+ gr.Markdown("<div class='colhead'>Reference region (ground truth)</div>")
367
+ with gr.Row():
368
+ gt_ref_html = gr.HTML("", elem_classes="refcol")
369
+ gt_html = gr.HTML("")
370
+ case_acceptable = gr.Radio(
371
+ ["yes", "partial", "no"],
372
+ label="Is the reference (ground-truth) annotation acceptable for this case?")
373
+
374
+ status_md = gr.Markdown("")
375
+ save_btn = gr.Button("Save & Next", variant="primary")
376
+ done_md = gr.Markdown("", visible=False)
377
+
378
+ # ---- ordered output list for render (must match render_case return order).
379
+ # NOTE: status_md is deliberately NOT here; it is driven only by the save/login
380
+ # handlers (listing it twice would make it a duplicate output and clobber messages).
381
+ CASE_OUTPUTS = ([intro_html, question_md, gt_ref_html, gt_html, case_acceptable,
382
+ progress_md, done_md]
383
+ + ref_htmls + item_htmls + ans_mds
384
+ + corr_rs + appr_rs + grnd_rs + note_tbs + item_rows)
385
+
386
+ def render_case(annotator, idx, records=None):
387
+ if records is None:
388
+ records = load_existing_responses(annotator)
389
+ done_n = len(completed_case_ids(records))
390
+ base = {
391
+ "progress": f"**{done_n} / {N_CASES}** cases completed",
392
+ }
393
+ # finished everything
394
+ if idx >= N_CASES:
395
+ ups = [gr.update(value=""), gr.update(value=""), gr.update(value=""),
396
+ gr.update(value=""), gr.update(value=None),
397
+ gr.update(value=base["progress"]),
398
+ gr.update(value="### All cases complete. Thank you.", visible=True)]
399
+ ups += [gr.update(value="") for _ in ref_htmls]
400
+ ups += [gr.update(value="") for _ in item_htmls]
401
+ ups += [gr.update(value="") for _ in ans_mds]
402
+ ups += [gr.update(value=None) for _ in corr_rs]
403
+ ups += [gr.update(value=None) for _ in appr_rs]
404
+ ups += [gr.update(value=None) for _ in grnd_rs]
405
+ ups += [gr.update(value="") for _ in note_tbs]
406
+ ups += [gr.update(visible=False) for _ in item_rows]
407
+ return ups, []
408
+
409
+ case = CASES[idx]
410
+ ref_uri = img_data_uri(case.get("reference_image", ""))
411
+ items = item_order(annotator, case)
412
+ order_ids = [it["item_id"] for it in items]
413
+ intro = case.get("intro") or INTRO_DEFAULT
414
+ q = "### " + case.get("question", "Assess the finding in this chest X-ray.")
415
+
416
+ ref_ups, item_ups, ans_ups, corr_ups, appr_ups, grnd_ups, note_ups, row_ups = \
417
+ [], [], [], [], [], [], [], []
418
+ for i in range(MAX_ITEMS):
419
+ if i < len(items):
420
+ it = items[i]
421
+ ref_ups.append(gr.update(value=image_html(f"ref_{idx}_{i}", ref_uri)))
422
+ item_ups.append(gr.update(value=image_html(f"item_{idx}_{i}", img_data_uri(it.get("image", "")))))
423
+ dec = it.get("decision", "answer")
424
+ ans_ups.append(gr.update(value=(f"<div class='answerbox'><b>System decision:</b> "
425
+ f"{'ANSWERED' if dec=='answer' else 'DEFERRED to radiologist'}"
426
+ f"<br><b>Answer:</b> {it.get('answer','(none)')}</div>")))
427
+ corr_ups.append(gr.update(value=None))
428
+ appr_ups.append(gr.update(value=None))
429
+ grnd_ups.append(gr.update(value=None))
430
+ note_ups.append(gr.update(value=""))
431
+ row_ups.append(gr.update(visible=True))
432
+ else:
433
+ ref_ups.append(gr.update(value=""))
434
+ item_ups.append(gr.update(value=""))
435
+ ans_ups.append(gr.update(value=""))
436
+ corr_ups.append(gr.update(value=None))
437
+ appr_ups.append(gr.update(value=None))
438
+ grnd_ups.append(gr.update(value=None))
439
+ note_ups.append(gr.update(value=""))
440
+ row_ups.append(gr.update(visible=False))
441
+
442
+ head = [
443
+ gr.update(value=f"<p>{intro}</p>"),
444
+ gr.update(value=q),
445
+ gr.update(value=image_html(f"gtref_{idx}", ref_uri)),
446
+ gr.update(value=image_html(f"gt_{idx}", img_data_uri(case.get("groundtruth_image", "")))),
447
+ gr.update(value=None),
448
+ gr.update(value=base["progress"]),
449
+ gr.update(value="", visible=False),
450
+ ]
451
+ ups = head + ref_ups + item_ups + ans_ups + corr_ups + appr_ups + grnd_ups + note_ups + row_ups
452
+ return ups, order_ids
453
+
454
+ # ------------------------------------------------------------- login logic
455
+ def do_login(name, pw):
456
+ name = (name or "").strip()
457
+ if name not in READERS or pw != READERS[name]:
458
+ return ([gr.update(), gr.update(visible=True), gr.update(visible=False),
459
+ "", 0, [], gr.update(value="Invalid name or password.")]
460
+ + [gr.update() for _ in CASE_OUTPUTS])
461
+ records = load_existing_responses(name)
462
+ idx = first_unfinished(records)
463
+ ups, order_ids = render_case(name, idx, records)
464
+ token = make_token(name)
465
+ return ([gr.update(value=token), # tok_out -> localStorage
466
+ gr.update(visible=False), # login_col hide
467
+ gr.update(visible=True), # study_col show
468
+ name, idx, order_ids,
469
+ gr.update(value="")] # login_msg clear
470
+ + ups)
471
+
472
+ def do_auto_login(name, tok):
473
+ name = (name or "").strip()
474
+ if not valid_token(name, tok) or name not in READERS:
475
+ return ([gr.update(), gr.update(visible=True), gr.update(visible=False),
476
+ "", 0, [], gr.update()]
477
+ + [gr.update() for _ in CASE_OUTPUTS])
478
+ records = load_existing_responses(name)
479
+ idx = first_unfinished(records)
480
+ ups, order_ids = render_case(name, idx, records)
481
+ return ([gr.update(value=tok), gr.update(visible=False), gr.update(visible=True),
482
+ name, idx, order_ids, gr.update(value="")]
483
+ + ups)
484
+
485
+ LOGIN_OUTPUTS = [tok_out, login_col, study_col, annotator_state, index_state,
486
+ order_state, login_msg] + CASE_OUTPUTS
487
+
488
+ login_btn.click(do_login, [name_in, pass_in], LOGIN_OUTPUTS).then(
489
+ None, [name_in, tok_out], None,
490
+ js="(u,t)=>{ if(t){ localStorage.setItem('grace_reader_user',u); localStorage.setItem('grace_reader_token',t);} }")
491
+
492
+ # ------------------------------------------------------------- save & next
493
+ def do_save(annotator, idx, order_ids, case_ok, *rating_vals):
494
+ # rating_vals = corr[0..M], appr[0..M], grnd[0..M], note[0..M]
495
+ M = MAX_ITEMS
496
+ corr = rating_vals[0:M]
497
+ appr = rating_vals[M:2 * M]
498
+ grnd = rating_vals[2 * M:3 * M]
499
+ note = rating_vals[3 * M:4 * M]
500
+
501
+ if idx >= N_CASES:
502
+ return [idx, order_ids, gr.update(value="Nothing to save.")] + [gr.update() for _ in CASE_OUTPUTS]
503
+
504
+ n_items = len(order_ids)
505
+ # validation: all visible items need the three required dims; case-level needed
506
+ missing = []
507
+ for i in range(n_items):
508
+ if not corr[i]:
509
+ missing.append(f"item {i+1}: correctness")
510
+ if not appr[i]:
511
+ missing.append(f"item {i+1}: appropriateness")
512
+ if not grnd[i]:
513
+ missing.append(f"item {i+1}: grounding")
514
+ if not case_ok:
515
+ missing.append("reference-acceptable question")
516
+ if missing:
517
+ msg = "Please complete before saving: " + "; ".join(missing[:6]) + ("..." if len(missing) > 6 else "")
518
+ return [idx, order_ids, gr.update(value=msg)] + [gr.update() for _ in CASE_OUTPUTS]
519
+
520
+ case = CASES[idx]
521
+ ts = int(time.time())
522
+ new_records = []
523
+ # per-item records: keyed by (annotator, case_id, item_id), value-per-dimension
524
+ for i in range(n_items):
525
+ new_records.append({
526
+ "schema_version": SCHEMA_VERSION, "annotator": annotator,
527
+ "case_id": case["case_id"], "item_id": order_ids[i],
528
+ "shown_position": i,
529
+ "dims": {"answer_correctness": corr[i], "decision_appropriateness": appr[i],
530
+ "grounding_relevance": grnd[i], "note": note[i] or ""},
531
+ "ts": ts,
532
+ })
533
+ # case-level record
534
+ new_records.append({
535
+ "schema_version": SCHEMA_VERSION, "annotator": annotator,
536
+ "case_id": case["case_id"], "item_id": "__case__",
537
+ "dims": {"reference_acceptable": case_ok},
538
+ "shown_order": order_ids, "ts": ts,
539
+ })
540
+ records = save_records(annotator, new_records)
541
+ nxt = first_unfinished(records)
542
+ ups, new_order = render_case(annotator, nxt, records)
543
+ return [nxt, new_order, gr.update(value="Saved.")] + ups
544
+
545
+ SAVE_INPUTS = ([annotator_state, index_state, order_state, case_acceptable]
546
+ + corr_rs + appr_rs + grnd_rs + note_tbs)
547
+ SAVE_OUTPUTS = [index_state, order_state, status_md] + CASE_OUTPUTS
548
+ save_btn.click(do_save, SAVE_INPUTS, SAVE_OUTPUTS)
549
+
550
+ # ------------------------------------------------------------- logout
551
+ def do_logout():
552
+ return (gr.update(visible=True), gr.update(visible=False), "", 0, [])
553
+ logout_btn.click(do_logout, None,
554
+ [login_col, study_col, annotator_state, index_state, order_state]).then(
555
+ None, None, None,
556
+ js="()=>{ localStorage.removeItem('grace_reader_user'); localStorage.removeItem('grace_reader_token'); location.reload(); }")
557
+
558
+ # ------------------------------------------------------------- boot / resume
559
+ demo.load(None, None, [user_ls, tok_ls],
560
+ js="()=>[localStorage.getItem('grace_reader_user')||'', localStorage.getItem('grace_reader_token')||'']").then(
561
+ do_auto_login, [user_ls, tok_ls], LOGIN_OUTPUTS)
562
+
563
+ return demo
564
+
565
+
566
+ if __name__ == "__main__":
567
+ _ensure_response_dataset()
568
+ build_app().queue().launch()
build_cases_example.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generate a synthetic demo case set (data/cases.json + placeholder images) so the app
3
+ runs immediately with ZERO real data. Replace with real P1/P3 outputs for the study:
4
+ put real images + a real cases.json in a PRIVATE HF dataset and set CASES_DATASET.
5
+
6
+ Case schema (data-driven; the app reads exactly this):
7
+ {
8
+ "study": "...",
9
+ "cases": [
10
+ {
11
+ "case_id": "c001",
12
+ "question": "Is there pneumonia (lung opacity)? If present, where?",
13
+ "intro": "optional per-case provenance note (else app default is used)",
14
+ "reference_image": "img/c001_ref.png", # the original CXR (reference column)
15
+ "groundtruth_image": "img/c001_gt.png", # reference-region / bbox panel (final row)
16
+ "items": [ # anonymized systems to score, no names
17
+ {"item_id": "c001_sysA", "image": "img/c001_a.png",
18
+ "answer": "Right lower lobe opacity, consistent with pneumonia.", "decision": "answer"},
19
+ {"item_id": "c001_sysB", "image": "img/c001_b.png",
20
+ "answer": "No acute cardiopulmonary abnormality.", "decision": "defer"}
21
+ ]
22
+ }
23
+ ]
24
+ }
25
+ NOTE: item_id must be a stable TRUE id (maps to the real system in the backend); the reader
26
+ never sees it. The app shuffles item order per (annotator, case) and derives rankings later.
27
+ """
28
+ import json
29
+ import random
30
+ from pathlib import Path
31
+ from PIL import Image, ImageDraw
32
+
33
+ HERE = Path(__file__).parent
34
+ DATA = HERE / "data"
35
+ IMG = DATA / "img"
36
+ IMG.mkdir(parents=True, exist_ok=True)
37
+
38
+ random.seed(7)
39
+ W = H = 512
40
+
41
+
42
+ def base_cxr(seed):
43
+ rnd = random.Random(seed)
44
+ im = Image.new("RGB", (W, H), (18, 18, 18))
45
+ d = ImageDraw.Draw(im, "RGBA")
46
+ # two faint lung fields
47
+ for cx in (170, 342):
48
+ d.ellipse([cx - 90, 120, cx + 90, 400], fill=(60, 60, 60, 255))
49
+ # a mediastinum
50
+ d.rectangle([236, 120, 276, 420], fill=(40, 40, 40, 255))
51
+ # a random faint "opacity"
52
+ ox, oy = rnd.choice([(150, 320), (330, 300), (200, 200)])
53
+ d.ellipse([ox - 40, oy - 30, ox + 40, oy + 30], fill=(150, 150, 150, 120))
54
+ return im, (ox, oy)
55
+
56
+
57
+ def box(im, center, color, label):
58
+ d = ImageDraw.Draw(im, "RGBA")
59
+ cx, cy = center
60
+ d.rectangle([cx - 55, cy - 45, cx + 55, cy + 45], outline=color, width=4)
61
+ d.rectangle([cx - 55, cy - 45, cx + 55, cy + 45], fill=color[:3] + (40,))
62
+ d.text((cx - 50, cy - 62), label, fill=color)
63
+ return im
64
+
65
+
66
+ cases = []
67
+ for k in range(4):
68
+ cid = f"c{k+1:03d}"
69
+ ref, opacity = base_cxr(k)
70
+ ref.save(IMG / f"{cid}_ref.png")
71
+
72
+ gt = ref.copy()
73
+ box(gt, opacity, (60, 200, 90, 255), "reference")
74
+ gt.save(IMG / f"{cid}_gt.png")
75
+
76
+ # system A highlights the true region (relevant); system B highlights a wrong region
77
+ a = ref.copy(); box(a, opacity, (230, 70, 60, 255), "highlight")
78
+ a.save(IMG / f"{cid}_a.png")
79
+ wrong = (256, 380)
80
+ b = ref.copy(); box(b, wrong, (230, 70, 60, 255), "highlight")
81
+ b.save(IMG / f"{cid}_b.png")
82
+
83
+ cases.append({
84
+ "case_id": cid,
85
+ "question": "Is there a focal lung opacity (e.g. pneumonia)? If present, where?",
86
+ "reference_image": f"img/{cid}_ref.png",
87
+ "groundtruth_image": f"img/{cid}_gt.png",
88
+ "items": [
89
+ {"item_id": f"{cid}_sysA", "image": f"img/{cid}_a.png",
90
+ "answer": "Focal opacity present; likely pneumonia.", "decision": "answer"},
91
+ {"item_id": f"{cid}_sysB", "image": f"img/{cid}_b.png",
92
+ "answer": "Uncertain; recommend radiologist review.", "decision": "defer"},
93
+ ],
94
+ })
95
+
96
+ with open(DATA / "cases.json", "w", encoding="utf-8") as f:
97
+ json.dump({"study": "GRACE reader study (demo)", "cases": cases}, f, indent=2)
98
+
99
+ print(f"wrote {DATA/'cases.json'} with {len(cases)} demo cases and placeholder images.")
data/cases.json ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "study": "GRACE reader study (demo)",
3
+ "cases": [
4
+ {
5
+ "case_id": "c001",
6
+ "question": "Is there a focal lung opacity (e.g. pneumonia)? If present, where?",
7
+ "reference_image": "img/c001_ref.png",
8
+ "groundtruth_image": "img/c001_gt.png",
9
+ "items": [
10
+ {
11
+ "item_id": "c001_sysA",
12
+ "image": "img/c001_a.png",
13
+ "answer": "Focal opacity present; likely pneumonia.",
14
+ "decision": "answer"
15
+ },
16
+ {
17
+ "item_id": "c001_sysB",
18
+ "image": "img/c001_b.png",
19
+ "answer": "Uncertain; recommend radiologist review.",
20
+ "decision": "defer"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "case_id": "c002",
26
+ "question": "Is there a focal lung opacity (e.g. pneumonia)? If present, where?",
27
+ "reference_image": "img/c002_ref.png",
28
+ "groundtruth_image": "img/c002_gt.png",
29
+ "items": [
30
+ {
31
+ "item_id": "c002_sysA",
32
+ "image": "img/c002_a.png",
33
+ "answer": "Focal opacity present; likely pneumonia.",
34
+ "decision": "answer"
35
+ },
36
+ {
37
+ "item_id": "c002_sysB",
38
+ "image": "img/c002_b.png",
39
+ "answer": "Uncertain; recommend radiologist review.",
40
+ "decision": "defer"
41
+ }
42
+ ]
43
+ },
44
+ {
45
+ "case_id": "c003",
46
+ "question": "Is there a focal lung opacity (e.g. pneumonia)? If present, where?",
47
+ "reference_image": "img/c003_ref.png",
48
+ "groundtruth_image": "img/c003_gt.png",
49
+ "items": [
50
+ {
51
+ "item_id": "c003_sysA",
52
+ "image": "img/c003_a.png",
53
+ "answer": "Focal opacity present; likely pneumonia.",
54
+ "decision": "answer"
55
+ },
56
+ {
57
+ "item_id": "c003_sysB",
58
+ "image": "img/c003_b.png",
59
+ "answer": "Uncertain; recommend radiologist review.",
60
+ "decision": "defer"
61
+ }
62
+ ]
63
+ },
64
+ {
65
+ "case_id": "c004",
66
+ "question": "Is there a focal lung opacity (e.g. pneumonia)? If present, where?",
67
+ "reference_image": "img/c004_ref.png",
68
+ "groundtruth_image": "img/c004_gt.png",
69
+ "items": [
70
+ {
71
+ "item_id": "c004_sysA",
72
+ "image": "img/c004_a.png",
73
+ "answer": "Focal opacity present; likely pneumonia.",
74
+ "decision": "answer"
75
+ },
76
+ {
77
+ "item_id": "c004_sysB",
78
+ "image": "img/c004_b.png",
79
+ "answer": "Uncertain; recommend radiologist review.",
80
+ "decision": "defer"
81
+ }
82
+ ]
83
+ }
84
+ ]
85
+ }
data/img/c001_a.png ADDED
data/img/c001_b.png ADDED
data/img/c001_gt.png ADDED
data/img/c001_ref.png ADDED
data/img/c002_a.png ADDED
data/img/c002_b.png ADDED
data/img/c002_gt.png ADDED
data/img/c002_ref.png ADDED
data/img/c003_a.png ADDED
data/img/c003_b.png ADDED
data/img/c003_gt.png ADDED
data/img/c003_ref.png ADDED
data/img/c004_a.png ADDED
data/img/c004_b.png ADDED
data/img/c004_gt.png ADDED
data/img/c004_ref.png ADDED
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio==4.44.1
2
+ huggingface_hub>=0.24.0
3
+ pillow>=10.0.0