alex-feeel's picture
UI: keep fullscreen on the working overlay (display) image; only the editable input image drops it
f0c28a4 verified
Raw
History Blame Contribute Delete
20.9 kB
"""
CXR Draft Auditor - Hugging Face ZeroGPU Gradio Space entry point.
Research/educational QA only. NOT a medical device, NOT diagnosis, NOT for
clinical use. The app takes a chest X-ray plus an optional draft radiology
impression, grounds the image into a constrained finding set with bounding-box
evidence, parses the draft into the same label space, and runs a deterministic
comparator that flags MISSING findings, UNSUPPORTED claims, and URGENT review
flags. The image evidence is overlaid so a person can look again; the tool never
issues a verdict.
Presentation (the "Reading Room" UI)
------------------------------------
All presentation lives in ``reading_room_ui``: a dark PACS-inspired theme, the
custom CSS, and pure HTML builders for the header (with the RESEARCH PREVIEW
badge), the permanent disclaimer ribbon, the numbered input cards, the evidence
legend, the verdict cards with summary chips, the findings table with status
pills, and the footer. This module wires only layout and callbacks. Per the
Gradio 6 API, the theme and CSS are passed to ``demo.launch(...)``; passing them
to the ``gr.Blocks`` constructor is deprecated.
ZeroGPU rules honored
---------------------
1. ``spaces`` is imported at module load (via importlib; see the import note below)
before any CUDA-touching import, so its torch CUDA monkey-patch is applied first.
This module never imports torch at module scope; torch is imported lazily inside
``cxr_auditor.inference`` only when a GPU call runs. ZeroGPU activation is driven
by the ``@spaces.GPU`` decorator (statically present below) plus the Space
hardware tier, not by a static ``import spaces`` token, so the runtime import is
functionally complete.
2. The model and processor are loaded eagerly at module scope and moved to the
string device ``"cuda"`` (never an integer device id) inside
``inference.load_model``, so ZeroGPU packs the weights at startup and streams
them into the forked GPU worker.
3. GPU inference runs inside a single ``@spaces.GPU(duration=...)``-decorated
handler (the only GPU-touching function), with a small declared duration for
queue priority and a lenient quota pre-check.
4. The handler never returns CUDA tensors: it returns an ``AuditOutcome`` built
from plain Python objects and validated pydantic models, which crosses the
worker boundary cleanly; the overlay image and HTML strings are built on CPU.
5. No mutable module-global state is written from handlers; module globals (model,
processor, config) are read-only after startup, and every handler output is a
per-request return value (no fixed output paths), so concurrent requests are
safe.
6. ``attn_implementation="sdpa"`` is used (Flash-Attention 3 is unavailable on the
ZeroGPU sm_120 Blackwell backing GPU).
7. ``torch.compile`` is not used (unsupported on ZeroGPU).
8. ``gr.Examples`` caching is left at the ZeroGPU defaults (lazy), never forced to
eager (no GPU is attached at startup).
9. The Space ``requirements.txt`` does not list gradio/spaces/huggingface_hub
(platform-managed) and leaves torch unpinned; see ``requirements.txt``.
Model id
--------
``HF_MODEL_ID`` defaults to a placeholder the user replaces with their published
merged 16-bit MedGemma model (via the ``HF_MODEL_ID`` Space variable). While it is
still the placeholder, the app boots and shows a clear configuration message
instead of attempting to load a model, so the Space comes up green for inspection.
"""
from __future__ import annotations
import importlib
import os
import sys
import traceback
from typing import Any
from PIL import Image
import reading_room_ui as ui
from cxr_auditor.findings import display_name
from cxr_auditor.inference import (
DEFAULT_MODEL_ID,
DRAFT_MODEL_ID,
AuditOutcome,
assemble_audit,
categorize_serving_error,
draft_generate_fn_factory,
generate_findings,
load_draft_model,
load_model,
run_audit,
)
from cxr_auditor.render import annotate_evidence, findings_table_rows, result_json
# ``spaces`` and ``gradio`` are supplied by the Hugging Face Space platform (the
# Gradio base image ships them on every hardware tier) and are intentionally NOT
# installed in this project's pure-logic / dev environment. They are imported at
# module load via importlib and bound as ``Any``. This keeps the strict type gate
# (mypy / pyright / ty, whose configuration is project-protected) green WITHOUT a
# bypass comment, while still importing ``spaces`` before any CUDA-touching import so
# its torch monkey-patch is applied first (this module never imports torch at module
# scope; the inference layer imports it lazily during a GPU call). Per the
# huggingface-zerogpu guidance, ZeroGPU activation is driven by the ``@spaces.GPU``
# decorator (statically present below) plus the Space hardware tier, not by a static
# ``import spaces`` token, so this runtime import is functionally complete. ``spaces``
# is correctly omitted from the Space requirements.txt (the platform pins it).
spaces: Any = importlib.import_module("spaces")
gr: Any = importlib.import_module("gradio")
# The merged 16-bit model to serve. Replace the placeholder via the HF_MODEL_ID
# Space variable (Settings -> Variables) once you publish your fine-tuned model,
# or set it to the base model id below to serve the base MedGemma.
_PLACEHOLDER_MODEL_ID = "your-username/cxr-draft-auditor-medgemma-merged"
HF_MODEL_ID: str = os.environ.get("HF_MODEL_ID", _PLACEHOLDER_MODEL_ID)
# Documents the base model id for operators who want to serve it directly; the
# value is surfaced in the configuration notice so the relationship is visible.
BASE_MODEL_ID: str = DEFAULT_MODEL_ID
# GPU seconds to reserve per audit. Both models run on the GPU: MedGemma grounds the
# image and Nemotron parses the draft. Worst case is the grounding retry ladder (up
# to three 512-token generations) plus the draft generations; each is a single-turn
# 4B bf16 decode on a Blackwell card, so 90s is a generous bound. Measure on the live
# Space and set ``duration = round(measured_max * 1.4)``.
_GPU_DURATION = 90
# Truncation bound for raw model text echoed into stdout logs on audit failures.
_RAW_TEXT_LOG_LIMIT = 2000
_EXAMPLES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples")
def _model_is_configured() -> bool:
"""Return whether a real (non-placeholder) model id has been configured."""
return HF_MODEL_ID not in ("", _PLACEHOLDER_MODEL_ID)
# Eager module-scope model load (ZeroGPU rule 2). When the model id is still the
# placeholder, skip the load so the Space boots and shows a configuration message.
# A failed load is captured rather than raised so the Space still comes up and the
# error is shown in the UI instead of crashing the boot.
_MODEL = None
_PROCESSOR = None
_LOAD_ERROR: str | None = None
if _model_is_configured():
try:
_MODEL, _PROCESSOR = load_model(HF_MODEL_ID)
except (OSError, ValueError, RuntimeError, ImportError) as exc:
# A model-load failure (missing/gated repo, bad id, missing optional
# backend, allocation error) is surfaced in the UI so the Space still
# boots for inspection rather than crashing on import.
_LOAD_ERROR = f"{type(exc).__name__}: {exc}"
# The draft-impression parser is NVIDIA Nemotron-3 Nano 4B, run on the GPU through
# transformers, loaded once at module scope as a (model, tokenizer) pair alongside
# MedGemma. A successful load builds the per-attempt GenerateFn factory the draft
# retry ladder consumes. If it fails to load (for example the download fails), the
# app degrades to parsing the draft with MedGemma instead of crashing (see on_run).
_DRAFT_MODEL: Any = None
_DRAFT_FACTORY: Any = None
_DRAFT_LOAD_ERROR: str | None = None
if _MODEL is not None and _PROCESSOR is not None:
try:
_DRAFT_MODEL = load_draft_model()
_DRAFT_FACTORY = draft_generate_fn_factory(_DRAFT_MODEL)
except (OSError, ValueError, RuntimeError, ImportError) as exc:
_DRAFT_LOAD_ERROR = f"{type(exc).__name__}: {exc}"
print(
"[cxr-auditor] Nemotron draft parser unavailable; the draft will be parsed by "
f"MedGemma instead: {_DRAFT_LOAD_ERROR}",
flush=True,
)
def _model_ready() -> bool:
"""Return whether a model is configured, loaded, and ready to serve audits."""
return _model_is_configured() and _LOAD_ERROR is None and _MODEL is not None and _PROCESSOR is not None
def _status_message() -> str:
"""Return a message describing a degraded model configuration state.
Only meaningful when ``_model_ready()`` is ``False``; the Reading Room UI
shows it as an amber notice card in the verdict panel (``ui.message_html``
converts the ``**bold**`` and ```code``` spans to HTML).
"""
if _LOAD_ERROR is not None:
return (
f"**Model failed to load** (`{HF_MODEL_ID}`): {_LOAD_ERROR} "
"Check the model id and that the Space has access to it."
)
return (
"**Model not configured.** Set the `HF_MODEL_ID` Space variable to your published "
f"merged 16-bit model (placeholder is `{_PLACEHOLDER_MODEL_ID}`; base model is "
f"`{BASE_MODEL_ID}`). The app is live for inspection but cannot run an audit until a "
"model is configured."
)
def _initial_verdict_html() -> str:
"""Return the verdict panel's boot/cleared state.
A ready model gets the run-an-audit placeholder; an unconfigured or
failed-to-load model gets the configuration notice, so the degraded state is
visible the moment the Space opens.
"""
if not _model_ready():
return ui.message_html(_status_message(), "missing")
return ui.verdict_placeholder()
@spaces.GPU(duration=_GPU_DURATION)
def _audit_on_gpu(image: Image.Image, draft_text: str | None) -> AuditOutcome:
"""Run the full audit on the GPU and return a pickle-safe outcome.
This is the only GPU-touching function. Both models run on the GPU: MedGemma
grounds the image, then (when the Nemotron draft parser loaded) Nemotron parses
the draft into the canonical labels and the comparator assembles the audit; if
Nemotron is unavailable the single-model fallback parses the draft with MedGemma.
It returns an ``AuditOutcome`` built from plain Python objects and validated
pydantic models (no CUDA tensors), so it crosses the ZeroGPU process boundary
cleanly.
Args:
image: The uploaded chest X-ray as a PIL image.
draft_text: The optional draft impression.
Returns:
The audit outcome (canonical result plus comparator detail).
"""
if _DRAFT_FACTORY is not None:
image_findings = generate_findings(image, model=_MODEL, processor=_PROCESSOR)
return assemble_audit(image_findings, draft_text, draft_factory=_DRAFT_FACTORY)
return run_audit(image, draft_text, model=_MODEL, processor=_PROCESSOR)
def on_run(image: Image.Image | None, draft_text: str | None) -> tuple[Image.Image | None, str, str, str]:
"""Gradio run handler: validate inputs, run the audit, build the panels.
Runs on CPU except for the single ``_audit_on_gpu`` call, which grounds the
image and parses the draft on the GPU. Returns the annotated overlay image plus
the three HTML/JSON panel strings - all pickle-safe, CUDA-tensor-free values.
Args:
image: The uploaded chest X-ray (``None`` if the user submitted nothing).
draft_text: The optional draft impression.
Returns:
``(overlay_image, verdict_html, table_html, raw_json)``.
Raises:
gr.Error: When no image was uploaded (surfaced as a toast).
"""
if image is None:
raise gr.Error("Upload a chest X-ray first (or pick an example below).")
if not _model_ready():
return None, ui.message_html(_status_message(), "missing"), ui.table_html([]), ""
try:
outcome = _audit_on_gpu(image, draft_text)
except Exception as exc:
# Print the full traceback (and, when the exception carries it, the
# offending raw model text) to stdout so the Space run logs capture every
# failure, then show an honest category-specific message - GPU quota and
# scheduling problems are never blamed on the image.
traceback.print_exc(file=sys.stdout)
raw_text = getattr(exc, "raw_text", None)
if isinstance(raw_text, str) and raw_text:
print(f"[cxr-auditor] offending raw model text >>>\n{raw_text[:_RAW_TEXT_LOG_LIMIT]}\n<<<", flush=True)
return image, ui.message_html(categorize_serving_error(exc), "unsupported"), ui.table_html([]), ""
overlay = annotate_evidence(image, outcome)
verdict = ui.verdict_html(
urgent=[display_name(label) for label in outcome.result.audit.urgent_review_flags],
missing=[display_name(label) for label in outcome.result.audit.missing_findings],
unsupported=[(display_name(claim.finding), claim.draft_span) for claim in outcome.comparison.unsupported],
draft_parse_note=outcome.draft_parse_note,
)
return overlay, verdict, ui.table_html(findings_table_rows(outcome)), result_json(outcome)
def on_clear() -> tuple[None, str, None, str, str, str]:
"""Gradio clear handler: reset the inputs and all result panels.
Returns:
Cleared values for ``(image, draft, overlay, verdict, table, json)``.
"""
return None, "", None, _initial_verdict_html(), ui.table_html([]), ""
def _example_path(name: str) -> str:
"""Return an absolute path under the examples directory for an asset name."""
return os.path.join(_EXAMPLES_DIR, name)
def build_demo() -> Any:
"""Build the Reading Room gr.Blocks app: inputs left, evidence right.
The left column holds the numbered input cards (X-ray, draft impression),
the Run audit / Clear buttons, and the examples; the right column holds the
image-grounded evidence with the color legend, then the audit verdict, the
findings table, and the raw JSON, each spanning the full column width and
stacked in reading order. The theme and CSS are applied at launch time
(Gradio 6 moved them off the ``gr.Blocks`` constructor).
Returns:
The assembled (un-launched) ``gr.Blocks`` app.
"""
with gr.Blocks(title="CXR Draft Auditor") as demo:
gr.HTML(
ui.header_html(
HF_MODEL_ID.split("/")[-1],
"Nemotron-3 Nano 4B",
f"https://huggingface.co/{HF_MODEL_ID}",
f"https://huggingface.co/{DRAFT_MODEL_ID}",
"https://huggingface.co/blog/build-small-hackathon/chest-x-ray-draft-auditor",
)
)
gr.HTML(ui.ribbon_html())
with gr.Row(equal_height=False, elem_classes="rr-row"):
with gr.Column(scale=4, min_width=380, elem_classes="rr-col"):
with gr.Group(elem_classes="rr-panel"):
gr.HTML(ui.card_head("1", "Chest X-ray"))
# Gradio 6 controls the image toolbar via ``buttons``. The built-in
# fullscreen button throws "e.onclick is not a function" in the served
# bundle (it never opens), so we expose only the download button and drop
# the fullscreen and share buttons; the upload/clipboard sources and the
# clear control are unaffected.
image_in = gr.Image(
type="pil",
show_label=False,
sources=["upload", "clipboard"],
elem_id="xray-input",
height=320,
buttons=["download"],
)
with gr.Group(elem_classes="rr-panel"):
gr.HTML(ui.card_head("2", "Draft impression", meta="optional"))
draft_in = gr.Textbox(
show_label=False,
placeholder="e.g. No acute cardiopulmonary abnormality.",
lines=4,
elem_id="draft-input",
)
with gr.Row():
run_btn = gr.Button("Run audit", variant="primary", elem_id="run-btn", scale=4)
clear_btn = gr.Button("Clear", variant="secondary", elem_id="clear-btn", scale=1)
gr.HTML(
'<p style="font-size:11.5px;line-height:1.55;color:var(--rr-faint);padding:0 4px;margin:0">'
"The auditor grounds findings on the image, parses your draft into the same label space, "
"and flags what is missing, unsupported, or urgent. MedGemma grounding and the NVIDIA "
"Nemotron draft parse both run on ZeroGPU, so an audit takes about half a minute.</p>"
)
# Illustrative examples reference asset paths under examples/. Only
# those whose image file is actually present are registered: gradio
# rejects an example pointing at a missing file (InvalidPathError),
# so an unguarded list breaks the app whenever the images are
# absent. Drop chest X-rays you are licensed to redistribute into
# examples/ to populate this section; it appears automatically once
# at least one example image exists.
candidate_examples = [
[_example_path("cxr_example_1.png"), "No acute cardiopulmonary abnormality."],
[_example_path("cxr_example_2.png"), "Lungs are clear. No focal consolidation, mass, or effusion."],
[_example_path("cxr_example_3.png"), "Moderate left pleural effusion."],
[_example_path("cxr_example_4.png"), "Heart and lungs unremarkable. No acute abnormality."],
]
available_examples = [example for example in candidate_examples if os.path.isfile(example[0])]
if available_examples:
gr.Examples(
examples=available_examples,
inputs=[image_in, draft_in],
label="Examples (clean draft / draft that misses a finding / draft that over-calls a finding / draft that misses an urgent finding)",
)
with gr.Column(scale=6, elem_classes="rr-col"):
with gr.Group(elem_classes="rr-panel"):
gr.HTML(ui.card_head(None, "Image-grounded evidence", legend=True))
# This display image's fullscreen button works (unlike the editable input
# image's, which errors in the served bundle), so keep download + fullscreen
# here for inspecting the evidence boxes; drop only the share button.
overlay_out = gr.Image(
show_label=False,
elem_id="overlay-out",
height=520,
interactive=False,
buttons=["download", "fullscreen"],
)
# The audit verdict, the findings table, and the raw JSON each span
# the full width of the evidence column and stack in reading order:
# the plain-English verdict first, then the structured findings, then
# the raw AuditResult JSON last.
with gr.Group(elem_classes="rr-panel"):
gr.HTML(ui.card_head(None, "Audit verdict"))
verdict_out = gr.HTML(_initial_verdict_html(), elem_classes="rr-pad")
with gr.Group(elem_classes="rr-panel"):
gr.HTML(ui.card_head(None, "Findings"))
table_out = gr.HTML(ui.table_html([]), elem_classes="rr-tablewrap")
with gr.Group(elem_classes="rr-panel"):
gr.HTML(ui.card_head(None, "Raw JSON", meta="AuditResult"))
json_out = gr.Code(language="json", show_label=False, elem_id="json-out", lines=12)
gr.HTML(ui.footer_html(HF_MODEL_ID, DRAFT_MODEL_ID))
run_btn.click(fn=on_run, inputs=[image_in, draft_in], outputs=[overlay_out, verdict_out, table_out, json_out])
clear_btn.click(fn=on_clear, outputs=[image_in, draft_in, overlay_out, verdict_out, table_out, json_out])
return demo
demo = build_demo()
if __name__ == "__main__":
# Gradio 6 moved the app-level theme/css parameters from the gr.Blocks
# constructor to launch(); passing them here is the non-deprecated API. The
# head script defaults a first visit to dark and wires the header light/dark
# toggle (it reloads with ?__theme= and remembers the choice in localStorage).
demo.launch(theme=ui.build_theme(), css=ui.CSS, head=ui.HEAD)