alex-feeel commited on
Commit
a21100c
·
verified ·
1 Parent(s): d6b05c4

Stack columns on mobile (<=700px) and add (N foci) hint for multi-box findings

Browse files
Files changed (4) hide show
  1. app.py +3 -3
  2. cxr_auditor/comparator.py +14 -1
  3. cxr_auditor/render.py +37 -2
  4. 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. The ``no_finding``
 
 
 
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
- rows.append([display_name(finding.finding), _SOURCE_IMAGE, _status_word(finding.status), flag])
 
 
 
 
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