Spaces:
Sleeping
Sleeping
Add upload validation and deployment updates
Browse files
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:
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
| 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": "
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|