Stack columns on mobile (<=700px) and add (N foci) hint for multi-box findings
Browse files- app.py +3 -3
- cxr_auditor/comparator.py +14 -1
- cxr_auditor/render.py +37 -2
- reading_room_ui.py +22 -0
app.py
CHANGED
|
@@ -312,8 +312,8 @@ def build_demo() -> Any:
|
|
| 312 |
)
|
| 313 |
gr.HTML(ui.ribbon_html())
|
| 314 |
|
| 315 |
-
with gr.Row(equal_height=False):
|
| 316 |
-
with gr.Column(scale=4, min_width=380):
|
| 317 |
with gr.Group(elem_classes="rr-panel"):
|
| 318 |
gr.HTML(ui.card_head("1", "Chest X-ray"))
|
| 319 |
image_in = gr.Image(
|
|
@@ -361,7 +361,7 @@ def build_demo() -> Any:
|
|
| 361 |
label="Examples (clean draft / draft that misses a finding / draft that over-calls a finding / draft that misses an urgent finding)",
|
| 362 |
)
|
| 363 |
|
| 364 |
-
with gr.Column(scale=6):
|
| 365 |
with gr.Group(elem_classes="rr-panel"):
|
| 366 |
gr.HTML(ui.card_head(None, "Image-grounded evidence", legend=True))
|
| 367 |
overlay_out = gr.Image(show_label=False, elem_id="overlay-out", height=520, interactive=False)
|
|
|
|
| 312 |
)
|
| 313 |
gr.HTML(ui.ribbon_html())
|
| 314 |
|
| 315 |
+
with gr.Row(equal_height=False, elem_classes="rr-row"):
|
| 316 |
+
with gr.Column(scale=4, min_width=380, elem_classes="rr-col"):
|
| 317 |
with gr.Group(elem_classes="rr-panel"):
|
| 318 |
gr.HTML(ui.card_head("1", "Chest X-ray"))
|
| 319 |
image_in = gr.Image(
|
|
|
|
| 361 |
label="Examples (clean draft / draft that misses a finding / draft that over-calls a finding / draft that misses an urgent finding)",
|
| 362 |
)
|
| 363 |
|
| 364 |
+
with gr.Column(scale=6, elem_classes="rr-col"):
|
| 365 |
with gr.Group(elem_classes="rr-panel"):
|
| 366 |
gr.HTML(ui.card_head(None, "Image-grounded evidence", legend=True))
|
| 367 |
overlay_out = gr.Image(show_label=False, elem_id="overlay-out", height=520, interactive=False)
|
cxr_auditor/comparator.py
CHANGED
|
@@ -27,6 +27,15 @@ label is a canonical positive.
|
|
| 27 |
- URGENT: any image-present positive finding whose label is on the urgent-review
|
| 28 |
whitelist (``findings.URGENT_WHITELIST`` via ``findings.is_urgent``).
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
The canonical ordering of ``CANONICAL_FINDINGS`` is used for every output list so
|
| 31 |
the result is deterministic regardless of input ordering and free of duplicates.
|
| 32 |
"""
|
|
@@ -108,7 +117,11 @@ def _present_image_findings(image_findings: Iterable[ImageFinding]) -> dict[str,
|
|
| 108 |
sentinel are excluded: only positive findings the image asserts as present
|
| 109 |
participate in the audit. When the same positive label appears more than once
|
| 110 |
with status ``PRESENT``, the first occurrence (in input order) wins, so its
|
| 111 |
-
box and evidence are the ones surfaced.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
"""
|
| 113 |
present: dict[str, ImageFinding] = {}
|
| 114 |
for finding in image_findings:
|
|
|
|
| 27 |
- URGENT: any image-present positive finding whose label is on the urgent-review
|
| 28 |
whitelist (``findings.URGENT_WHITELIST`` via ``findings.is_urgent``).
|
| 29 |
|
| 30 |
+
Matching is label-level (set-based), not per-lesion: every rule above keys on the
|
| 31 |
+
canonical label, so a label is judged present-or-absent once, no matter how many
|
| 32 |
+
boxes the image model localized for it. A finding the model grounds at two
|
| 33 |
+
spatially-distinct sites yields at most one MISSING, one UNSUPPORTED, and one
|
| 34 |
+
URGENT entry for that label; a second, separate lesion carrying the same label
|
| 35 |
+
that the draft happens to omit is not flagged on its own. The overlay still draws
|
| 36 |
+
every distinct box (see ``render``), so the overlay can legitimately show more
|
| 37 |
+
boxes than the audit lists or the findings table have rows for that label.
|
| 38 |
+
|
| 39 |
The canonical ordering of ``CANONICAL_FINDINGS`` is used for every output list so
|
| 40 |
the result is deterministic regardless of input ordering and free of duplicates.
|
| 41 |
"""
|
|
|
|
| 117 |
sentinel are excluded: only positive findings the image asserts as present
|
| 118 |
participate in the audit. When the same positive label appears more than once
|
| 119 |
with status ``PRESENT``, the first occurrence (in input order) wins, so its
|
| 120 |
+
box and evidence are the ones surfaced. Indexing by label makes the audit
|
| 121 |
+
label-level: a second present box of the same label (a spatially-distinct
|
| 122 |
+
lesion) collapses into this one entry and is not compared against the draft on
|
| 123 |
+
its own, so a draft that omits only that second site raises no separate flag.
|
| 124 |
+
The overlay still draws every distinct box (see ``render``).
|
| 125 |
"""
|
| 126 |
present: dict[str, ImageFinding] = {}
|
| 127 |
for finding in image_findings:
|
cxr_auditor/render.py
CHANGED
|
@@ -32,6 +32,14 @@ the others. ``cluster_overlay_boxes`` merges spatially-overlapping boxes into on
|
|
| 32 |
is drawn exactly once with the correct, order-independent color and a single
|
| 33 |
combined label.
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
Label collision avoidance
|
| 36 |
-------------------------
|
| 37 |
Distinct regions can still carry long labels at nearly the same height (for
|
|
@@ -541,6 +549,25 @@ _FLAG_UNSUPPORTED = "Unsupported claim"
|
|
| 541 |
_FLAG_NONE = ""
|
| 542 |
|
| 543 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
def findings_table_rows(outcome: AuditOutcome) -> list[list[str]]:
|
| 545 |
"""Build the structured findings table rows.
|
| 546 |
|
|
@@ -549,7 +576,10 @@ def findings_table_rows(outcome: AuditOutcome) -> list[list[str]]:
|
|
| 549 |
``status`` is ``"Present"`` or ``"Absent"``, and ``audit_flag`` is a plain
|
| 550 |
English phrase. Image findings are deduplicated by canonical label (the model
|
| 551 |
can localize one label to several boxes; the table shows it once), so a
|
| 552 |
-
non-expert never sees the same finding listed repeatedly
|
|
|
|
|
|
|
|
|
|
| 553 |
sentinel is never listed beside positive findings - it is mutually exclusive
|
| 554 |
with them - so a negative draft ("lungs are clear") adds no contradictory row;
|
| 555 |
a genuinely clear study instead shows a single ``no_finding`` row.
|
|
@@ -567,6 +597,7 @@ def findings_table_rows(outcome: AuditOutcome) -> list[list[str]]:
|
|
| 567 |
|
| 568 |
missing = set(outcome.result.audit.missing_findings)
|
| 569 |
urgent = set(outcome.result.audit.urgent_review_flags)
|
|
|
|
| 570 |
seen_image_labels: set[str] = set()
|
| 571 |
for finding in outcome.result.image_findings:
|
| 572 |
if finding.status is not FindingStatus.PRESENT or not _is_positive(finding.finding):
|
|
@@ -580,7 +611,11 @@ def findings_table_rows(outcome: AuditOutcome) -> list[list[str]]:
|
|
| 580 |
flag = _FLAG_MISSING
|
| 581 |
else:
|
| 582 |
flag = _FLAG_SUPPORTED
|
| 583 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
|
| 585 |
unsupported = set(outcome.result.audit.unsupported_claims)
|
| 586 |
for draft_finding in outcome.result.draft_findings:
|
|
|
|
| 32 |
is drawn exactly once with the correct, order-independent color and a single
|
| 33 |
combined label.
|
| 34 |
|
| 35 |
+
The findings table and the audit panel are deduplicated by canonical label (the
|
| 36 |
+
audit reasons about each label once; see ``comparator``), while the overlay keeps
|
| 37 |
+
one box per distinct region. A label the model localizes at several separate
|
| 38 |
+
sites therefore appears once in the table and panel but draws several boxes, so
|
| 39 |
+
the overlay can legitimately show more boxes than the table or panel have rows.
|
| 40 |
+
``findings_table_rows`` appends an "(N foci)" hint to such a label so a reader can
|
| 41 |
+
reconcile the single table row with the several boxes on the image.
|
| 42 |
+
|
| 43 |
Label collision avoidance
|
| 44 |
-------------------------
|
| 45 |
Distinct regions can still carry long labels at nearly the same height (for
|
|
|
|
| 549 |
_FLAG_NONE = ""
|
| 550 |
|
| 551 |
|
| 552 |
+
def _foci_counts(outcome: AuditOutcome) -> dict[str, int]:
|
| 553 |
+
"""Count the distinct overlay regions drawn for each canonical label.
|
| 554 |
+
|
| 555 |
+
Mirrors the overlay pipeline (``cluster_overlay_boxes(categorize_image_findings(...))``)
|
| 556 |
+
so the count matches what the image actually shows: each cluster is one drawn
|
| 557 |
+
region, and a label is counted once per cluster it belongs to. A label the model
|
| 558 |
+
localizes at two separate sites scores 2; two near-coincident boxes that merge
|
| 559 |
+
into one cluster score 1; a present finding with no box scores 0 (it draws
|
| 560 |
+
nothing). ``findings_table_rows`` uses a count above one to append an "(N foci)"
|
| 561 |
+
hint, reconciling the single deduplicated row with the several boxes the overlay
|
| 562 |
+
draws.
|
| 563 |
+
"""
|
| 564 |
+
counts: dict[str, int] = {}
|
| 565 |
+
for cluster in cluster_overlay_boxes(categorize_image_findings(outcome)):
|
| 566 |
+
for label in cluster.labels:
|
| 567 |
+
counts[label] = counts.get(label, 0) + 1
|
| 568 |
+
return counts
|
| 569 |
+
|
| 570 |
+
|
| 571 |
def findings_table_rows(outcome: AuditOutcome) -> list[list[str]]:
|
| 572 |
"""Build the structured findings table rows.
|
| 573 |
|
|
|
|
| 576 |
``status`` is ``"Present"`` or ``"Absent"``, and ``audit_flag`` is a plain
|
| 577 |
English phrase. Image findings are deduplicated by canonical label (the model
|
| 578 |
can localize one label to several boxes; the table shows it once), so a
|
| 579 |
+
non-expert never sees the same finding listed repeatedly; when the overlay
|
| 580 |
+
draws several distinct boxes for one label, that label's single row carries an
|
| 581 |
+
"(N foci)" hint so the one row reconciles with the several boxes on the image.
|
| 582 |
+
The ``no_finding``
|
| 583 |
sentinel is never listed beside positive findings - it is mutually exclusive
|
| 584 |
with them - so a negative draft ("lungs are clear") adds no contradictory row;
|
| 585 |
a genuinely clear study instead shows a single ``no_finding`` row.
|
|
|
|
| 597 |
|
| 598 |
missing = set(outcome.result.audit.missing_findings)
|
| 599 |
urgent = set(outcome.result.audit.urgent_review_flags)
|
| 600 |
+
foci_by_label = _foci_counts(outcome)
|
| 601 |
seen_image_labels: set[str] = set()
|
| 602 |
for finding in outcome.result.image_findings:
|
| 603 |
if finding.status is not FindingStatus.PRESENT or not _is_positive(finding.finding):
|
|
|
|
| 611 |
flag = _FLAG_MISSING
|
| 612 |
else:
|
| 613 |
flag = _FLAG_SUPPORTED
|
| 614 |
+
name = display_name(finding.finding)
|
| 615 |
+
foci = foci_by_label.get(finding.finding, 0)
|
| 616 |
+
if foci > 1:
|
| 617 |
+
name = f"{name} ({foci} foci)"
|
| 618 |
+
rows.append([name, _SOURCE_IMAGE, _status_word(finding.status), flag])
|
| 619 |
|
| 620 |
unsupported = set(outcome.result.audit.unsupported_claims)
|
| 621 |
for draft_finding in outcome.result.draft_findings:
|
reading_room_ui.py
CHANGED
|
@@ -312,6 +312,28 @@ table.rr-table td.dim {{ color: var(--rr-muted); }}
|
|
| 312 |
.rr-pad {{ padding: 12px 16px; }}
|
| 313 |
footer {{ opacity: 0.55; }}
|
| 314 |
.rr-panel .block {{ background: transparent !important; }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
"""
|
| 316 |
|
| 317 |
|
|
|
|
| 312 |
.rr-pad {{ padding: 12px 16px; }}
|
| 313 |
footer {{ opacity: 0.55; }}
|
| 314 |
.rr-panel .block {{ background: transparent !important; }}
|
| 315 |
+
|
| 316 |
+
/* ---- mobile (<=700px): stack the two columns and let the custom flex rows
|
| 317 |
+
wrap, so a phone never overflows horizontally. Scoped entirely to this media
|
| 318 |
+
query, so the desktop and tablet layout above is untouched. The custom header,
|
| 319 |
+
ribbon, and footer are non-wrapping flex rows whose nowrap children would
|
| 320 |
+
otherwise pin the container wider than the viewport and defeat column
|
| 321 |
+
stacking; here they wrap and their nowrap text relaxes. The evidence column
|
| 322 |
+
(rr-col) is forced to a full-width flex line with no min-width floor so the
|
| 323 |
+
left and right columns stack instead of sitting side by side. ---- */
|
| 324 |
+
@media (max-width: 700px) {{
|
| 325 |
+
#rr-header {{ flex-wrap: wrap; }}
|
| 326 |
+
#rr-header h1 {{ white-space: normal; }}
|
| 327 |
+
#rr-header .chip {{ white-space: normal; }}
|
| 328 |
+
#rr-header .spacer {{ flex-basis: 100%; }}
|
| 329 |
+
#rr-ribbon {{ flex-wrap: wrap; }}
|
| 330 |
+
#rr-footer {{ flex-wrap: wrap; }}
|
| 331 |
+
#rr-footer .spacer {{ flex-basis: 100%; }}
|
| 332 |
+
.rr-head {{ flex-wrap: wrap; }}
|
| 333 |
+
.rr-head .title {{ white-space: normal; }}
|
| 334 |
+
.rr-row {{ flex-wrap: wrap !important; }}
|
| 335 |
+
.rr-col {{ flex: 1 1 100% !important; min-width: 0 !important; }}
|
| 336 |
+
}}
|
| 337 |
"""
|
| 338 |
|
| 339 |
|