Mahrufa commited on
Commit
3897321
·
1 Parent(s): 7a116d9

Add upload validation and deployment updates

Browse files
Files changed (2) hide show
  1. README.md +15 -0
  2. app.py +141 -23
README.md CHANGED
@@ -59,6 +59,8 @@ Features:
59
  - Retrieves top-k visually similar images with cosine similarity
60
  - Avoids returning the same image as the top result for dataset queries
61
  - Shows query image, query filename, query label, image resolution, embedding dimension, and query source
 
 
62
  - Uses colored clinical-style labels for `NORMAL`, `PNEUMONIA`, agreement state, and retrieval confidence
63
  - Includes a compact model card with encoder, embedding dimension, similarity metric, dataset, and clinical status
64
  - Adds a clinical-style retrieval triage panel for query source, majority retrieved pattern, agreement, similarity range, and review status
@@ -69,6 +71,9 @@ Features:
69
  - Shows Top-k sensitivity for Top-3, Top-5, and Top-10 label agreement when the query label is known
70
  - Shows a retrieval confidence badge using a simple heuristic based on top similarity and, when available, Precision@K
71
  - Makes clear that retrieval confidence is not clinical or diagnostic confidence
 
 
 
72
  - Renames retrieved examples as a **Similar Case Board**
73
  - Shows rank, filename, label badge, cosine similarity, query-label agreement, and a progress bar on each result card
74
  - Includes a compact result table with rank, filename, label, cosine similarity, and label agreement
@@ -341,6 +346,15 @@ Retrieval confidence is a heuristic:
341
  - Low confidence otherwise
342
  - Uploaded images use top similarity only because query-label agreement is unavailable
343
 
 
 
 
 
 
 
 
 
 
344
  For super-resolution, each image is:
345
 
346
  1. Converted safely to RGB
@@ -367,6 +381,7 @@ For the synthetic Hi-C mini-demo:
367
  - ResNet18 and ResNet50 are pretrained on natural images, not chest X-rays.
368
  - TorchXRayVision support requires optional dependency installation and a separately built index.
369
  - Retrieval results show visual similarity, not clinical equivalence.
 
370
  - Retrieval confidence is not clinical confidence.
371
  - Retrieval triage is a research dashboard summary, not clinical triage.
372
  - Query-vs-retrieved difference maps are visual comparison aids, not finding maps.
 
59
  - Retrieves top-k visually similar images with cosine similarity
60
  - Avoids returning the same image as the top result for dataset queries
61
  - Shows query image, query filename, query label, image resolution, embedding dimension, and query source
62
+ - For uploaded CXR-index queries, runs a transparent input-domain check that looks for CXR-like grayscale, aspect ratio, and contrast properties
63
+ - Flags likely out-of-domain uploads, such as posters or banners, before interpreting CXR nearest-neighbor results
64
  - Uses colored clinical-style labels for `NORMAL`, `PNEUMONIA`, agreement state, and retrieval confidence
65
  - Includes a compact model card with encoder, embedding dimension, similarity metric, dataset, and clinical status
66
  - Adds a clinical-style retrieval triage panel for query source, majority retrieved pattern, agreement, similarity range, and review status
 
71
  - Shows Top-k sensitivity for Top-3, Top-5, and Top-10 label agreement when the query label is known
72
  - Shows a retrieval confidence badge using a simple heuristic based on top similarity and, when available, Precision@K
73
  - Makes clear that retrieval confidence is not clinical or diagnostic confidence
74
+ - Uses safer uploaded-query wording such as **Top retrieved dataset label** and states that retrieved labels are nearest-neighbor metadata, not predicted labels or diagnoses
75
+ - For out-of-domain uploads, marks retrieval confidence as **Out-of-domain / Low**, hides label agreement metrics as unavailable, and shows NORMAL/PNEUMONIA nearest labels only as debugging context
76
+ - Recommends custom uploaded-gallery retrieval for non-CXR images instead of using the CXR index
77
  - Renames retrieved examples as a **Similar Case Board**
78
  - Shows rank, filename, label badge, cosine similarity, query-label agreement, and a progress bar on each result card
79
  - Includes a compact result table with rank, filename, label, cosine similarity, and label agreement
 
346
  - Low confidence otherwise
347
  - Uploaded images use top similarity only because query-label agreement is unavailable
348
 
349
+ Uploaded CXR-index queries also run a simple out-of-domain input check before retrieval summaries are shown. The check uses transparent image heuristics only:
350
+
351
+ - whether the image is mostly grayscale
352
+ - whether the aspect ratio is roughly radiograph-like
353
+ - whether the image is highly colorful
354
+ - whether grayscale contrast is sufficient for a medical-image-like input
355
+
356
+ This check is not a clinical classifier. If an uploaded image appears out of domain, the app still allows nearest-neighbor retrieval but warns that results may be meaningless and displays nearest CXR dataset labels only as debugging metadata.
357
+
358
  For super-resolution, each image is:
359
 
360
  1. Converted safely to RGB
 
381
  - ResNet18 and ResNet50 are pretrained on natural images, not chest X-rays.
382
  - TorchXRayVision support requires optional dependency installation and a separately built index.
383
  - Retrieval results show visual similarity, not clinical equivalence.
384
+ - The CXR retrieval module is intended for chest X-ray-like inputs. Out-of-domain images may still produce nearest-neighbor results, but these are not meaningful and are flagged by the app.
385
  - Retrieval confidence is not clinical confidence.
386
  - Retrieval triage is a research dashboard summary, not clinical triage.
387
  - Query-vs-retrieved difference maps are visual comparison aids, not finding maps.
app.py CHANGED
@@ -93,8 +93,8 @@ def inject_css() -> None:
93
 
94
  .block-container {
95
  max-width: 1400px;
96
- padding-top: 0.9rem;
97
- padding-bottom: 3rem;
98
  }
99
 
100
  [data-testid="stSidebar"] {
@@ -109,9 +109,15 @@ def inject_css() -> None:
109
  background:
110
  linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(239, 246, 255, 0.94) 46%, rgba(236, 253, 245, 0.92) 100%);
111
  box-shadow: 0 16px 36px rgba(15, 23, 42, 0.10);
 
112
  margin-bottom: 0.65rem;
113
  position: relative;
114
- overflow: hidden;
 
 
 
 
 
115
  }
116
 
117
  .hero::after {
@@ -129,7 +135,15 @@ def inject_css() -> None:
129
  display: flex;
130
  gap: 0.45rem;
131
  align-items: center;
 
132
  margin-bottom: 0.45rem;
 
 
 
 
 
 
 
133
  }
134
 
135
  .hero h1 {
@@ -158,7 +172,9 @@ def inject_css() -> None:
158
  display: flex;
159
  gap: 0.5rem;
160
  flex-wrap: wrap;
161
- margin-top: 0.65rem;
 
 
162
  }
163
 
164
  .badge {
@@ -1077,6 +1093,43 @@ def absolute_difference_map(query_image: Image.Image, retrieved_image: Image.Ima
1077
  return Image.fromarray((heatmap * 255).astype(np.uint8), mode="RGB")
1078
 
1079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1080
  def matrix_to_heatmap_image(matrix: np.ndarray, colormap: str = "viridis") -> Image.Image:
1081
  matrix = np.asarray(matrix, dtype=np.float32)
1082
  min_value = float(matrix.min())
@@ -1337,7 +1390,7 @@ def calculate_retrieval_quality(
1337
  "precision_at_k": None,
1338
  "mean_similarity": mean_similarity,
1339
  "top_similarity": top_similarity,
1340
- "note": "Label agreement unavailable for uploaded image",
1341
  }
1342
 
1343
  result_labels = get_result_labels(results, labels)
@@ -1375,6 +1428,7 @@ def confidence_badge(confidence: str) -> str:
1375
  "High": "badge badge-high",
1376
  "Medium": "badge badge-medium",
1377
  "Low": "badge badge-low",
 
1378
  }[confidence]
1379
  return f'<span class="{badge_class}">{confidence} retrieval confidence</span>'
1380
 
@@ -1554,6 +1608,30 @@ def show_query_analysis_card(
1554
  st.markdown(key_value_rows(rows), unsafe_allow_html=True)
1555
 
1556
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1557
  def get_result_labels(results: pd.DataFrame, labels: np.ndarray) -> list[str]:
1558
  return [str(labels[int(index)]) for index in results["index"]]
1559
 
@@ -1563,36 +1641,47 @@ def show_summary_card(
1563
  labels: np.ndarray,
1564
  quality: dict[str, float | int | str | None],
1565
  query_index: int | None,
 
1566
  ) -> None:
1567
  result_labels = get_result_labels(results, labels)
1568
  normal_count = result_labels.count("NORMAL")
1569
  pneumonia_count = result_labels.count("PNEUMONIA")
1570
  top_index = int(results.iloc[0]["index"])
1571
- confidence = retrieval_confidence_label(quality, uploaded=query_index is None)
 
1572
 
1573
  with st.container(border=True):
1574
  st.markdown('<div class="section-title">Retrieval Summary</div>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
1575
  st.markdown(
1576
- metric_grid(
1577
- [
1578
- ("Top match label", escape(str(labels[top_index]))),
1579
- ("Top similarity", f"{float(results.iloc[0]['similarity']):.3f}"),
1580
- ("Retrieved", str(len(results))),
1581
- ("NORMAL", str(normal_count)),
1582
- ("PNEUMONIA", str(pneumonia_count)),
1583
- ],
1584
- "metric-grid-5",
1585
- ),
1586
  unsafe_allow_html=True,
1587
  )
 
 
 
 
 
 
 
 
1588
 
1589
 
1590
  def show_retrieval_quality_card(
1591
  results: pd.DataFrame,
1592
  quality: dict[str, float | int | str | None],
1593
  query_index: int | None,
 
1594
  ) -> None:
1595
- confidence = retrieval_confidence_label(quality, uploaded=query_index is None)
 
1596
  with st.container(border=True):
1597
  st.markdown('<div class="section-title">Retrieval Quality</div>', unsafe_allow_html=True)
1598
  if query_index is None:
@@ -1642,11 +1731,15 @@ def show_retrieval_triage(
1642
  query_label: str,
1643
  query_index: int | None,
1644
  query_source: str,
 
1645
  ) -> None:
1646
  result_labels = get_result_labels(results, labels)
1647
  normal_count = result_labels.count("NORMAL")
1648
  pneumonia_count = result_labels.count("PNEUMONIA")
1649
- if normal_count > pneumonia_count:
 
 
 
1650
  pattern = "NORMAL-majority"
1651
  elif pneumonia_count > normal_count:
1652
  pattern = "PNEUMONIA-majority"
@@ -1673,6 +1766,8 @@ def show_retrieval_triage(
1673
  ),
1674
  unsafe_allow_html=True,
1675
  )
 
 
1676
 
1677
 
1678
  def show_topk_sensitivity(
@@ -2707,6 +2802,10 @@ def render_retrieval_dashboard(
2707
  ) -> None:
2708
  results = retrieve_similar(query_embedding, embeddings.copy(), top_k=top_k, query_index=query_index)
2709
  quality = calculate_retrieval_quality(results, labels, query_label, query_index)
 
 
 
 
2710
  st.session_state["last_retrieval_context"] = {
2711
  "query_filename": filename,
2712
  "query_label": query_label,
@@ -2720,13 +2819,14 @@ def render_retrieval_dashboard(
2720
  show_query_card(query_image, filename, query_label, query_source)
2721
  with analysis_col:
2722
  show_query_analysis_card(query_image, query_embedding, filename, query_label, query_source)
 
2723
 
2724
- show_summary_card(results, labels, quality, query_index)
2725
- show_retrieval_quality_card(results, quality, query_index)
2726
 
2727
  triage_col, model_col = st.columns(2, gap="large")
2728
  with triage_col:
2729
- show_retrieval_triage(results, labels, query_label, query_index, query_source)
2730
  with model_col:
2731
  show_model_card(metadata)
2732
 
@@ -2761,6 +2861,10 @@ def render_retrieval_dashboard(
2761
  index=0,
2762
  )
2763
 
 
 
 
 
2764
  if retrieval_chart_view == "Graph":
2765
  chart_col, distribution_col = st.columns([1.15, 0.85], gap="large")
2766
  with chart_col:
@@ -2770,7 +2874,14 @@ def render_retrieval_dashboard(
2770
 
2771
  with distribution_col:
2772
  with st.container(border=True):
2773
- st.markdown('<div class="section-title">Label Distribution</div>', unsafe_allow_html=True)
 
 
 
 
 
 
 
2774
  show_label_distribution(results, labels)
2775
  else:
2776
  similarity_table = pd.DataFrame(
@@ -2802,7 +2913,14 @@ def render_retrieval_dashboard(
2802
  )
2803
  with data_col2:
2804
  with st.container(border=True):
2805
- st.markdown('<div class="section-title">Label Distribution</div>', unsafe_allow_html=True)
 
 
 
 
 
 
 
2806
  st.dataframe(
2807
  distribution_table,
2808
  hide_index=True,
 
93
 
94
  .block-container {
95
  max-width: 1400px;
96
+ padding-top: 2rem !important;
97
+ padding-bottom: 3rem !important;
98
  }
99
 
100
  [data-testid="stSidebar"] {
 
109
  background:
110
  linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(239, 246, 255, 0.94) 46%, rgba(236, 253, 245, 0.92) 100%);
111
  box-shadow: 0 16px 36px rgba(15, 23, 42, 0.10);
112
+ margin-top: 1rem !important;
113
  margin-bottom: 0.65rem;
114
  position: relative;
115
+ overflow: visible !important;
116
+ }
117
+
118
+ .hero-card {
119
+ margin-top: 1rem !important;
120
+ overflow: visible !important;
121
  }
122
 
123
  .hero::after {
 
135
  display: flex;
136
  gap: 0.45rem;
137
  align-items: center;
138
+ margin-top: 0.25rem;
139
  margin-bottom: 0.45rem;
140
+ overflow: visible !important;
141
+ }
142
+
143
+ .hero-badge-row {
144
+ margin-top: 0.25rem;
145
+ margin-bottom: 1rem;
146
+ overflow: visible !important;
147
  }
148
 
149
  .hero h1 {
 
172
  display: flex;
173
  gap: 0.5rem;
174
  flex-wrap: wrap;
175
+ margin-top: 0.25rem;
176
+ margin-bottom: 1rem;
177
+ overflow: visible !important;
178
  }
179
 
180
  .badge {
 
1093
  return Image.fromarray((heatmap * 255).astype(np.uint8), mode="RGB")
1094
 
1095
 
1096
+ def is_likely_xray_image(image: Image.Image) -> tuple[bool, list[str]]:
1097
+ """Transparent input check for CXR-like uploads; not a clinical classifier."""
1098
+ rgb = np.asarray(image.convert("RGB").resize((224, 224)), dtype=np.float32)
1099
+ gray = np.asarray(image.convert("L").resize((224, 224)), dtype=np.float32)
1100
+ width, height = image.size
1101
+ aspect_ratio = width / max(1, height)
1102
+
1103
+ rg_diff = np.mean(np.abs(rgb[..., 0] - rgb[..., 1]))
1104
+ rb_diff = np.mean(np.abs(rgb[..., 0] - rgb[..., 2]))
1105
+ gb_diff = np.mean(np.abs(rgb[..., 1] - rgb[..., 2]))
1106
+ mean_channel_diff = float((rg_diff + rb_diff + gb_diff) / 3.0)
1107
+ gray_std = float(gray.std())
1108
+
1109
+ reasons = []
1110
+ if not 0.5 <= aspect_ratio <= 2.0:
1111
+ reasons.append(f"Aspect ratio {aspect_ratio:.2f} is outside the radiograph-like range 0.5-2.0.")
1112
+ if mean_channel_diff > 22.0:
1113
+ reasons.append(f"Color-channel difference is high ({mean_channel_diff:.1f}); CXR images are usually near-grayscale.")
1114
+ elif mean_channel_diff > 12.0:
1115
+ reasons.append(f"Color-channel difference is moderately high ({mean_channel_diff:.1f}).")
1116
+ if gray_std < 25.0:
1117
+ reasons.append(f"Grayscale contrast is low ({gray_std:.1f}); medical radiographs usually have broader intensity contrast.")
1118
+
1119
+ is_likely = 0.5 <= aspect_ratio <= 2.0 and mean_channel_diff <= 22.0 and gray_std >= 25.0
1120
+ if is_likely and not reasons:
1121
+ reasons.append("Image is near-grayscale, has reasonable aspect ratio, and has sufficient intensity contrast.")
1122
+ return is_likely, reasons
1123
+
1124
+
1125
+ def input_domain_status(domain_check: dict | None) -> str:
1126
+ if domain_check is None:
1127
+ return "Dataset image"
1128
+ if domain_check["is_likely_xray"]:
1129
+ return "Likely CXR-like image"
1130
+ return "Uncertain" if len(domain_check["reasons"]) == 1 else "Out-of-domain image"
1131
+
1132
+
1133
  def matrix_to_heatmap_image(matrix: np.ndarray, colormap: str = "viridis") -> Image.Image:
1134
  matrix = np.asarray(matrix, dtype=np.float32)
1135
  min_value = float(matrix.min())
 
1390
  "precision_at_k": None,
1391
  "mean_similarity": mean_similarity,
1392
  "top_similarity": top_similarity,
1393
+ "note": "Ground-truth label unavailable for uploaded image.",
1394
  }
1395
 
1396
  result_labels = get_result_labels(results, labels)
 
1428
  "High": "badge badge-high",
1429
  "Medium": "badge badge-medium",
1430
  "Low": "badge badge-low",
1431
+ "Out-of-domain / Low": "badge badge-low",
1432
  }[confidence]
1433
  return f'<span class="{badge_class}">{confidence} retrieval confidence</span>'
1434
 
 
1608
  st.markdown(key_value_rows(rows), unsafe_allow_html=True)
1609
 
1610
 
1611
+ def show_input_domain_check_card(domain_check: dict | None) -> None:
1612
+ if domain_check is None:
1613
+ return
1614
+
1615
+ status = input_domain_status(domain_check)
1616
+ badge_class = "badge-high" if status == "Likely CXR-like image" else "badge-medium" if status == "Uncertain" else "badge-low"
1617
+ with st.container(border=True):
1618
+ st.markdown('<div class="section-title">Input domain check</div>', unsafe_allow_html=True)
1619
+ st.markdown(f'<span class="badge {badge_class}">{escape(status)}</span>', unsafe_allow_html=True)
1620
+ if status != "Likely CXR-like image":
1621
+ st.warning(
1622
+ "This uploaded image does not appear to be a chest X-ray. "
1623
+ "CXR retrieval results may be meaningless."
1624
+ )
1625
+ st.info(
1626
+ "Out-of-domain upload detected. Please upload a chest X-ray image for CXR retrieval, "
1627
+ "or use custom uploaded gallery retrieval for non-CXR images."
1628
+ )
1629
+ with st.expander("Input validation notes", expanded=False):
1630
+ for reason in domain_check["reasons"]:
1631
+ st.write(f"- {reason}")
1632
+ st.caption("This is transparent input validation, not a clinical claim or image diagnosis.")
1633
+
1634
+
1635
  def get_result_labels(results: pd.DataFrame, labels: np.ndarray) -> list[str]:
1636
  return [str(labels[int(index)]) for index in results["index"]]
1637
 
 
1641
  labels: np.ndarray,
1642
  quality: dict[str, float | int | str | None],
1643
  query_index: int | None,
1644
+ domain_check: dict | None = None,
1645
  ) -> None:
1646
  result_labels = get_result_labels(results, labels)
1647
  normal_count = result_labels.count("NORMAL")
1648
  pneumonia_count = result_labels.count("PNEUMONIA")
1649
  top_index = int(results.iloc[0]["index"])
1650
+ is_uploaded = query_index is None
1651
+ is_out_of_domain = is_uploaded and domain_check is not None and not bool(domain_check["is_likely_xray"])
1652
 
1653
  with st.container(border=True):
1654
  st.markdown('<div class="section-title">Retrieval Summary</div>', unsafe_allow_html=True)
1655
+ top_label_name = "Top retrieved dataset label" if is_uploaded else "Top retrieved label"
1656
+ metric_items = [
1657
+ (top_label_name, escape(str(labels[top_index]))),
1658
+ ("Top similarity", f"{float(results.iloc[0]['similarity']):.3f}"),
1659
+ ("Retrieved", str(len(results))),
1660
+ ]
1661
+ if not is_out_of_domain:
1662
+ metric_items.extend([("NORMAL", str(normal_count)), ("PNEUMONIA", str(pneumonia_count))])
1663
  st.markdown(
1664
+ metric_grid(metric_items, "metric-grid-5" if len(metric_items) > 3 else "metric-grid-3"),
 
 
 
 
 
 
 
 
 
1665
  unsafe_allow_html=True,
1666
  )
1667
+ st.caption("This is not a predicted label. Retrieved dataset labels are nearest-neighbor metadata, not diagnoses.")
1668
+ if is_out_of_domain:
1669
+ with st.expander("Nearest CXR dataset labels, for debugging only", expanded=True):
1670
+ st.warning("These NORMAL/PNEUMONIA counts are nearest CXR dataset labels only and may be meaningless for this upload.")
1671
+ st.markdown(
1672
+ metric_grid([("NORMAL", str(normal_count)), ("PNEUMONIA", str(pneumonia_count))], "metric-grid-3"),
1673
+ unsafe_allow_html=True,
1674
+ )
1675
 
1676
 
1677
  def show_retrieval_quality_card(
1678
  results: pd.DataFrame,
1679
  quality: dict[str, float | int | str | None],
1680
  query_index: int | None,
1681
+ domain_check: dict | None = None,
1682
  ) -> None:
1683
+ is_out_of_domain = query_index is None and domain_check is not None and not bool(domain_check["is_likely_xray"])
1684
+ confidence = "Out-of-domain / Low" if is_out_of_domain else retrieval_confidence_label(quality, uploaded=query_index is None)
1685
  with st.container(border=True):
1686
  st.markdown('<div class="section-title">Retrieval Quality</div>', unsafe_allow_html=True)
1687
  if query_index is None:
 
1731
  query_label: str,
1732
  query_index: int | None,
1733
  query_source: str,
1734
+ domain_check: dict | None = None,
1735
  ) -> None:
1736
  result_labels = get_result_labels(results, labels)
1737
  normal_count = result_labels.count("NORMAL")
1738
  pneumonia_count = result_labels.count("PNEUMONIA")
1739
+ is_out_of_domain = query_index is None and domain_check is not None and not bool(domain_check["is_likely_xray"])
1740
+ if is_out_of_domain:
1741
+ pattern = "Out-of-domain; labels debugging only"
1742
+ elif normal_count > pneumonia_count:
1743
  pattern = "NORMAL-majority"
1744
  elif pneumonia_count > normal_count:
1745
  pattern = "PNEUMONIA-majority"
 
1766
  ),
1767
  unsafe_allow_html=True,
1768
  )
1769
+ if is_out_of_domain:
1770
+ st.caption("For non-CXR images, use custom uploaded gallery retrieval instead of CXR-index retrieval.")
1771
 
1772
 
1773
  def show_topk_sensitivity(
 
2802
  ) -> None:
2803
  results = retrieve_similar(query_embedding, embeddings.copy(), top_k=top_k, query_index=query_index)
2804
  quality = calculate_retrieval_quality(results, labels, query_label, query_index)
2805
+ domain_check = None
2806
+ if query_index is None:
2807
+ is_likely_xray, reasons = is_likely_xray_image(query_image)
2808
+ domain_check = {"is_likely_xray": is_likely_xray, "reasons": reasons}
2809
  st.session_state["last_retrieval_context"] = {
2810
  "query_filename": filename,
2811
  "query_label": query_label,
 
2819
  show_query_card(query_image, filename, query_label, query_source)
2820
  with analysis_col:
2821
  show_query_analysis_card(query_image, query_embedding, filename, query_label, query_source)
2822
+ show_input_domain_check_card(domain_check)
2823
 
2824
+ show_summary_card(results, labels, quality, query_index, domain_check)
2825
+ show_retrieval_quality_card(results, quality, query_index, domain_check)
2826
 
2827
  triage_col, model_col = st.columns(2, gap="large")
2828
  with triage_col:
2829
+ show_retrieval_triage(results, labels, query_label, query_index, query_source, domain_check)
2830
  with model_col:
2831
  show_model_card(metadata)
2832
 
 
2861
  index=0,
2862
  )
2863
 
2864
+ is_out_of_domain_upload = domain_check is not None and not bool(domain_check["is_likely_xray"])
2865
+ if is_out_of_domain_upload:
2866
+ st.warning("For non-CXR retrieval, upload a custom reference gallery instead of using the CXR index.")
2867
+
2868
  if retrieval_chart_view == "Graph":
2869
  chart_col, distribution_col = st.columns([1.15, 0.85], gap="large")
2870
  with chart_col:
 
2874
 
2875
  with distribution_col:
2876
  with st.container(border=True):
2877
+ distribution_title = (
2878
+ "Nearest CXR dataset labels, for debugging only"
2879
+ if is_out_of_domain_upload
2880
+ else "Retrieved Label Distribution"
2881
+ )
2882
+ st.markdown(f'<div class="section-title">{distribution_title}</div>', unsafe_allow_html=True)
2883
+ if is_out_of_domain_upload:
2884
+ st.caption("These are nearest-neighbor dataset labels, not predictions for the uploaded image.")
2885
  show_label_distribution(results, labels)
2886
  else:
2887
  similarity_table = pd.DataFrame(
 
2913
  )
2914
  with data_col2:
2915
  with st.container(border=True):
2916
+ distribution_title = (
2917
+ "Nearest CXR dataset labels, for debugging only"
2918
+ if is_out_of_domain_upload
2919
+ else "Retrieved Label Distribution"
2920
+ )
2921
+ st.markdown(f'<div class="section-title">{distribution_title}</div>', unsafe_allow_html=True)
2922
+ if is_out_of_domain_upload:
2923
+ st.caption("These are nearest-neighbor dataset labels, not predictions for the uploaded image.")
2924
  st.dataframe(
2925
  distribution_table,
2926
  hide_index=True,