from __future__ import annotations import csv import hashlib import io import json import random import re import time from pathlib import Path import streamlit as st from github_storage import ( is_configured as github_configured, ) DATA_DIR = Path("data") PAIRS_FILE = DATA_DIR / "pairs.json" RESULTS_DIR = Path("results") AUDIT_LOG = RESULTS_DIR / "audit_log.csv" FIELDNAMES = [ "pair_index", "reviewer_id", "clinically_plausible", "pathology_preserved", "quality_score", "comments", "timestamp", ] USE_GITHUB = github_configured() def _load_reviewer_tokens() -> dict[str, str]: import os tokens: dict[str, str] = {} for key, val in os.environ.items(): if key.startswith("REVIEWER_TOKEN_"): reviewer_id = key.replace("REVIEWER_TOKEN_", "").lower() tokens[val] = reviewer_id try: reviewer_secrets = st.secrets.get("reviewers", {}) for reviewer_id, token in reviewer_secrets.items(): tokens[str(token)] = str(reviewer_id) except (KeyError, FileNotFoundError): pass return tokens def _authenticate(token: str) -> str | None: valid_tokens = _load_reviewer_tokens() if not valid_tokens: if re.match(r"^[a-zA-Z0-9_-]{2,30}$", token): return token return None return valid_tokens.get(token) def _safe_reviewer_id(reviewer_id: str) -> str: return hashlib.sha256(reviewer_id.encode()).hexdigest()[:12] def _safe_filename_id(value: str) -> str: safe = re.sub(r"[^a-zA-Z0-9_-]+", "_", value).strip("_") return safe or _safe_reviewer_id(value) def _reviews_to_csv(reviews: dict[int, dict]) -> str: output = io.StringIO() writer = csv.DictWriter(output, fieldnames=FIELDNAMES, extrasaction="ignore") writer.writeheader() for idx in sorted(reviews.keys()): writer.writerow(reviews[idx]) return output.getvalue() def _strip_dicom_metadata(file_path: Path) -> bytes | None: suffix = file_path.suffix.lower() if suffix in (".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif"): return file_path.read_bytes() if suffix in (".dcm", ".dicom"): try: import pydicom from PIL import Image ds = pydicom.dcmread(str(file_path)) pixel_array = ds.pixel_data if hasattr(ds, "pixel_array"): pixel_array = ds.pixel_array else: return None img = Image.fromarray(pixel_array) if img.mode not in ("L", "RGB"): img = img.convert("L") buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue() except Exception: return None return file_path.read_bytes() if file_path.exists() else None def load_pairs() -> list[dict]: if USE_GITHUB: import github_storage pairs = github_storage.load_pairs() if pairs: return pairs if not PAIRS_FILE.exists(): st.error(f"Pairs file not found: {PAIRS_FILE}") st.stop() with open(PAIRS_FILE) as f: return json.load(f) def get_results_path(reviewer_id: str) -> Path: RESULTS_DIR.mkdir(parents=True, exist_ok=True) safe_id = _safe_reviewer_id(reviewer_id) return RESULTS_DIR / f"reviews_{safe_id}.csv" def load_existing_reviews(reviewer_id: str) -> dict[int, dict]: if USE_GITHUB: import github_storage return github_storage.load_existing_reviews(reviewer_id) path = get_results_path(reviewer_id) reviews: dict[int, dict] = {} if path.exists(): with open(path) as f: for row in csv.DictReader(f): reviews[int(row["pair_index"])] = row return reviews def save_review(reviewer_id: str, pair_index: int, review: dict) -> None: if USE_GITHUB: import github_storage github_storage.save_review(reviewer_id, pair_index, review) return path = get_results_path(reviewer_id) existing = load_existing_reviews(reviewer_id) is_update = pair_index in existing existing[pair_index] = review with open(path, "w", newline="") as f: f.write(_reviews_to_csv(existing)) append_audit_log(reviewer_id, pair_index, "update" if is_update else "submit") def append_audit_log(reviewer_id: str, pair_index: int, action: str) -> None: if USE_GITHUB: import github_storage github_storage.append_audit_log(reviewer_id, pair_index, action) return RESULTS_DIR.mkdir(parents=True, exist_ok=True) write_header = not AUDIT_LOG.exists() with open(AUDIT_LOG, "a", newline="") as f: writer = csv.writer(f) if write_header: writer.writerow(["timestamp", "reviewer_id", "pair_index", "action"]) writer.writerow([ time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), reviewer_id, pair_index, action, ]) def get_shuffled_order(pairs: list[dict], reviewer_id: str) -> list[int]: seed = int(hashlib.sha256(reviewer_id.encode()).hexdigest(), 16) % (2**32) indices = list(range(len(pairs))) random.Random(seed).shuffle(indices) return indices def get_lr_swap(pair_index: int, reviewer_id: str) -> bool: combined = f"{reviewer_id}_{pair_index}" return int(hashlib.md5(combined.encode()).hexdigest(), 16) % 2 == 0 def render_login() -> str | None: st.title("SpineFairBench — Radiologist Validation") st.markdown( "You are reviewing pairs of spine X-rays for clinical realism. " "For each pair you will see two images side by side. " "Please answer the three questions below each pair honestly and to " "the best of your clinical judgment." ) st.markdown("---") token = st.text_input("Enter your access token to begin", type="password") if token.strip(): reviewer_id = _authenticate(token.strip()) if reviewer_id: return reviewer_id st.error("Invalid access token.") return None def render_image(path: Path, label: str) -> None: st.subheader(label) if not path.exists(): st.warning(f"Image not found: {path.name}") return image_bytes = _strip_dicom_metadata(path) if image_bytes: st.image(image_bytes, use_container_width=True) else: st.warning(f"Could not load image: {path.name}") def main() -> None: st.set_page_config(page_title="SpineFairBench Validation", layout="wide") if "reviewer_id" not in st.session_state: st.session_state.reviewer_id = None if "position" not in st.session_state: st.session_state.position = 0 if st.session_state.reviewer_id is None: result = render_login() if result: st.session_state.reviewer_id = result append_audit_log(result, -1, "login") st.rerun() return reviewer_id: str = st.session_state.reviewer_id pairs = load_pairs() order = get_shuffled_order(pairs, reviewer_id) reviews = load_existing_reviews(reviewer_id) total = len(pairs) reviewed = len(reviews) pos = st.session_state.position st.sidebar.markdown(f"**Reviewer:** {reviewer_id}") st.sidebar.metric("Progress", f"{reviewed} / {total}") st.sidebar.progress(reviewed / total if total > 0 else 0) st.sidebar.download_button( "Export Results CSV", data=_reviews_to_csv(reviews), file_name=f"validation_{_safe_filename_id(reviewer_id)}.csv", mime="text/csv", disabled=reviewed == 0, ) if st.sidebar.button("Log out"): append_audit_log(reviewer_id, -1, "logout") st.session_state.reviewer_id = None st.session_state.position = 0 st.rerun() return if pos >= total: st.balloons() st.success(f"All {total} pairs reviewed. Thank you!") return pair_index = order[pos] pair = pairs[pair_index] col_prev, col_counter, col_next = st.columns([1, 2, 1]) with col_prev: if st.button("← Previous", disabled=pos == 0): st.session_state.position = max(0, pos - 1) st.rerun() with col_counter: status = " ✓" if pair_index in reviews else "" st.markdown(f"**Pair {pos + 1} of {total}{status}**") with col_next: if st.button("Skip →", disabled=pos >= total - 1): st.session_state.position = min(total - 1, pos + 1) st.rerun() source_path = DATA_DIR / pair.get("source_path", "") generated_path = DATA_DIR / pair.get("generated_path", "") swap = get_lr_swap(pair_index, reviewer_id) if swap: left_path, right_path = generated_path, source_path else: left_path, right_path = source_path, generated_path col_a, col_b = st.columns(2) with col_a: render_image(left_path, "Image A") with col_b: render_image(right_path, "Image B") st.markdown("---") existing = reviews.get(pair_index, {}) plausible = st.radio( "1. Do both images appear clinically plausible as real spine X-rays?", ["Yes", "No"], index=0 if existing.get("clinically_plausible", "Yes") == "Yes" else 1, horizontal=True, key=f"plausible_{pair_index}", ) preserved = st.radio( "2. Is the pathology consistent across both images?", ["Yes", "No", "Uncertain"], index=["Yes", "No", "Uncertain"].index( existing.get("pathology_preserved", "Yes") ), horizontal=True, key=f"preserved_{pair_index}", ) quality = st.radio( "3. Overall pair quality", ["1", "2", "3", "4", "5"], index=int(existing.get("quality_score", "3")) - 1, horizontal=True, key=f"quality_{pair_index}", captions=["Poor", "Below average", "Acceptable", "Good", "Excellent"], ) comments = st.text_area( "Comments (optional)", value=existing.get("comments", ""), key=f"comments_{pair_index}", height=80, ) if st.button("Submit & Next", type="primary", use_container_width=True): review = { "pair_index": pair_index, "reviewer_id": reviewer_id, "clinically_plausible": plausible, "pathology_preserved": preserved, "quality_score": quality, "comments": comments, "timestamp": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), } try: save_review(reviewer_id, pair_index, review) except Exception as e: st.error(f"Failed to save review: {e}. Please try again.") return if pos < total - 1: st.session_state.position = pos + 1 else: st.session_state.position = total st.rerun() if __name__ == "__main__": main()