Deploy merged demo: representative images (#42), t-SNE exact solver (#45), PCA reproducibility (#46), decoupled projection/KMeans + thread pipeline, demo header/footer
Browse files- Dockerfile +4 -2
- README.md +15 -0
- apps/embed_explore/app.py +5 -5
- apps/embed_explore/components/image_preview.py +15 -8
- apps/embed_explore/components/sidebar.py +235 -155
- apps/precalculated/app.py +33 -17
- apps/precalculated/components/data_preview.py +97 -77
- shared/__init__.py +8 -1
- shared/components/demo_chrome.py +76 -0
- shared/components/representatives.py +76 -0
- shared/components/summary.py +74 -40
- shared/components/visualization.py +47 -52
- shared/services/clustering_service.py +6 -10
- shared/services/embedding_service.py +117 -51
- shared/utils/backend.py +2 -3
- shared/utils/clustering.py +10 -3
- shared/utils/image_pipeline.py +154 -0
- shared/utils/images.py +197 -0
- shared/utils/io.py +23 -8
- shared/utils/models.py +3 -1
- shared/utils/representatives.py +62 -0
Dockerfile
CHANGED
|
@@ -23,8 +23,10 @@ RUN pip install --no-cache-dir -r requirements-space.txt
|
|
| 23 |
# App source (apps/ and shared/ are pushed to the Space repo alongside this file).
|
| 24 |
COPY --chown=user . $HOME/app
|
| 25 |
|
| 26 |
-
# Demo data is mounted at /data (see README)
|
| 27 |
-
|
|
|
|
|
|
|
| 28 |
|
| 29 |
EXPOSE 7860
|
| 30 |
CMD ["streamlit", "run", "apps/precalculated/app.py", \
|
|
|
|
| 23 |
# App source (apps/ and shared/ are pushed to the Space repo alongside this file).
|
| 24 |
COPY --chown=user . $HOME/app
|
| 25 |
|
| 26 |
+
# Demo data is mounted at /data (see README); EMB_EXPLORER_DEMO=1 turns on the
|
| 27 |
+
# hosted-demo chrome (header/footer) so it stays dormant in normal local use.
|
| 28 |
+
ENV EMB_EXPLORER_DEMO_DATA_ROOT=/data \
|
| 29 |
+
EMB_EXPLORER_DEMO=1
|
| 30 |
|
| 31 |
EXPOSE 7860
|
| 32 |
CMD ["streamlit", "run", "apps/precalculated/app.py", \
|
README.md
CHANGED
|
@@ -7,6 +7,21 @@ sdk: docker
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
# Image Embedding Explorer — Precalculated Demo
|
|
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
+
short_description: Filter, project, and cluster precalculated image embeddings
|
| 11 |
+
tags:
|
| 12 |
+
- biodiversity
|
| 13 |
+
- embeddings
|
| 14 |
+
- bioclip
|
| 15 |
+
- clustering
|
| 16 |
+
- dimensionality-reduction
|
| 17 |
+
- umap
|
| 18 |
+
- tsne
|
| 19 |
+
- visualization
|
| 20 |
+
- imageomics
|
| 21 |
+
datasets:
|
| 22 |
+
- imageomics/TreeOfLife-200M-Embeddings
|
| 23 |
+
models:
|
| 24 |
+
- imageomics/bioclip-2
|
| 25 |
---
|
| 26 |
|
| 27 |
# Image Embedding Explorer — Precalculated Demo
|
apps/embed_explore/app.py
CHANGED
|
@@ -7,11 +7,6 @@ cluster them, and explore the results visually.
|
|
| 7 |
|
| 8 |
import streamlit as st
|
| 9 |
|
| 10 |
-
from apps.embed_explore.components.sidebar import render_clustering_sidebar
|
| 11 |
-
from apps.embed_explore.components.image_preview import render_image_preview
|
| 12 |
-
from shared.components.summary import render_clustering_summary
|
| 13 |
-
from shared.components.visualization import render_scatter_plot
|
| 14 |
-
|
| 15 |
|
| 16 |
def main():
|
| 17 |
"""CLI entry point — launches the Streamlit server."""
|
|
@@ -25,6 +20,11 @@ def main():
|
|
| 25 |
|
| 26 |
def app():
|
| 27 |
"""Streamlit application layout."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
st.set_page_config(
|
| 29 |
layout="wide",
|
| 30 |
page_title="Embed & Explore",
|
|
|
|
| 7 |
|
| 8 |
import streamlit as st
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
def main():
|
| 12 |
"""CLI entry point — launches the Streamlit server."""
|
|
|
|
| 20 |
|
| 21 |
def app():
|
| 22 |
"""Streamlit application layout."""
|
| 23 |
+
from apps.embed_explore.components.sidebar import render_clustering_sidebar
|
| 24 |
+
from apps.embed_explore.components.image_preview import render_image_preview
|
| 25 |
+
from shared.components.summary import render_clustering_summary
|
| 26 |
+
from shared.components.visualization import render_scatter_plot
|
| 27 |
+
|
| 28 |
st.set_page_config(
|
| 29 |
layout="wide",
|
| 30 |
page_title="Embed & Explore",
|
apps/embed_explore/components/image_preview.py
CHANGED
|
@@ -19,24 +19,31 @@ def render_image_preview():
|
|
| 19 |
|
| 20 |
valid_paths = st.session_state.get("valid_paths", None)
|
| 21 |
labels = st.session_state.get("labels", None)
|
| 22 |
-
|
|
|
|
| 23 |
|
| 24 |
if (
|
| 25 |
valid_paths is not None and
|
| 26 |
-
labels is not None and
|
| 27 |
selected_idx is not None and
|
| 28 |
0 <= selected_idx < len(valid_paths)
|
| 29 |
):
|
| 30 |
img_path = valid_paths[selected_idx]
|
| 31 |
-
cluster = labels[selected_idx] if labels is not None else
|
| 32 |
|
| 33 |
-
# Log only when image changes
|
| 34 |
if _last_displayed_path != img_path:
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
| 36 |
_last_displayed_path = img_path
|
| 37 |
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
st.markdown(f"**File:** `{os.path.basename(img_path)}`")
|
| 40 |
-
|
|
|
|
| 41 |
else:
|
| 42 |
-
st.info("Image preview will appear here after you select a
|
|
|
|
| 19 |
|
| 20 |
valid_paths = st.session_state.get("valid_paths", None)
|
| 21 |
labels = st.session_state.get("labels", None)
|
| 22 |
+
kmeans_col = st.session_state.get("kmeans_column", None)
|
| 23 |
+
selected_idx = st.session_state.get("selected_image_idx", None)
|
| 24 |
|
| 25 |
if (
|
| 26 |
valid_paths is not None and
|
|
|
|
| 27 |
selected_idx is not None and
|
| 28 |
0 <= selected_idx < len(valid_paths)
|
| 29 |
):
|
| 30 |
img_path = valid_paths[selected_idx]
|
| 31 |
+
cluster = labels[selected_idx] if labels is not None else None
|
| 32 |
|
|
|
|
| 33 |
if _last_displayed_path != img_path:
|
| 34 |
+
log_msg = f"[Image] Loading local file: {os.path.basename(img_path)}"
|
| 35 |
+
if cluster is not None:
|
| 36 |
+
log_msg += f" (cluster={cluster})"
|
| 37 |
+
logger.info(log_msg)
|
| 38 |
_last_displayed_path = img_path
|
| 39 |
|
| 40 |
+
caption = os.path.basename(img_path)
|
| 41 |
+
if cluster is not None and kmeans_col:
|
| 42 |
+
caption = f"{kmeans_col}={cluster}: {caption}"
|
| 43 |
+
|
| 44 |
+
st.image(img_path, caption=caption, width='stretch')
|
| 45 |
st.markdown(f"**File:** `{os.path.basename(img_path)}`")
|
| 46 |
+
if cluster is not None and kmeans_col:
|
| 47 |
+
st.markdown(f"**{kmeans_col}:** `{cluster}`")
|
| 48 |
else:
|
| 49 |
+
st.info("Image preview will appear here after you select a point in the scatter.")
|
apps/embed_explore/components/sidebar.py
CHANGED
|
@@ -4,13 +4,20 @@ Sidebar components for the embed_explore application.
|
|
| 4 |
|
| 5 |
import streamlit as st
|
| 6 |
import os
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
from shared.services.embedding_service import EmbeddingService
|
| 10 |
from shared.services.clustering_service import ClusteringService
|
| 11 |
from shared.services.file_service import FileService
|
| 12 |
from shared.lib.progress import StreamlitProgressContext
|
| 13 |
-
from shared.components.clustering_controls import
|
|
|
|
|
|
|
|
|
|
| 14 |
from shared.utils.backend import check_cuda_available, resolve_backend, is_oom_error
|
| 15 |
from shared.utils.logging_config import get_logger
|
| 16 |
|
|
@@ -74,10 +81,11 @@ def render_embedding_section() -> Tuple[bool, Optional[str], Optional[str], int,
|
|
| 74 |
st.session_state.valid_paths = valid_paths
|
| 75 |
st.session_state.last_image_dir = image_dir
|
| 76 |
st.session_state.embedding_complete = True
|
| 77 |
-
# Reset clustering/selection state
|
| 78 |
st.session_state.labels = None
|
|
|
|
| 79 |
st.session_state.data = None
|
| 80 |
-
st.session_state.selected_image_idx =
|
| 81 |
|
| 82 |
except Exception as e:
|
| 83 |
st.error(f"Error during embedding: {e}")
|
|
@@ -89,152 +97,239 @@ def render_embedding_section() -> Tuple[bool, Optional[str], Optional[str], int,
|
|
| 89 |
return embed_button, image_dir, model_name, n_workers, batch_size
|
| 90 |
|
| 91 |
|
| 92 |
-
def
|
| 93 |
-
"""
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
|
|
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
"""
|
| 102 |
-
with st.expander("Cluster", expanded=False):
|
| 103 |
-
# Basic clustering controls
|
| 104 |
-
n_clusters, reduction_method = render_basic_clustering_controls()
|
| 105 |
-
|
| 106 |
-
# Backend and advanced controls
|
| 107 |
-
dim_reduction_backend, clustering_backend, n_workers_clustering, seed = render_clustering_backend_controls()
|
| 108 |
-
|
| 109 |
-
cluster_button = st.button("Run Clustering", type="primary")
|
| 110 |
-
|
| 111 |
-
# Handle clustering execution
|
| 112 |
-
if cluster_button:
|
| 113 |
-
embeddings = st.session_state.get("embeddings", None)
|
| 114 |
-
valid_paths = st.session_state.get("valid_paths", None)
|
| 115 |
-
|
| 116 |
-
if embeddings is not None and valid_paths is not None and len(valid_paths) > 1:
|
| 117 |
-
run_clustering_with_fallback(
|
| 118 |
-
embeddings, valid_paths, n_clusters, reduction_method,
|
| 119 |
-
n_workers_clustering, dim_reduction_backend, clustering_backend, seed
|
| 120 |
-
)
|
| 121 |
-
else:
|
| 122 |
-
st.error("Please run embedding first.")
|
| 123 |
-
|
| 124 |
-
return cluster_button, n_clusters, reduction_method
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def run_clustering_with_fallback(
|
| 128 |
-
embeddings,
|
| 129 |
-
valid_paths,
|
| 130 |
-
n_clusters: int,
|
| 131 |
-
reduction_method: str,
|
| 132 |
-
n_workers: int,
|
| 133 |
-
dim_reduction_backend: str,
|
| 134 |
-
clustering_backend: str,
|
| 135 |
-
seed: Optional[int]
|
| 136 |
-
):
|
| 137 |
-
"""
|
| 138 |
-
Run clustering with robust error handling and automatic fallbacks.
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
-
logger.info(f"Starting clustering: samples={len(embeddings)}, clusters={n_clusters}, "
|
| 148 |
-
f"reduction={reduction_method}, device={device_info}")
|
| 149 |
-
logger.info(f"Backends: dim_reduction={actual_dim_backend}, clustering={actual_cluster_backend}")
|
| 150 |
|
|
|
|
|
|
|
| 151 |
try:
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
| 156 |
)
|
| 157 |
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
| 159 |
st.session_state.data = df_plot
|
| 160 |
st.session_state.labels = labels
|
| 161 |
-
st.session_state.
|
| 162 |
|
| 163 |
-
# Compute
|
|
|
|
|
|
|
| 164 |
logger.info("Computing clustering summary statistics...")
|
| 165 |
summary_df, representatives = ClusteringService.generate_clustering_summary(
|
| 166 |
embeddings, labels, df_plot
|
| 167 |
)
|
| 168 |
-
st.session_state.
|
| 169 |
-
st.session_state.
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
-
|
|
|
|
| 173 |
|
| 174 |
except (RuntimeError, OSError) as e:
|
| 175 |
if is_oom_error(e):
|
| 176 |
-
st.error("**GPU Out of Memory**
|
| 177 |
-
|
| 178 |
-
logger.exception("GPU OOM error during clustering")
|
| 179 |
else:
|
| 180 |
-
st.error(f"Error during
|
| 181 |
-
logger.exception("
|
| 182 |
-
|
| 183 |
except MemoryError:
|
| 184 |
st.error("**System Out of Memory** - Reduce dataset size")
|
| 185 |
-
logger.exception("System memory exhausted during
|
| 186 |
-
|
| 187 |
except Exception as e:
|
| 188 |
-
st.error(f"Error
|
| 189 |
-
logger.exception("Unexpected
|
| 190 |
|
| 191 |
|
| 192 |
-
def
|
| 193 |
-
"""
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
df_plot
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
if df_plot is not None and labels is not None:
|
| 201 |
-
available_clusters = sorted(df_plot['cluster'].unique(), key=lambda x: int(x))
|
| 202 |
-
selected_clusters = st.multiselect(
|
| 203 |
-
"Select cluster(s) to save",
|
| 204 |
-
available_clusters,
|
| 205 |
-
default=available_clusters[:1] if available_clusters else [],
|
| 206 |
-
key="save_cluster_select"
|
| 207 |
-
)
|
| 208 |
-
save_dir = st.text_input(
|
| 209 |
-
"Directory to save selected cluster images",
|
| 210 |
-
value="cluster_selected_output",
|
| 211 |
-
key="save_cluster_dir"
|
| 212 |
-
)
|
| 213 |
-
save_cluster_button = st.button("Save images", key="save_cluster_btn")
|
| 214 |
|
| 215 |
-
# Handle save execution
|
| 216 |
-
if save_cluster_button and selected_clusters:
|
| 217 |
-
cluster_rows = df_plot[df_plot['cluster'].isin(selected_clusters)]
|
| 218 |
-
max_workers = st.session_state.get("num_threads", 8)
|
| 219 |
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
f"Images from cluster(s) {', '.join(map(str, selected_clusters))} saved successfully!"
|
| 223 |
-
) as progress:
|
| 224 |
-
try:
|
| 225 |
-
save_summary_df, csv_path = FileService.save_cluster_images(
|
| 226 |
-
cluster_rows, save_dir, max_workers, progress_callback=progress
|
| 227 |
-
)
|
| 228 |
-
st.info(f"Summary CSV saved at {csv_path}")
|
| 229 |
-
|
| 230 |
-
except Exception as e:
|
| 231 |
-
save_status_placeholder.error(f"Error saving images: {e}")
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
# --- Repartition expander and status ---
|
| 240 |
repartition_status_placeholder = st.empty()
|
|
@@ -243,7 +338,7 @@ def render_save_section():
|
|
| 243 |
repartition_dir = st.text_input(
|
| 244 |
"Directory",
|
| 245 |
value="repartitioned_output",
|
| 246 |
-
key="repartition_dir"
|
| 247 |
)
|
| 248 |
max_workers = st.number_input(
|
| 249 |
"Number of threads (higher = faster, try 8-32)",
|
|
@@ -251,49 +346,34 @@ def render_save_section():
|
|
| 251 |
max_value=64,
|
| 252 |
value=8,
|
| 253 |
step=1,
|
| 254 |
-
key="num_threads"
|
| 255 |
)
|
| 256 |
repartition_button = st.button("Repartition images by cluster", key="repartition_btn")
|
| 257 |
|
| 258 |
-
# Handle repartition execution
|
| 259 |
if repartition_button:
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
repartition_status_placeholder
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
st.info(f"Summary CSV saved at {csv_path}")
|
| 274 |
-
|
| 275 |
-
except Exception as e:
|
| 276 |
-
repartition_status_placeholder.error(f"Error repartitioning images: {e}")
|
| 277 |
|
| 278 |
|
| 279 |
def render_clustering_sidebar():
|
| 280 |
-
"""Render the complete
|
| 281 |
tab_compute, tab_save = st.tabs(["Compute", "Save"])
|
| 282 |
|
| 283 |
with tab_compute:
|
| 284 |
-
|
| 285 |
-
|
|
|
|
| 286 |
|
| 287 |
with tab_save:
|
| 288 |
render_save_section()
|
| 289 |
-
|
| 290 |
-
return {
|
| 291 |
-
'embed_button': embed_button,
|
| 292 |
-
'image_dir': image_dir,
|
| 293 |
-
'model_name': model_name,
|
| 294 |
-
'n_workers': n_workers,
|
| 295 |
-
'batch_size': batch_size,
|
| 296 |
-
'cluster_button': cluster_button,
|
| 297 |
-
'n_clusters': n_clusters,
|
| 298 |
-
'reduction_method': reduction_method,
|
| 299 |
-
}
|
|
|
|
| 4 |
|
| 5 |
import streamlit as st
|
| 6 |
import os
|
| 7 |
+
import time
|
| 8 |
+
import hashlib
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from typing import Tuple, Optional
|
| 12 |
|
| 13 |
from shared.services.embedding_service import EmbeddingService
|
| 14 |
from shared.services.clustering_service import ClusteringService
|
| 15 |
from shared.services.file_service import FileService
|
| 16 |
from shared.lib.progress import StreamlitProgressContext
|
| 17 |
+
from shared.components.clustering_controls import (
|
| 18 |
+
render_projection_controls,
|
| 19 |
+
render_kmeans_controls,
|
| 20 |
+
)
|
| 21 |
from shared.utils.backend import check_cuda_available, resolve_backend, is_oom_error
|
| 22 |
from shared.utils.logging_config import get_logger
|
| 23 |
|
|
|
|
| 81 |
st.session_state.valid_paths = valid_paths
|
| 82 |
st.session_state.last_image_dir = image_dir
|
| 83 |
st.session_state.embedding_complete = True
|
| 84 |
+
# Reset projection/clustering/selection state for the new embeddings
|
| 85 |
st.session_state.labels = None
|
| 86 |
+
st.session_state.kmeans_column = None
|
| 87 |
st.session_state.data = None
|
| 88 |
+
st.session_state.selected_image_idx = None
|
| 89 |
|
| 90 |
except Exception as e:
|
| 91 |
st.error(f"Error during embedding: {e}")
|
|
|
|
| 97 |
return embed_button, image_dir, model_name, n_workers, batch_size
|
| 98 |
|
| 99 |
|
| 100 |
+
def render_projection_section():
|
| 101 |
+
"""Render the 2D projection section."""
|
| 102 |
+
with st.expander("Project to 2D", expanded=False):
|
| 103 |
+
embeddings = st.session_state.get("embeddings", None)
|
| 104 |
+
valid_paths = st.session_state.get("valid_paths", None)
|
| 105 |
|
| 106 |
+
if embeddings is None or valid_paths is None or len(valid_paths) < 2:
|
| 107 |
+
st.info("Run embedding first to enable projection.")
|
| 108 |
+
return
|
| 109 |
|
| 110 |
+
n_samples, emb_dim = embeddings.shape
|
| 111 |
+
st.markdown(f"**Ready to project:** {n_samples:,} images ({emb_dim}-dim embeddings)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
+
reduction_method = st.selectbox(
|
| 114 |
+
"Dimensionality Reduction",
|
| 115 |
+
["TSNE", "PCA", "UMAP"],
|
| 116 |
+
help="Method to project high-dimensional embeddings to 2D for visualization.",
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
dim_reduction_backend, seed = render_projection_controls()
|
| 120 |
+
|
| 121 |
+
if st.button("Project to 2D", type="primary"):
|
| 122 |
+
_run_projection(embeddings, valid_paths, reduction_method, dim_reduction_backend, seed)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def render_kmeans_section():
|
| 126 |
+
"""Render the optional KMeans clustering section."""
|
| 127 |
+
with st.expander("KMeans Clustering", expanded=False):
|
| 128 |
+
df_plot = st.session_state.get("data", None)
|
| 129 |
+
embeddings = st.session_state.get("embeddings", None)
|
| 130 |
+
|
| 131 |
+
if df_plot is None or embeddings is None:
|
| 132 |
+
st.info("Run projection first to enable KMeans.")
|
| 133 |
+
return
|
| 134 |
+
|
| 135 |
+
emb_dim = embeddings.shape[1]
|
| 136 |
+
st.markdown(f"**{len(df_plot):,} points** ({emb_dim}-dim embeddings)")
|
| 137 |
+
|
| 138 |
+
n_clusters = st.slider("Number of clusters", 2, min(100, max(2, len(df_plot) // 2)), 5)
|
| 139 |
+
|
| 140 |
+
clustering_backend, n_workers, seed = render_kmeans_controls()
|
| 141 |
+
|
| 142 |
+
if st.button("Run KMeans", type="primary"):
|
| 143 |
+
_run_kmeans(embeddings, n_clusters, clustering_backend, n_workers, seed)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _run_projection(embeddings, valid_paths, reduction_method, dim_reduction_backend, seed):
|
| 147 |
+
"""Run dim reduction and create the 2D scatter plot dataframe."""
|
| 148 |
+
try:
|
| 149 |
+
cuda_available, device_info = check_cuda_available()
|
| 150 |
+
actual_backend = resolve_backend(dim_reduction_backend, "reduction")
|
| 151 |
+
|
| 152 |
+
logger.info("=" * 60)
|
| 153 |
+
logger.info("PROJECTION START")
|
| 154 |
+
logger.info(f"Device: {device_info} (CUDA: {'Yes' if cuda_available else 'No'})")
|
| 155 |
+
logger.info(f"Backend: {actual_backend} (requested: {dim_reduction_backend})")
|
| 156 |
+
|
| 157 |
+
t_start = time.time()
|
| 158 |
+
n_samples, emb_dim = embeddings.shape
|
| 159 |
+
logger.info(f"Records: {n_samples:,} | Dim: {emb_dim}")
|
| 160 |
+
|
| 161 |
+
with st.spinner(f"Running {reduction_method}..."):
|
| 162 |
+
reduced = ClusteringService.run_dim_reduction_safe(
|
| 163 |
+
embeddings, reduction_method,
|
| 164 |
+
n_workers=8, dim_reduction_backend=actual_backend, seed=seed
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
t_total = time.time() - t_start
|
| 168 |
+
logger.info(f"Projection complete in {t_total:.2f}s")
|
| 169 |
+
|
| 170 |
+
# Build plot dataframe (no cluster column)
|
| 171 |
+
df_plot = pd.DataFrame({
|
| 172 |
+
"x": reduced[:, 0],
|
| 173 |
+
"y": reduced[:, 1],
|
| 174 |
+
"image_path": valid_paths,
|
| 175 |
+
"file_name": [os.path.basename(p) for p in valid_paths],
|
| 176 |
+
"idx": range(len(valid_paths)),
|
| 177 |
+
})
|
| 178 |
+
|
| 179 |
+
# Carry over any prior KMeans columns from the previous df_plot (if length matches)
|
| 180 |
+
prev_df = st.session_state.get("data")
|
| 181 |
+
if prev_df is not None and len(prev_df) == len(df_plot):
|
| 182 |
+
for col in prev_df.columns:
|
| 183 |
+
if col.startswith("KMeans (k="):
|
| 184 |
+
df_plot[col] = prev_df[col].values
|
| 185 |
+
|
| 186 |
+
data_hash = hashlib.md5(f"{len(df_plot)}_{reduction_method}_{t_total}".encode()).hexdigest()[:8]
|
| 187 |
+
st.session_state.data = df_plot
|
| 188 |
+
st.session_state.data_version = data_hash
|
| 189 |
+
st.session_state.selected_image_idx = None
|
| 190 |
+
|
| 191 |
+
logger.info("=" * 60)
|
| 192 |
+
st.success(f"Projected {n_samples:,} points to 2D using {reduction_method}.")
|
| 193 |
+
|
| 194 |
+
except (RuntimeError, OSError) as e:
|
| 195 |
+
if is_oom_error(e):
|
| 196 |
+
st.error("**GPU Out of Memory**")
|
| 197 |
+
st.info("Try: Reduce dataset size, use 'sklearn' backend, or try PCA.")
|
| 198 |
+
logger.exception("GPU OOM during projection")
|
| 199 |
+
else:
|
| 200 |
+
st.error(f"Error during projection: {e}")
|
| 201 |
+
logger.exception("Projection error")
|
| 202 |
+
except MemoryError:
|
| 203 |
+
st.error("**System Out of Memory** - Reduce dataset size")
|
| 204 |
+
logger.exception("System memory exhausted during projection")
|
| 205 |
+
except Exception as e:
|
| 206 |
+
st.error(f"Error: {e}")
|
| 207 |
+
logger.exception("Unexpected projection error")
|
| 208 |
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
+
def _run_kmeans(embeddings, n_clusters, clustering_backend, n_workers, seed):
|
| 211 |
+
"""Run KMeans on already-extracted embeddings and add labels to df_plot."""
|
| 212 |
try:
|
| 213 |
+
actual_backend = resolve_backend(clustering_backend, "clustering")
|
| 214 |
+
logger.info(f"KMeans: k={n_clusters}, backend={actual_backend}")
|
| 215 |
+
|
| 216 |
+
with st.spinner(f"Running KMeans (k={n_clusters})..."):
|
| 217 |
+
labels = ClusteringService.run_kmeans_only_safe(
|
| 218 |
+
embeddings, n_clusters,
|
| 219 |
+
n_workers=n_workers, clustering_backend=actual_backend, seed=seed
|
| 220 |
)
|
| 221 |
|
| 222 |
+
df_plot = st.session_state.data
|
| 223 |
+
kmeans_col = f"KMeans (k={n_clusters})"
|
| 224 |
+
|
| 225 |
+
df_plot[kmeans_col] = labels.astype(str)
|
| 226 |
st.session_state.data = df_plot
|
| 227 |
st.session_state.labels = labels
|
| 228 |
+
st.session_state.kmeans_column = kmeans_col
|
| 229 |
|
| 230 |
+
# Compute clustering summary on the full embedding space.
|
| 231 |
+
# Cache by kmeans_col so multiple KMeans runs can each have their own
|
| 232 |
+
# summary + representatives that the user can switch between.
|
| 233 |
logger.info("Computing clustering summary statistics...")
|
| 234 |
summary_df, representatives = ClusteringService.generate_clustering_summary(
|
| 235 |
embeddings, labels, df_plot
|
| 236 |
)
|
| 237 |
+
summaries = st.session_state.get("clustering_summaries", {})
|
| 238 |
+
reps_by_col = st.session_state.get("clustering_representatives_by_col", {})
|
| 239 |
+
summaries[kmeans_col] = summary_df
|
| 240 |
+
reps_by_col[kmeans_col] = representatives
|
| 241 |
+
st.session_state.clustering_summaries = summaries
|
| 242 |
+
st.session_state.clustering_representatives_by_col = reps_by_col
|
| 243 |
+
logger.info(f"Clustering summary computed for {kmeans_col}: {len(summary_df)} clusters")
|
| 244 |
|
| 245 |
+
logger.info(f"KMeans complete: {len(np.unique(labels))} clusters")
|
| 246 |
+
st.success(f"KMeans complete! {len(np.unique(labels))} clusters assigned.")
|
| 247 |
|
| 248 |
except (RuntimeError, OSError) as e:
|
| 249 |
if is_oom_error(e):
|
| 250 |
+
st.error("**GPU Out of Memory**")
|
| 251 |
+
logger.exception("GPU OOM during KMeans")
|
|
|
|
| 252 |
else:
|
| 253 |
+
st.error(f"Error during KMeans: {e}")
|
| 254 |
+
logger.exception("KMeans error")
|
|
|
|
| 255 |
except MemoryError:
|
| 256 |
st.error("**System Out of Memory** - Reduce dataset size")
|
| 257 |
+
logger.exception("System memory exhausted during KMeans")
|
|
|
|
| 258 |
except Exception as e:
|
| 259 |
+
st.error(f"Error: {e}")
|
| 260 |
+
logger.exception("Unexpected KMeans error")
|
| 261 |
|
| 262 |
|
| 263 |
+
def _get_available_kmeans_cols(df_plot) -> list:
|
| 264 |
+
"""Return KMeans columns in df_plot sorted by k value."""
|
| 265 |
+
if df_plot is None:
|
| 266 |
+
return []
|
| 267 |
+
return sorted(
|
| 268 |
+
[c for c in df_plot.columns if c.startswith("KMeans (k=")],
|
| 269 |
+
key=lambda c: int(c.split("=")[1].rstrip(")")),
|
| 270 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
+
def render_save_section():
|
| 274 |
+
"""Render the save operations section of the sidebar.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
+
Both 'Save Images from Specific Cluster' and 'Repartition Images by Cluster'
|
| 277 |
+
require at least one KMeans run. When multiple KMeans runs exist, the user
|
| 278 |
+
picks which one to operate on via a shared selector at the top.
|
| 279 |
+
"""
|
| 280 |
+
df_plot = st.session_state.get("data", None)
|
| 281 |
+
kmeans_cols = _get_available_kmeans_cols(df_plot)
|
| 282 |
+
|
| 283 |
+
if not kmeans_cols:
|
| 284 |
+
st.info("Run KMeans first to enable saving by cluster.")
|
| 285 |
+
return
|
| 286 |
+
|
| 287 |
+
# Shared selector: which KMeans run drives both save operations
|
| 288 |
+
default_idx = len(kmeans_cols) - 1 # most recent run
|
| 289 |
+
selected_kmeans_col = st.selectbox(
|
| 290 |
+
"KMeans result",
|
| 291 |
+
options=kmeans_cols,
|
| 292 |
+
index=default_idx,
|
| 293 |
+
key="save_kmeans_selector",
|
| 294 |
+
help="Pick which KMeans run to use for save / repartition.",
|
| 295 |
+
)
|
| 296 |
|
| 297 |
+
# --- Save images from a specific cluster utility ---
|
| 298 |
+
save_status_placeholder = st.empty()
|
| 299 |
+
with st.expander("Save Images from Specific Cluster", expanded=True):
|
| 300 |
+
available_clusters = sorted(df_plot[selected_kmeans_col].unique(), key=lambda x: int(x))
|
| 301 |
+
selected_clusters = st.multiselect(
|
| 302 |
+
"Select cluster(s) to save",
|
| 303 |
+
available_clusters,
|
| 304 |
+
default=available_clusters[:1] if available_clusters else [],
|
| 305 |
+
key="save_cluster_select",
|
| 306 |
+
)
|
| 307 |
+
save_dir = st.text_input(
|
| 308 |
+
"Directory to save selected cluster images",
|
| 309 |
+
value="cluster_selected_output",
|
| 310 |
+
key="save_cluster_dir",
|
| 311 |
+
)
|
| 312 |
+
save_cluster_button = st.button("Save images", key="save_cluster_btn")
|
| 313 |
+
|
| 314 |
+
if save_cluster_button and selected_clusters:
|
| 315 |
+
cluster_rows = df_plot[df_plot[selected_kmeans_col].isin(selected_clusters)].copy()
|
| 316 |
+
# FileService expects a 'cluster' column
|
| 317 |
+
cluster_rows["cluster"] = cluster_rows[selected_kmeans_col]
|
| 318 |
+
max_workers = st.session_state.get("num_threads", 8)
|
| 319 |
+
|
| 320 |
+
with StreamlitProgressContext(
|
| 321 |
+
save_status_placeholder,
|
| 322 |
+
f"Images from cluster(s) {', '.join(map(str, selected_clusters))} saved successfully!"
|
| 323 |
+
) as progress:
|
| 324 |
+
try:
|
| 325 |
+
save_summary_df, csv_path = FileService.save_cluster_images(
|
| 326 |
+
cluster_rows, save_dir, max_workers, progress_callback=progress
|
| 327 |
+
)
|
| 328 |
+
st.info(f"Summary CSV saved at {csv_path}")
|
| 329 |
+
except Exception as e:
|
| 330 |
+
save_status_placeholder.error(f"Error saving images: {e}")
|
| 331 |
+
elif save_cluster_button:
|
| 332 |
+
save_status_placeholder.warning("Please select at least one cluster.")
|
| 333 |
|
| 334 |
# --- Repartition expander and status ---
|
| 335 |
repartition_status_placeholder = st.empty()
|
|
|
|
| 338 |
repartition_dir = st.text_input(
|
| 339 |
"Directory",
|
| 340 |
value="repartitioned_output",
|
| 341 |
+
key="repartition_dir",
|
| 342 |
)
|
| 343 |
max_workers = st.number_input(
|
| 344 |
"Number of threads (higher = faster, try 8-32)",
|
|
|
|
| 346 |
max_value=64,
|
| 347 |
value=8,
|
| 348 |
step=1,
|
| 349 |
+
key="num_threads",
|
| 350 |
)
|
| 351 |
repartition_button = st.button("Repartition images by cluster", key="repartition_btn")
|
| 352 |
|
|
|
|
| 353 |
if repartition_button:
|
| 354 |
+
df_for_repartition = df_plot.copy()
|
| 355 |
+
df_for_repartition["cluster"] = df_for_repartition[selected_kmeans_col]
|
| 356 |
+
with StreamlitProgressContext(
|
| 357 |
+
repartition_status_placeholder,
|
| 358 |
+
f"Repartition complete! Images organized in {repartition_dir}",
|
| 359 |
+
) as progress:
|
| 360 |
+
try:
|
| 361 |
+
repartition_summary_df, csv_path = FileService.repartition_images_by_cluster(
|
| 362 |
+
df_for_repartition, repartition_dir, max_workers, progress_callback=progress
|
| 363 |
+
)
|
| 364 |
+
st.info(f"Summary CSV saved at {csv_path}")
|
| 365 |
+
except Exception as e:
|
| 366 |
+
repartition_status_placeholder.error(f"Error repartitioning images: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
|
| 368 |
|
| 369 |
def render_clustering_sidebar():
|
| 370 |
+
"""Render the complete sidebar with embed / project / KMeans / save sections."""
|
| 371 |
tab_compute, tab_save = st.tabs(["Compute", "Save"])
|
| 372 |
|
| 373 |
with tab_compute:
|
| 374 |
+
render_embedding_section()
|
| 375 |
+
render_projection_section()
|
| 376 |
+
render_kmeans_section()
|
| 377 |
|
| 378 |
with tab_save:
|
| 379 |
render_save_section()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
apps/precalculated/app.py
CHANGED
|
@@ -7,16 +7,6 @@ Features dynamic filter generation based on available columns.
|
|
| 7 |
|
| 8 |
import streamlit as st
|
| 9 |
|
| 10 |
-
from apps.precalculated.components.sidebar import (
|
| 11 |
-
render_file_section,
|
| 12 |
-
render_dynamic_filters,
|
| 13 |
-
render_projection_section,
|
| 14 |
-
render_kmeans_section,
|
| 15 |
-
)
|
| 16 |
-
from apps.precalculated.components.data_preview import render_data_preview
|
| 17 |
-
from shared.components.visualization import render_scatter_plot
|
| 18 |
-
from shared.components.summary import render_clustering_summary
|
| 19 |
-
|
| 20 |
|
| 21 |
def main():
|
| 22 |
"""CLI entry point — launches the Streamlit server."""
|
|
@@ -30,6 +20,24 @@ def main():
|
|
| 30 |
|
| 31 |
def app():
|
| 32 |
"""Streamlit application layout."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
st.set_page_config(
|
| 34 |
layout="wide",
|
| 35 |
page_title="Precalculated Embeddings Explorer",
|
|
@@ -45,12 +53,15 @@ def app():
|
|
| 45 |
del st.session_state[key]
|
| 46 |
st.session_state.page_type = "precalculated_app"
|
| 47 |
|
| 48 |
-
# Header
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
"
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
# Row 1: File loading
|
| 56 |
render_file_section()
|
|
@@ -71,9 +82,14 @@ def app():
|
|
| 71 |
with col_preview:
|
| 72 |
render_data_preview()
|
| 73 |
|
| 74 |
-
# Bottom: Taxonomy summary
|
| 75 |
st.markdown("---")
|
| 76 |
render_clustering_summary(show_taxonomy=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
if __name__ == "__main__":
|
|
|
|
| 7 |
|
| 8 |
import streamlit as st
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
def main():
|
| 12 |
"""CLI entry point — launches the Streamlit server."""
|
|
|
|
| 20 |
|
| 21 |
def app():
|
| 22 |
"""Streamlit application layout."""
|
| 23 |
+
from apps.precalculated.components.sidebar import (
|
| 24 |
+
render_file_section,
|
| 25 |
+
render_dynamic_filters,
|
| 26 |
+
render_projection_section,
|
| 27 |
+
render_kmeans_section,
|
| 28 |
+
)
|
| 29 |
+
from apps.precalculated.components.data_preview import (
|
| 30 |
+
render_data_preview,
|
| 31 |
+
render_cluster_representatives,
|
| 32 |
+
)
|
| 33 |
+
from shared.components.visualization import render_scatter_plot
|
| 34 |
+
from shared.components.summary import render_clustering_summary
|
| 35 |
+
from shared.components.demo_chrome import (
|
| 36 |
+
is_demo_mode,
|
| 37 |
+
render_demo_header,
|
| 38 |
+
render_demo_footer,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
st.set_page_config(
|
| 42 |
layout="wide",
|
| 43 |
page_title="Precalculated Embeddings Explorer",
|
|
|
|
| 53 |
del st.session_state[key]
|
| 54 |
st.session_state.page_type = "precalculated_app"
|
| 55 |
|
| 56 |
+
# Header — demo chrome when hosted, otherwise the standard title.
|
| 57 |
+
if is_demo_mode():
|
| 58 |
+
render_demo_header()
|
| 59 |
+
else:
|
| 60 |
+
st.title("📊 Precalculated Embeddings Explorer")
|
| 61 |
+
st.markdown(
|
| 62 |
+
"Load parquet files with embeddings, apply dynamic filters, and cluster for visualization. "
|
| 63 |
+
"Filters are automatically generated based on your data columns."
|
| 64 |
+
)
|
| 65 |
|
| 66 |
# Row 1: File loading
|
| 67 |
render_file_section()
|
|
|
|
| 82 |
with col_preview:
|
| 83 |
render_data_preview()
|
| 84 |
|
| 85 |
+
# Bottom: Taxonomy summary + representative images
|
| 86 |
st.markdown("---")
|
| 87 |
render_clustering_summary(show_taxonomy=True)
|
| 88 |
+
render_cluster_representatives()
|
| 89 |
+
|
| 90 |
+
# Demo-only attribution / funding footer.
|
| 91 |
+
if is_demo_mode():
|
| 92 |
+
render_demo_footer()
|
| 93 |
|
| 94 |
|
| 95 |
if __name__ == "__main__":
|
apps/precalculated/components/data_preview.py
CHANGED
|
@@ -6,80 +6,21 @@ Dynamically displays all available metadata fields.
|
|
| 6 |
import streamlit as st
|
| 7 |
import pandas as pd
|
| 8 |
import numpy as np
|
| 9 |
-
import requests
|
| 10 |
-
import time
|
| 11 |
-
from typing import Optional
|
| 12 |
-
from PIL import Image
|
| 13 |
-
from io import BytesIO
|
| 14 |
|
| 15 |
from shared.utils.logging_config import get_logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
logger = get_logger(__name__)
|
| 18 |
|
| 19 |
|
| 20 |
-
@st.cache_data(ttl=300, show_spinner=False)
|
| 21 |
-
def _fetch_image_from_url_cached(url: str, timeout: int = 5) -> Optional[bytes]:
|
| 22 |
-
"""Internal cached function to fetch image bytes."""
|
| 23 |
-
if not url or not isinstance(url, str):
|
| 24 |
-
return None
|
| 25 |
-
|
| 26 |
-
try:
|
| 27 |
-
if not url.startswith(('http://', 'https://')):
|
| 28 |
-
return None
|
| 29 |
-
|
| 30 |
-
response = requests.get(url, timeout=timeout, stream=True)
|
| 31 |
-
response.raise_for_status()
|
| 32 |
-
|
| 33 |
-
content_type = response.headers.get('content-type', '').lower()
|
| 34 |
-
if not content_type.startswith('image/'):
|
| 35 |
-
return None
|
| 36 |
-
|
| 37 |
-
return response.content
|
| 38 |
-
|
| 39 |
-
except Exception:
|
| 40 |
-
return None
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def fetch_image_from_url(url: str, timeout: int = 5) -> Optional[bytes]:
|
| 44 |
-
"""
|
| 45 |
-
Fetch an image from a URL with logging.
|
| 46 |
-
Uses caching internally but logs the request.
|
| 47 |
-
"""
|
| 48 |
-
if not url or not isinstance(url, str):
|
| 49 |
-
return None
|
| 50 |
-
|
| 51 |
-
if not url.startswith(('http://', 'https://')):
|
| 52 |
-
logger.warning(f"[Image] Invalid URL scheme: {url[:50]}...")
|
| 53 |
-
return None
|
| 54 |
-
|
| 55 |
-
logger.info(f"[Image] Fetching: {url[:80]}...")
|
| 56 |
-
start_time = time.time()
|
| 57 |
-
|
| 58 |
-
result = _fetch_image_from_url_cached(url, timeout)
|
| 59 |
-
|
| 60 |
-
elapsed = time.time() - start_time
|
| 61 |
-
if result:
|
| 62 |
-
logger.info(f"[Image] Loaded: {len(result)/1024:.1f}KB in {elapsed:.3f}s")
|
| 63 |
-
else:
|
| 64 |
-
logger.warning(f"[Image] Failed to load: {url[:50]}...")
|
| 65 |
-
|
| 66 |
-
return result
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def get_image_from_url(url: str) -> Optional[Image.Image]:
|
| 70 |
-
"""Get image from URL with caching and logging."""
|
| 71 |
-
image_bytes = fetch_image_from_url(url)
|
| 72 |
-
if image_bytes:
|
| 73 |
-
try:
|
| 74 |
-
image = Image.open(BytesIO(image_bytes))
|
| 75 |
-
logger.info(f"[Image] Opened: {image.size[0]}x{image.size[1]} {image.mode}")
|
| 76 |
-
return image
|
| 77 |
-
except Exception as e:
|
| 78 |
-
logger.error(f"[Image] Failed to open: {e}")
|
| 79 |
-
return None
|
| 80 |
-
return None
|
| 81 |
-
|
| 82 |
-
|
| 83 |
def render_data_preview():
|
| 84 |
"""Render the data preview panel (record details on point click)."""
|
| 85 |
df_plot = st.session_state.get("data", None)
|
|
@@ -110,15 +51,12 @@ def render_data_preview():
|
|
| 110 |
|
| 111 |
st.markdown("### Record Details")
|
| 112 |
|
| 113 |
-
# Try to display image if
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
image =
|
| 119 |
-
if image is not None:
|
| 120 |
-
st.image(image, width=280)
|
| 121 |
-
break
|
| 122 |
|
| 123 |
st.markdown(f"**UUID:** `{selected_uuid}`")
|
| 124 |
|
|
@@ -276,3 +214,85 @@ def render_cluster_analysis():
|
|
| 276 |
st.code(tree_output, language="text")
|
| 277 |
else:
|
| 278 |
st.info(f"No valid '{color_by}' values to compare with KMeans clusters.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import streamlit as st
|
| 7 |
import pandas as pd
|
| 8 |
import numpy as np
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
from shared.utils.logging_config import get_logger
|
| 11 |
+
from shared.utils.representatives import find_cluster_representatives
|
| 12 |
+
from shared.utils.images import (
|
| 13 |
+
IMAGE_URL_COLUMNS,
|
| 14 |
+
fetch_images_concurrent,
|
| 15 |
+
get_image_from_url,
|
| 16 |
+
resolve_record_image_url,
|
| 17 |
+
_IMAGE_CACHE,
|
| 18 |
+
)
|
| 19 |
+
from shared.components.representatives import render_representative_images
|
| 20 |
|
| 21 |
logger = get_logger(__name__)
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def render_data_preview():
|
| 25 |
"""Render the data preview panel (record details on point click)."""
|
| 26 |
df_plot = st.session_state.get("data", None)
|
|
|
|
| 51 |
|
| 52 |
st.markdown("### Record Details")
|
| 53 |
|
| 54 |
+
# Try to display image if an image URL column exists (process-cached).
|
| 55 |
+
url = resolve_record_image_url(record)
|
| 56 |
+
if url:
|
| 57 |
+
image = get_image_from_url(url)
|
| 58 |
+
if image is not None:
|
| 59 |
+
st.image(image, width=280)
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
st.markdown(f"**UUID:** `{selected_uuid}`")
|
| 62 |
|
|
|
|
| 214 |
st.code(tree_output, language="text")
|
| 215 |
else:
|
| 216 |
st.info(f"No valid '{color_by}' values to compare with KMeans clusters.")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def render_cluster_representatives():
|
| 220 |
+
"""Render representative images per KMeans cluster for the precalculated app.
|
| 221 |
+
|
| 222 |
+
Representatives are the members closest to each cluster centroid (computed
|
| 223 |
+
on the full-dimensional embeddings). Images are fetched from each record's
|
| 224 |
+
URL column; URLs that fail to load are skipped and the next-closest
|
| 225 |
+
candidate is tried (fallback), so transient/broken URLs don't leave gaps.
|
| 226 |
+
"""
|
| 227 |
+
df_plot = st.session_state.get("data", None)
|
| 228 |
+
embeddings = st.session_state.get("embeddings", None)
|
| 229 |
+
if df_plot is None or embeddings is None:
|
| 230 |
+
return
|
| 231 |
+
|
| 232 |
+
kmeans_cols = sorted(
|
| 233 |
+
[c for c in df_plot.columns if c.startswith("KMeans (k=")],
|
| 234 |
+
key=lambda c: int(c.split("=")[1].rstrip(")")),
|
| 235 |
+
)
|
| 236 |
+
if not kmeans_cols:
|
| 237 |
+
return # nothing to show until a KMeans run exists
|
| 238 |
+
|
| 239 |
+
st.markdown("### Representative Images")
|
| 240 |
+
st.caption(
|
| 241 |
+
"Members closest to each cluster centroid. Images load from each "
|
| 242 |
+
"record's URL; unreachable images are skipped automatically."
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
selected_col = st.selectbox(
|
| 246 |
+
"KMeans result",
|
| 247 |
+
options=kmeans_cols,
|
| 248 |
+
index=len(kmeans_cols) - 1,
|
| 249 |
+
key="representatives_kmeans_selector",
|
| 250 |
+
help="Which KMeans run to show representatives for.",
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
# Guard: embeddings must align row-for-row with df_plot.
|
| 254 |
+
if len(embeddings) != len(df_plot):
|
| 255 |
+
st.info("Re-run projection and KMeans to view representatives.")
|
| 256 |
+
return
|
| 257 |
+
|
| 258 |
+
n_per_cluster = 3
|
| 259 |
+
representatives = find_cluster_representatives(
|
| 260 |
+
embeddings, df_plot[selected_col].values, n_per_cluster=n_per_cluster
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# Warm the cache concurrently. Representatives are oversampled for fallback,
|
| 264 |
+
# but we only need a few successes per cluster — prefetch a prefix (2x the
|
| 265 |
+
# display count) in parallel. Deeper fallback candidates (rare) resolve
|
| 266 |
+
# on-demand below.
|
| 267 |
+
prefetch_per_cluster = n_per_cluster * 2
|
| 268 |
+
prefetch_urls = [
|
| 269 |
+
resolve_record_image_url(df_plot.iloc[idx])
|
| 270 |
+
for idxs in representatives.values()
|
| 271 |
+
for idx in idxs[:prefetch_per_cluster]
|
| 272 |
+
]
|
| 273 |
+
with st.spinner("Loading representative images..."):
|
| 274 |
+
fetch_images_concurrent([u for u in prefetch_urls if u])
|
| 275 |
+
|
| 276 |
+
def _resolve(idx):
|
| 277 |
+
url = resolve_record_image_url(df_plot.iloc[idx])
|
| 278 |
+
if not url:
|
| 279 |
+
return None
|
| 280 |
+
# Prefetched URLs hit the process cache; anything deeper falls back to
|
| 281 |
+
# a single synchronous fetch (also cached).
|
| 282 |
+
if url in _IMAGE_CACHE:
|
| 283 |
+
return _IMAGE_CACHE[url]
|
| 284 |
+
return get_image_from_url(url)
|
| 285 |
+
|
| 286 |
+
def _caption(idx):
|
| 287 |
+
row = df_plot.iloc[idx]
|
| 288 |
+
for col in ("scientific_name", "species", "common_name", "uuid"):
|
| 289 |
+
if col in row.index and pd.notna(row[col]):
|
| 290 |
+
return str(row[col])
|
| 291 |
+
return None
|
| 292 |
+
|
| 293 |
+
render_representative_images(
|
| 294 |
+
representatives,
|
| 295 |
+
resolve_image=_resolve,
|
| 296 |
+
n_per_cluster=n_per_cluster,
|
| 297 |
+
caption_fn=_caption,
|
| 298 |
+
)
|
shared/__init__.py
CHANGED
|
@@ -2,4 +2,11 @@
|
|
| 2 |
Shared utilities and services for the emb-explorer applications.
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
Shared utilities and services for the emb-explorer applications.
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
from importlib.metadata import PackageNotFoundError, version as _version
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
# Single source of truth: the version declared in pyproject.toml,
|
| 9 |
+
# read from the installed package metadata.
|
| 10 |
+
__version__ = _version("emb-explorer")
|
| 11 |
+
except PackageNotFoundError: # running from a source tree without an install
|
| 12 |
+
__version__ = "0.0.0+unknown"
|
shared/components/demo_chrome.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Demo-only page chrome (header + footer) for the hosted Hugging Face Space.
|
| 2 |
+
|
| 3 |
+
Rendered only when ``EMB_EXPLORER_DEMO=1`` (set by the Space Dockerfile), so the
|
| 4 |
+
normal local apps are unaffected. Kept additive and self-contained so it merges
|
| 5 |
+
cleanly across branches.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
import streamlit as st
|
| 11 |
+
|
| 12 |
+
REPO_URL = "https://github.com/Imageomics/emb-explorer"
|
| 13 |
+
EMBEDDINGS_DATASET_URL = "https://huggingface.co/datasets/imageomics/TreeOfLife-200M-Embeddings"
|
| 14 |
+
BIOCLIP2_URL = "https://huggingface.co/imageomics/bioclip-2"
|
| 15 |
+
BIOCLIP2_SITE_URL = "https://imageomics.github.io/bioclip-2/"
|
| 16 |
+
PYBIOCLIP_URL = "https://github.com/Imageomics/pybioclip"
|
| 17 |
+
TOL200M_URL = "https://huggingface.co/datasets/imageomics/TreeOfLife-200M"
|
| 18 |
+
IMAGEOMICS_URL = "https://imageomics.org"
|
| 19 |
+
NSF_AWARD_URL = "https://www.nsf.gov/awardsearch/showAward?AWD_ID=2118240"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def is_demo_mode() -> bool:
|
| 23 |
+
"""True when running as the hosted demo (the Space sets EMB_EXPLORER_DEMO=1)."""
|
| 24 |
+
return os.environ.get("EMB_EXPLORER_DEMO", "0") == "1"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def render_demo_header() -> None:
|
| 28 |
+
"""Demo title + one-line plain-text intro (no links; links live in the footer)."""
|
| 29 |
+
st.title("🔍 Image Embedding Explorer (Demo)")
|
| 30 |
+
st.markdown(
|
| 31 |
+
"A hosted demo of the Image Embedding Explorer. Explore precalculated "
|
| 32 |
+
"BioCLIP 2 embeddings from a curated subset of the TreeOfLife-200M image "
|
| 33 |
+
"collection."
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_FOOTER_CSS = """
|
| 38 |
+
<style>
|
| 39 |
+
.demo-footer { margin-top: 2rem; padding-top: 1rem;
|
| 40 |
+
border-top: 1px solid rgba(128, 128, 128, 0.3);
|
| 41 |
+
opacity: 0.6; font-size: 0.82rem; line-height: 1.5; }
|
| 42 |
+
.demo-footer:hover { opacity: 0.9; }
|
| 43 |
+
.demo-footer a { color: #3f9b6e; text-decoration: none; }
|
| 44 |
+
.demo-footer a:hover { text-decoration: underline; }
|
| 45 |
+
</style>
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def render_demo_footer() -> None:
|
| 50 |
+
"""Muted, bordered attribution / funding footer (Imageomics standard text).
|
| 51 |
+
|
| 52 |
+
Adopts the bioclip-image-search footer, with the emb-explorer source repo
|
| 53 |
+
and the TreeOfLife-200M-Embeddings dataset added.
|
| 54 |
+
"""
|
| 55 |
+
st.markdown(_FOOTER_CSS, unsafe_allow_html=True)
|
| 56 |
+
st.markdown(
|
| 57 |
+
'<div class="demo-footer">'
|
| 58 |
+
f'This demo is built with the <a href="{REPO_URL}">emb-explorer</a> source '
|
| 59 |
+
f'repository and explores a curated subset of the '
|
| 60 |
+
f'<a href="{EMBEDDINGS_DATASET_URL}">TreeOfLife-200M-Embeddings</a> dataset. '
|
| 61 |
+
f'For more information on the <a href="{BIOCLIP2_URL}">BioCLIP 2</a> model '
|
| 62 |
+
f'creation, see our <a href="{BIOCLIP2_SITE_URL}">BioCLIP 2 Project website</a>, '
|
| 63 |
+
'and for easier programmatic integration of BioCLIP 2, checkout '
|
| 64 |
+
f'<a href="{PYBIOCLIP_URL}">pybioclip</a>. To learn more about the data, check out '
|
| 65 |
+
f'our <a href="{TOL200M_URL}">TreeOfLife-200M Dataset</a>.'
|
| 66 |
+
'<br><br>'
|
| 67 |
+
f'This work was supported by the <a href="{IMAGEOMICS_URL}">Imageomics Institute</a>, '
|
| 68 |
+
"which is funded by the US National Science Foundation's Harnessing the Data "
|
| 69 |
+
f'Revolution (HDR) program under <a href="{NSF_AWARD_URL}">Award #2118240</a> '
|
| 70 |
+
'(Imageomics: A New Frontier of Biological Information Powered by Knowledge-Guided '
|
| 71 |
+
'Machine Learning). Any opinions, findings and conclusions or recommendations '
|
| 72 |
+
'expressed in this material are those of the author(s) and do not necessarily '
|
| 73 |
+
'reflect the views of the National Science Foundation.'
|
| 74 |
+
'</div>',
|
| 75 |
+
unsafe_allow_html=True,
|
| 76 |
+
)
|
shared/components/representatives.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared renderer for per-cluster representative images.
|
| 2 |
+
|
| 3 |
+
Both apps surface representative images differently:
|
| 4 |
+
- embed_explore resolves a local image file path.
|
| 5 |
+
- precalculated fetches a remote image URL (which can fail).
|
| 6 |
+
|
| 7 |
+
This renderer is source-agnostic: the caller passes a `resolve_image(idx)`
|
| 8 |
+
callable that returns something `st.image` can display (a PIL image, a path,
|
| 9 |
+
or bytes) or `None` when the image is unavailable. The renderer walks each
|
| 10 |
+
cluster's ranked candidate indices and collects up to `n_per_cluster`
|
| 11 |
+
successful images, skipping any that resolve to `None` — the shared fallback.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from typing import Any, Callable, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
import streamlit as st
|
| 17 |
+
|
| 18 |
+
from shared.utils.logging_config import get_logger
|
| 19 |
+
|
| 20 |
+
logger = get_logger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _sorted_cluster_ids(representatives: Dict[object, List[int]]) -> List[object]:
|
| 24 |
+
"""Sort cluster ids numerically when possible, else as strings."""
|
| 25 |
+
keys = list(representatives.keys())
|
| 26 |
+
try:
|
| 27 |
+
return sorted(keys, key=lambda k: int(k))
|
| 28 |
+
except (ValueError, TypeError):
|
| 29 |
+
return sorted(keys, key=str)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def render_representative_images(
|
| 33 |
+
representatives: Dict[object, List[int]],
|
| 34 |
+
resolve_image: Callable[[int], Optional[Any]],
|
| 35 |
+
n_per_cluster: int = 3,
|
| 36 |
+
caption_fn: Optional[Callable[[int], str]] = None,
|
| 37 |
+
columns: int = 3,
|
| 38 |
+
) -> None:
|
| 39 |
+
"""Render up to `n_per_cluster` representative images per cluster.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
representatives: {cluster_id: [ranked candidate global indices]}, as
|
| 43 |
+
returned by `find_cluster_representatives`.
|
| 44 |
+
resolve_image: idx -> displayable (PIL image / path / bytes) or None.
|
| 45 |
+
None means "unavailable" and the renderer falls back to the next
|
| 46 |
+
candidate.
|
| 47 |
+
n_per_cluster: number of images to show per cluster.
|
| 48 |
+
caption_fn: optional idx -> caption string.
|
| 49 |
+
columns: images per row.
|
| 50 |
+
"""
|
| 51 |
+
for cluster_id in _sorted_cluster_ids(representatives):
|
| 52 |
+
candidates = representatives[cluster_id]
|
| 53 |
+
st.markdown(f"**Cluster {cluster_id}**")
|
| 54 |
+
|
| 55 |
+
# Walk ranked candidates, collecting successful resolutions until we
|
| 56 |
+
# have n_per_cluster (or run out of candidates).
|
| 57 |
+
shown: List[tuple] = [] # (displayable, caption)
|
| 58 |
+
for idx in candidates:
|
| 59 |
+
if len(shown) >= n_per_cluster:
|
| 60 |
+
break
|
| 61 |
+
try:
|
| 62 |
+
img = resolve_image(idx)
|
| 63 |
+
except Exception as e: # never let one bad image break the panel
|
| 64 |
+
logger.debug(f"resolve_image({idx}) raised: {e}")
|
| 65 |
+
img = None
|
| 66 |
+
if img is not None:
|
| 67 |
+
caption = caption_fn(idx) if caption_fn else None
|
| 68 |
+
shown.append((img, caption))
|
| 69 |
+
|
| 70 |
+
if not shown:
|
| 71 |
+
st.caption("No images available for this cluster.")
|
| 72 |
+
continue
|
| 73 |
+
|
| 74 |
+
cols = st.columns(min(columns, len(shown)))
|
| 75 |
+
for i, (img, caption) in enumerate(shown):
|
| 76 |
+
cols[i % len(cols)].image(img, caption=caption, width="stretch")
|
shared/components/summary.py
CHANGED
|
@@ -6,6 +6,7 @@ import streamlit as st
|
|
| 6 |
import os
|
| 7 |
import pandas as pd
|
| 8 |
from shared.utils.taxonomy_tree import build_taxonomic_tree, format_tree_string, get_tree_statistics
|
|
|
|
| 9 |
from shared.utils.logging_config import get_logger
|
| 10 |
|
| 11 |
logger = get_logger(__name__)
|
|
@@ -144,47 +145,80 @@ def render_taxonomic_tree_summary():
|
|
| 144 |
|
| 145 |
|
| 146 |
def render_clustering_summary(show_taxonomy=False):
|
| 147 |
-
"""Render the clustering summary panel using cached results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
df_plot = st.session_state.get("data", None)
|
| 149 |
-
labels = st.session_state.get("labels", None)
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
has_images = 'image_path' in df_plot.columns
|
| 157 |
-
|
| 158 |
-
if has_images:
|
| 159 |
-
# embed_explore app: show full clustering summary with representative images
|
| 160 |
-
if labels is not None:
|
| 161 |
-
st.subheader("Clustering Summary")
|
| 162 |
-
|
| 163 |
-
if summary_df is not None and representatives is not None:
|
| 164 |
-
logger.debug("Displaying cached clustering summary")
|
| 165 |
-
st.dataframe(summary_df, hide_index=True, width='stretch')
|
| 166 |
-
|
| 167 |
-
st.markdown("#### Representative Images")
|
| 168 |
-
for row in summary_df.itertuples():
|
| 169 |
-
k = row.Cluster
|
| 170 |
-
st.markdown(f"**Cluster {k}**")
|
| 171 |
-
img_cols = st.columns(3)
|
| 172 |
-
for i, img_idx in enumerate(representatives[k]):
|
| 173 |
-
img_path = df_plot.iloc[img_idx]["image_path"]
|
| 174 |
-
logger.debug(f"Displaying representative image: {img_path}")
|
| 175 |
-
img_cols[i].image(
|
| 176 |
-
img_path,
|
| 177 |
-
width='stretch',
|
| 178 |
-
caption=os.path.basename(img_path)
|
| 179 |
-
)
|
| 180 |
-
else:
|
| 181 |
-
st.info("Clustering summary will be computed when you run clustering.")
|
| 182 |
-
else:
|
| 183 |
-
# Precalculated app: show taxonomy tree (works with or without KMeans)
|
| 184 |
-
if show_taxonomy:
|
| 185 |
-
filtered_df = st.session_state.get("filtered_df_for_clustering", None)
|
| 186 |
-
if filtered_df is not None:
|
| 187 |
-
render_taxonomic_tree_summary()
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
else:
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import os
|
| 7 |
import pandas as pd
|
| 8 |
from shared.utils.taxonomy_tree import build_taxonomic_tree, format_tree_string, get_tree_statistics
|
| 9 |
+
from shared.components.representatives import render_representative_images
|
| 10 |
from shared.utils.logging_config import get_logger
|
| 11 |
|
| 12 |
logger = get_logger(__name__)
|
|
|
|
| 145 |
|
| 146 |
|
| 147 |
def render_clustering_summary(show_taxonomy=False):
|
| 148 |
+
"""Render the clustering summary panel using cached results per KMeans run.
|
| 149 |
+
|
| 150 |
+
For the embed_explore app, when multiple KMeans runs exist on df_plot,
|
| 151 |
+
the user can pick which run's summary + representative images to display.
|
| 152 |
+
Summaries are cached per kmeans_col by `_run_kmeans` so switching is instant.
|
| 153 |
+
"""
|
| 154 |
df_plot = st.session_state.get("data", None)
|
|
|
|
| 155 |
|
| 156 |
+
if df_plot is None:
|
| 157 |
+
st.info("Summary will appear here after projection.")
|
| 158 |
+
return
|
| 159 |
+
|
| 160 |
+
has_images = 'image_path' in df_plot.columns
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
+
if has_images:
|
| 163 |
+
# embed_explore app: full clustering summary with representative images
|
| 164 |
+
kmeans_cols = sorted(
|
| 165 |
+
[c for c in df_plot.columns if c.startswith("KMeans (k=")],
|
| 166 |
+
key=lambda c: int(c.split("=")[1].rstrip(")")),
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
if not kmeans_cols:
|
| 170 |
+
st.subheader("Clustering Summary")
|
| 171 |
+
st.info("Run KMeans to see the clustering summary and representative images.")
|
| 172 |
+
return
|
| 173 |
+
|
| 174 |
+
summaries = st.session_state.get("clustering_summaries", {}) or {}
|
| 175 |
+
reps_by_col = st.session_state.get("clustering_representatives_by_col", {}) or {}
|
| 176 |
+
|
| 177 |
+
st.subheader("Clustering Summary")
|
| 178 |
+
default_idx = len(kmeans_cols) - 1 # most recent run
|
| 179 |
+
selected_kmeans_col = st.selectbox(
|
| 180 |
+
"KMeans result",
|
| 181 |
+
options=kmeans_cols,
|
| 182 |
+
index=default_idx,
|
| 183 |
+
key="summary_kmeans_selector",
|
| 184 |
+
help="Select which KMeans run to view summary + representative images for.",
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
summary_df = summaries.get(selected_kmeans_col)
|
| 188 |
+
representatives = reps_by_col.get(selected_kmeans_col)
|
| 189 |
+
|
| 190 |
+
if summary_df is None or representatives is None:
|
| 191 |
+
st.info(
|
| 192 |
+
f"No cached summary for {selected_kmeans_col}. "
|
| 193 |
+
"Re-run KMeans with this k to regenerate it."
|
| 194 |
+
)
|
| 195 |
+
return
|
| 196 |
+
|
| 197 |
+
logger.debug(f"Displaying cached clustering summary for {selected_kmeans_col}")
|
| 198 |
+
st.dataframe(summary_df, hide_index=True, width='stretch')
|
| 199 |
+
|
| 200 |
+
st.markdown("#### Representative Images")
|
| 201 |
+
|
| 202 |
+
def _resolve_local_image(idx):
|
| 203 |
+
"""Return the local image path if it exists, else None (fallback)."""
|
| 204 |
+
path = df_plot.iloc[idx]["image_path"]
|
| 205 |
+
if isinstance(path, str) and os.path.exists(path):
|
| 206 |
+
return path
|
| 207 |
+
return None
|
| 208 |
+
|
| 209 |
+
def _local_caption(idx):
|
| 210 |
+
path = df_plot.iloc[idx]["image_path"]
|
| 211 |
+
return os.path.basename(path) if isinstance(path, str) else None
|
| 212 |
+
|
| 213 |
+
render_representative_images(
|
| 214 |
+
representatives,
|
| 215 |
+
resolve_image=_resolve_local_image,
|
| 216 |
+
n_per_cluster=3,
|
| 217 |
+
caption_fn=_local_caption,
|
| 218 |
+
)
|
| 219 |
else:
|
| 220 |
+
# Precalculated app: show taxonomy tree (works with or without KMeans)
|
| 221 |
+
if show_taxonomy:
|
| 222 |
+
filtered_df = st.session_state.get("filtered_df_for_clustering", None)
|
| 223 |
+
if filtered_df is not None:
|
| 224 |
+
render_taxonomic_tree_summary()
|
shared/components/visualization.py
CHANGED
|
@@ -77,54 +77,51 @@ def _render_chart_fragment(df_plot):
|
|
| 77 |
else:
|
| 78 |
heatmap_bins = 40 # Default, not used
|
| 79 |
|
| 80 |
-
# Determine color column
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
)
|
| 93 |
-
|
| 94 |
-
colorable_cols = kmeans_cols + other_cols
|
| 95 |
-
|
| 96 |
-
# Build unique count lookup for display
|
| 97 |
-
col_nunique = {c: df_plot[c].nunique() for c in colorable_cols}
|
| 98 |
-
|
| 99 |
-
if colorable_cols:
|
| 100 |
-
color_col = st.selectbox(
|
| 101 |
-
"Color by",
|
| 102 |
-
options=["(none)"] + colorable_cols,
|
| 103 |
-
index=0,
|
| 104 |
-
key="color_by_column",
|
| 105 |
-
format_func=lambda c: c if c == "(none)" else f"{c} ({col_nunique[c]})",
|
| 106 |
-
help="Select a column to color the points by"
|
| 107 |
-
)
|
| 108 |
-
if color_col == "(none)":
|
| 109 |
-
color_col = None
|
| 110 |
-
else:
|
| 111 |
color_col = None
|
| 112 |
-
|
| 113 |
-
# Warning for high cardinality
|
| 114 |
-
if color_col and df_plot[color_col].nunique() > 20:
|
| 115 |
-
st.warning(f"'{color_col}' has {df_plot[color_col].nunique()} unique values. Colors may repeat.")
|
| 116 |
-
|
| 117 |
-
# Trigger full page rerun when color changes (so bottom section updates).
|
| 118 |
-
# Use a sentinel to distinguish "never set" from "set to None".
|
| 119 |
-
_sentinel = object()
|
| 120 |
-
prev_color = st.session_state.get("_prev_color_by", _sentinel)
|
| 121 |
-
if color_col != prev_color:
|
| 122 |
-
st.session_state["_prev_color_by"] = color_col
|
| 123 |
-
if prev_color is not _sentinel:
|
| 124 |
-
st.rerun(scope="app")
|
| 125 |
else:
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
point_selector = alt.selection_point(fields=["idx"], name="point_selection")
|
| 130 |
|
|
@@ -133,13 +130,11 @@ def _render_chart_fragment(df_plot):
|
|
| 133 |
skip_cols = {'x', 'y', 'idx', 'emb', 'embedding', 'embeddings', 'vector',
|
| 134 |
'uuid', 'identifier', 'image_url', 'url', 'img_url', 'image'}
|
| 135 |
|
| 136 |
-
# For embed_explore, include
|
| 137 |
-
if not is_precalculated:
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
tooltip_fields.append('cluster:N')
|
| 142 |
-
skip_cols.update({'cluster', 'cluster_name'})
|
| 143 |
|
| 144 |
# Add the color column first if set (and not already in tooltip)
|
| 145 |
if color_col and color_col not in skip_cols:
|
|
|
|
| 77 |
else:
|
| 78 |
heatmap_bins = 40 # Default, not used
|
| 79 |
|
| 80 |
+
# Determine color column — same dropdown pattern for both apps.
|
| 81 |
+
# Build list of colorable columns (skip technical/identifier columns).
|
| 82 |
+
skip_color_cols = {'x', 'y', 'idx', 'uuid', 'emb', 'embedding', 'embeddings', 'vector',
|
| 83 |
+
'identifier', 'image_url', 'url', 'img_url', 'image',
|
| 84 |
+
'image_path', 'file_name'}
|
| 85 |
+
colorable_cols = [c for c in df_plot.columns
|
| 86 |
+
if c not in skip_color_cols and df_plot[c].nunique() <= 100]
|
| 87 |
+
|
| 88 |
+
# Sort KMeans columns to front (all runs, sorted by k)
|
| 89 |
+
kmeans_cols = sorted(
|
| 90 |
+
[c for c in colorable_cols if c.startswith("KMeans (k=")],
|
| 91 |
+
key=lambda c: int(c.split("=")[1].rstrip(")"))
|
| 92 |
+
)
|
| 93 |
+
other_cols = [c for c in colorable_cols if not c.startswith("KMeans (k=")]
|
| 94 |
+
colorable_cols = kmeans_cols + other_cols
|
| 95 |
+
|
| 96 |
+
# Build unique count lookup for display
|
| 97 |
+
col_nunique = {c: df_plot[c].nunique() for c in colorable_cols}
|
| 98 |
+
|
| 99 |
+
if colorable_cols:
|
| 100 |
+
color_col = st.selectbox(
|
| 101 |
+
"Color by",
|
| 102 |
+
options=["(none)"] + colorable_cols,
|
| 103 |
+
index=0,
|
| 104 |
+
key="color_by_column",
|
| 105 |
+
format_func=lambda c: c if c == "(none)" else f"{c} ({col_nunique[c]})",
|
| 106 |
+
help="Select a column to color the points by"
|
| 107 |
)
|
| 108 |
+
if color_col == "(none)":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
color_col = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
else:
|
| 111 |
+
color_col = None
|
| 112 |
+
|
| 113 |
+
# Warning for high cardinality
|
| 114 |
+
if color_col and df_plot[color_col].nunique() > 20:
|
| 115 |
+
st.warning(f"'{color_col}' has {df_plot[color_col].nunique()} unique values. Colors may repeat.")
|
| 116 |
+
|
| 117 |
+
# Trigger full page rerun when color changes (so bottom section updates).
|
| 118 |
+
# Use a sentinel to distinguish "never set" from "set to None".
|
| 119 |
+
_sentinel = object()
|
| 120 |
+
prev_color = st.session_state.get("_prev_color_by", _sentinel)
|
| 121 |
+
if color_col != prev_color:
|
| 122 |
+
st.session_state["_prev_color_by"] = color_col
|
| 123 |
+
if prev_color is not _sentinel:
|
| 124 |
+
st.rerun(scope="app")
|
| 125 |
|
| 126 |
point_selector = alt.selection_point(fields=["idx"], name="point_selection")
|
| 127 |
|
|
|
|
| 130 |
skip_cols = {'x', 'y', 'idx', 'emb', 'embedding', 'embeddings', 'vector',
|
| 131 |
'uuid', 'identifier', 'image_url', 'url', 'img_url', 'image'}
|
| 132 |
|
| 133 |
+
# For embed_explore, include the file_name in the tooltip for quick reference
|
| 134 |
+
if not is_precalculated and 'file_name' in df_plot.columns:
|
| 135 |
+
tooltip_fields.append('file_name:N')
|
| 136 |
+
skip_cols.add('file_name')
|
| 137 |
+
skip_cols.add('image_path')
|
|
|
|
|
|
|
| 138 |
|
| 139 |
# Add the color column first if set (and not already in tooltip)
|
| 140 |
if color_col and color_col not in skip_cols:
|
shared/services/clustering_service.py
CHANGED
|
@@ -196,25 +196,21 @@ class ClusteringService:
|
|
| 196 |
Returns:
|
| 197 |
Tuple of (summary dataframe, representatives dict)
|
| 198 |
"""
|
|
|
|
|
|
|
| 199 |
logger.info("Generating clustering summary statistics")
|
| 200 |
cluster_ids = np.unique(labels)
|
| 201 |
logger.debug(f"Found {len(cluster_ids)} unique clusters")
|
| 202 |
-
summary_data = []
|
| 203 |
-
representatives = {}
|
| 204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
for k in cluster_ids:
|
| 206 |
idxs = np.where(labels == k)[0]
|
| 207 |
cluster_embeds = embeddings[idxs]
|
| 208 |
centroid = cluster_embeds.mean(axis=0)
|
| 209 |
-
|
| 210 |
-
# Internal variance
|
| 211 |
variance = np.mean(np.sum((cluster_embeds - centroid) ** 2, axis=1))
|
| 212 |
-
|
| 213 |
-
# Find 3 closest images
|
| 214 |
-
dists = np.sum((cluster_embeds - centroid) ** 2, axis=1)
|
| 215 |
-
closest_indices = idxs[np.argsort(dists)[:3]]
|
| 216 |
-
representatives[k] = closest_indices
|
| 217 |
-
|
| 218 |
summary_data.append({
|
| 219 |
"Cluster": int(k),
|
| 220 |
"Count": len(idxs),
|
|
|
|
| 196 |
Returns:
|
| 197 |
Tuple of (summary dataframe, representatives dict)
|
| 198 |
"""
|
| 199 |
+
from shared.utils.representatives import find_cluster_representatives
|
| 200 |
+
|
| 201 |
logger.info("Generating clustering summary statistics")
|
| 202 |
cluster_ids = np.unique(labels)
|
| 203 |
logger.debug(f"Found {len(cluster_ids)} unique clusters")
|
|
|
|
|
|
|
| 204 |
|
| 205 |
+
# Ranked representative candidates per cluster (shared utility).
|
| 206 |
+
representatives = find_cluster_representatives(embeddings, labels)
|
| 207 |
+
|
| 208 |
+
summary_data = []
|
| 209 |
for k in cluster_ids:
|
| 210 |
idxs = np.where(labels == k)[0]
|
| 211 |
cluster_embeds = embeddings[idxs]
|
| 212 |
centroid = cluster_embeds.mean(axis=0)
|
|
|
|
|
|
|
| 213 |
variance = np.mean(np.sum((cluster_embeds - centroid) ** 2, axis=1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
summary_data.append({
|
| 215 |
"Cluster": int(k),
|
| 216 |
"Count": len(idxs),
|
shared/services/embedding_service.py
CHANGED
|
@@ -3,8 +3,68 @@ Embedding generation service.
|
|
| 3 |
|
| 4 |
Heavy libraries (torch, open_clip) are imported lazily inside methods
|
| 5 |
to avoid slowing down app startup.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
|
|
|
| 8 |
import numpy as np
|
| 9 |
import streamlit as st
|
| 10 |
import time
|
|
@@ -79,90 +139,96 @@ class EmbeddingService:
|
|
| 79 |
model_name: str,
|
| 80 |
batch_size: int,
|
| 81 |
n_workers: int,
|
| 82 |
-
progress_callback: Optional[Callable[[float, str], None]] = None
|
|
|
|
| 83 |
) -> Tuple[np.ndarray, List[str]]:
|
| 84 |
"""
|
| 85 |
Generate embeddings for images in a directory.
|
| 86 |
|
|
|
|
|
|
|
|
|
|
| 87 |
Args:
|
| 88 |
image_dir: Path to directory containing images
|
| 89 |
model_name: Name of the model to use
|
| 90 |
-
batch_size: Batch size for
|
| 91 |
-
n_workers:
|
| 92 |
progress_callback: Optional callback for progress updates
|
|
|
|
| 93 |
|
| 94 |
Returns:
|
| 95 |
Tuple of (embeddings array, list of valid image paths)
|
| 96 |
"""
|
| 97 |
import torch
|
| 98 |
-
from
|
| 99 |
|
| 100 |
logger.info(f"Starting embedding generation: dir={image_dir}, model={model_name}, "
|
| 101 |
-
f"batch_size={batch_size}, n_workers={n_workers}")
|
| 102 |
total_start = time.time()
|
| 103 |
|
| 104 |
if progress_callback:
|
| 105 |
progress_callback(0.0, "Listing images...")
|
| 106 |
|
| 107 |
-
image_paths = list_image_files(image_dir)
|
| 108 |
-
|
|
|
|
| 109 |
|
| 110 |
if progress_callback:
|
| 111 |
-
progress_callback(0.
|
| 112 |
|
| 113 |
torch_device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
|
|
| 114 |
logger.info(f"Using device: {torch_device}")
|
| 115 |
model, preprocess = EmbeddingService.load_model_unified(model_name, torch_device)
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
preprocess
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
num_workers=n_workers,
|
| 135 |
-
pin_memory=True
|
| 136 |
-
)
|
| 137 |
|
| 138 |
-
total = len(image_paths)
|
| 139 |
-
valid_paths = []
|
| 140 |
-
embeddings = []
|
| 141 |
-
|
| 142 |
-
processed = 0
|
| 143 |
-
with torch.no_grad():
|
| 144 |
-
for batch_paths, batch_imgs in dataloader:
|
| 145 |
-
batch_imgs = batch_imgs.to(torch_device, non_blocking=True)
|
| 146 |
-
batch_embeds = model.encode_image(batch_imgs).cpu().numpy()
|
| 147 |
-
embeddings.append(batch_embeds)
|
| 148 |
-
valid_paths.extend(batch_paths)
|
| 149 |
-
processed += len(batch_paths)
|
| 150 |
-
|
| 151 |
-
if progress_callback:
|
| 152 |
-
progress = 0.2 + (processed / total) * 0.8 # Use 20% to 100% for actual processing
|
| 153 |
-
progress_callback(progress, f"Embedding {processed}/{total}")
|
| 154 |
-
|
| 155 |
-
# Stack embeddings if available
|
| 156 |
-
if embeddings:
|
| 157 |
-
embeddings = np.vstack(embeddings)
|
| 158 |
else:
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
if progress_callback:
|
| 162 |
progress_callback(1.0, f"Complete! Generated {embeddings.shape[0]} embeddings")
|
| 163 |
|
| 164 |
total_elapsed = time.time() - total_start
|
| 165 |
-
|
| 166 |
-
|
|
|
|
| 167 |
|
| 168 |
return embeddings, valid_paths
|
|
|
|
| 3 |
|
| 4 |
Heavy libraries (torch, open_clip) are imported lazily inside methods
|
| 5 |
to avoid slowing down app startup.
|
| 6 |
+
|
| 7 |
+
Device-aware concurrency:
|
| 8 |
+
|
| 9 |
+
PyTorch has two kinds of parallelism built in, we focus on the intra-op
|
| 10 |
+
parallelism which is relevant to the embedding pipeline:
|
| 11 |
+
|
| 12 |
+
Intra-op is the parallelism inside a single operation. One op, say
|
| 13 |
+
`Normalize` on a `[3, 244, 244]` tensor, or a big matrix multiply, splits its
|
| 14 |
+
own work across multiple threads (via an openMP/MKL thread pool).
|
| 15 |
+
`torch.get_num_threads()` queries how many threads one op may use, and
|
| 16 |
+
`torch.set_num_threads(n)` sets it.
|
| 17 |
+
|
| 18 |
+
A single `preprocess(img)` is a chain of torch ops (resize -> to_tensor ->
|
| 19 |
+
normalize). With the default intra-op thread settings, each of those ops can
|
| 20 |
+
fan its work out across all CPU cores. So ONE preprocess call of one image
|
| 21 |
+
can momentarily spin up ~`cpu_count` threads to do that tiny bit of math.
|
| 22 |
+
|
| 23 |
+
^^^ Why that's wasteful here?
|
| 24 |
+
|
| 25 |
+
Since we already have our own parallelism layer at image level: the
|
| 26 |
+
`ThreadPoolExecutor` runs `workers` threads, one image per thread, and each
|
| 27 |
+
thread calls `preprocess(img)`. If each preprocess call fans out across all
|
| 28 |
+
CPU cores, then `workers` threads can easily oversubscribe the CPU with
|
| 29 |
+
`workers * cpu_count` threads. This causes contention and can actually slow
|
| 30 |
+
down the whole process.
|
| 31 |
+
|
| 32 |
+
```
|
| 33 |
+
Layer 1 (ThreadPoolExecutor): 16 worker threads, each handling one image preprocess
|
| 34 |
+
Layer 2 (torch intra-op): x Each preprocess call can use up to `cpu_count` threads
|
| 35 |
+
========================================================
|
| 36 |
+
Total threads = 16 (workers) * cpu_count (intra-op) =>
|
| 37 |
+
Potentially 256 threads on a 16-core machine,
|
| 38 |
+
causing oversubscription and slowdown.
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
By setting `torch.set_num_threads(1)`, we ensure that each preprocess call
|
| 42 |
+
runs single-thread, no internal spliting. All parallelism comes cleanly from
|
| 43 |
+
one place - the `ThreadPoolExecutor`. Instead of two nested layers that
|
| 44 |
+
multiply into a thread explosion, each core does one useful thing (decode a
|
| 45 |
+
whole image) with no scheduling thrash and no per-op thread-launch overhead.
|
| 46 |
+
|
| 47 |
+
```
|
| 48 |
+
Layer 1 (ThreadPoolExecutor): 16 worker threads, each handling one image preprocess
|
| 49 |
+
Layer 2 (torch intra-op): x 1 (each op runs single-threaded, instantly)
|
| 50 |
+
========================================================
|
| 51 |
+
Total threads = 16 (workers) * 1 (intra-op) =>
|
| 52 |
+
Potentially 16 threads on a 16-core machine,
|
| 53 |
+
fully utilizing the CPU without oversubscription.
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
What Intra-op is good for?
|
| 57 |
+
|
| 58 |
+
Intra-op parallelism is excellent for big ops. On the CPU-only path, the
|
| 59 |
+
forward pass of the model is the bottleneck, and it benefits from intra-op
|
| 60 |
+
parallelism. So we leave torch's intra-op threads alone on CPU, and cap the
|
| 61 |
+
worker threads to a small number (2) to avoid too much contention. On GPU,
|
| 62 |
+
the forward pass is fast and doesn't need CPU cores, so we maximize worker
|
| 63 |
+
threads for decoding and set intra-op to 1 to avoid oversubscription.
|
| 64 |
+
|
| 65 |
"""
|
| 66 |
|
| 67 |
+
import os
|
| 68 |
import numpy as np
|
| 69 |
import streamlit as st
|
| 70 |
import time
|
|
|
|
| 139 |
model_name: str,
|
| 140 |
batch_size: int,
|
| 141 |
n_workers: int,
|
| 142 |
+
progress_callback: Optional[Callable[[float, str], None]] = None,
|
| 143 |
+
recursive: bool = False,
|
| 144 |
) -> Tuple[np.ndarray, List[str]]:
|
| 145 |
"""
|
| 146 |
Generate embeddings for images in a directory.
|
| 147 |
|
| 148 |
+
Preprocessing runs on a thread pool (GIL-light) overlapped with the model
|
| 149 |
+
forward pass — no multiprocessing, so behavior is identical on every OS.
|
| 150 |
+
|
| 151 |
Args:
|
| 152 |
image_dir: Path to directory containing images
|
| 153 |
model_name: Name of the model to use
|
| 154 |
+
batch_size: Batch size for the forward pass
|
| 155 |
+
n_workers: Max preprocessing threads (capped per device, see below)
|
| 156 |
progress_callback: Optional callback for progress updates
|
| 157 |
+
recursive: Recurse into subdirectories when listing images
|
| 158 |
|
| 159 |
Returns:
|
| 160 |
Tuple of (embeddings array, list of valid image paths)
|
| 161 |
"""
|
| 162 |
import torch
|
| 163 |
+
from shared.utils.image_pipeline import embed_image_folder
|
| 164 |
|
| 165 |
logger.info(f"Starting embedding generation: dir={image_dir}, model={model_name}, "
|
| 166 |
+
f"batch_size={batch_size}, n_workers={n_workers}, recursive={recursive}")
|
| 167 |
total_start = time.time()
|
| 168 |
|
| 169 |
if progress_callback:
|
| 170 |
progress_callback(0.0, "Listing images...")
|
| 171 |
|
| 172 |
+
image_paths = list_image_files(image_dir, recursive=recursive)
|
| 173 |
+
total = len(image_paths)
|
| 174 |
+
logger.info(f"Found {total} images in {image_dir}")
|
| 175 |
|
| 176 |
if progress_callback:
|
| 177 |
+
progress_callback(0.05, f"Found {total} images. Loading model...")
|
| 178 |
|
| 179 |
torch_device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 180 |
+
device = torch.device(torch_device)
|
| 181 |
logger.info(f"Using device: {torch_device}")
|
| 182 |
model, preprocess = EmbeddingService.load_model_unified(model_name, torch_device)
|
| 183 |
|
| 184 |
+
# Device-aware concurrency:
|
| 185 |
+
cpu_count = os.cpu_count() or 1
|
| 186 |
+
prev_threads = None
|
| 187 |
+
|
| 188 |
+
if device.type == "cuda":
|
| 189 |
+
# GPU: feed the GPU with parallel decode, avoid per-op oversubscription.
|
| 190 |
+
# - preprocess threads: wide
|
| 191 |
+
# - torch intra-op threads: forced to 1
|
| 192 |
+
|
| 193 |
+
# Set the number of preprocessing threads, clamped by three ceilings:
|
| 194 |
+
# 1) the user-requested n_workers
|
| 195 |
+
# 2) the number of CPU cores
|
| 196 |
+
# 3) never more threads than images
|
| 197 |
+
workers = max(1, min(n_workers, cpu_count, max(total, 1)))
|
| 198 |
+
|
| 199 |
+
prev_threads = torch.get_num_threads()
|
| 200 |
+
torch.set_num_threads(1)
|
|
|
|
|
|
|
|
|
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
else:
|
| 203 |
+
# CPU: the CPU forward is the bottleneck, needs the cores,
|
| 204 |
+
# so keep preprocess pool small and leave torch threads alone.
|
| 205 |
+
workers = max(1, min(2, n_workers, max(total, 1)))
|
| 206 |
+
|
| 207 |
+
# Map the pipeline's 0..1 progress into the 0.1..1.0 band (model load took 0..0.1).
|
| 208 |
+
def _embed_progress(frac: float, msg: str):
|
| 209 |
+
if progress_callback:
|
| 210 |
+
progress_callback(0.1 + 0.9 * frac, msg)
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
embeddings, valid_paths = embed_image_folder(
|
| 214 |
+
image_paths,
|
| 215 |
+
model,
|
| 216 |
+
preprocess,
|
| 217 |
+
device,
|
| 218 |
+
batch_size=batch_size,
|
| 219 |
+
n_workers=workers,
|
| 220 |
+
progress_callback=_embed_progress,
|
| 221 |
+
)
|
| 222 |
+
finally:
|
| 223 |
+
if prev_threads is not None:
|
| 224 |
+
torch.set_num_threads(prev_threads)
|
| 225 |
|
| 226 |
if progress_callback:
|
| 227 |
progress_callback(1.0, f"Complete! Generated {embeddings.shape[0]} embeddings")
|
| 228 |
|
| 229 |
total_elapsed = time.time() - total_start
|
| 230 |
+
rate = embeddings.shape[0] / total_elapsed if total_elapsed > 0 else 0.0
|
| 231 |
+
logger.info(f"Embedding generation completed: {embeddings.shape[0]} embeddings in "
|
| 232 |
+
f"{total_elapsed:.2f}s ({rate:.1f} images/sec)")
|
| 233 |
|
| 234 |
return embeddings, valid_paths
|
shared/utils/backend.py
CHANGED
|
@@ -99,9 +99,8 @@ def resolve_backend(backend: str, operation: str = "general") -> str:
|
|
| 99 |
return backend
|
| 100 |
|
| 101 |
cuda_available, device_info = check_cuda_available()
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
if cuda_available and has_cuml:
|
| 105 |
resolved = "cuml"
|
| 106 |
logger.info(f"Auto-resolved {operation} backend to cuML (GPU: {device_info})")
|
| 107 |
else:
|
|
|
|
| 99 |
return backend
|
| 100 |
|
| 101 |
cuda_available, device_info = check_cuda_available()
|
| 102 |
+
# Only probe for cuML when CUDA is actually available.
|
| 103 |
+
if cuda_available and check_cuml_available():
|
|
|
|
| 104 |
resolved = "cuml"
|
| 105 |
logger.info(f"Auto-resolved {operation} backend to cuML (GPU: {device_info})")
|
| 106 |
else:
|
shared/utils/clustering.py
CHANGED
|
@@ -199,7 +199,9 @@ def _reduce_dim_sklearn(embeddings: np.ndarray, method: str, seed: Optional[int]
|
|
| 199 |
effective_workers = -1 if n_workers > 1 else n_workers
|
| 200 |
|
| 201 |
if method.upper() == "PCA":
|
| 202 |
-
|
|
|
|
|
|
|
| 203 |
elif method.upper() == "TSNE":
|
| 204 |
# Adjust perplexity to be valid for the sample size
|
| 205 |
n_samples = embeddings.shape[0]
|
|
@@ -244,16 +246,21 @@ def _reduce_dim_cuml(embeddings: np.ndarray, method: str, seed: Optional[int], n
|
|
| 244 |
|
| 245 |
if method.upper() == "PCA":
|
| 246 |
from cuml.decomposition import PCA as cuPCA
|
|
|
|
|
|
|
| 247 |
reducer = cuPCA(n_components=2)
|
| 248 |
elif method.upper() == "TSNE":
|
| 249 |
from cuml.manifold import TSNE as cuTSNE
|
| 250 |
n_samples = embeddings.shape[0]
|
| 251 |
perplexity = min(30, max(5, n_samples // 3))
|
| 252 |
|
|
|
|
|
|
|
|
|
|
| 253 |
if seed is not None:
|
| 254 |
-
reducer = cuTSNE(n_components=2, perplexity=perplexity, random_state=seed)
|
| 255 |
else:
|
| 256 |
-
reducer = cuTSNE(n_components=2, perplexity=perplexity)
|
| 257 |
else:
|
| 258 |
raise ValueError("Unsupported method. Choose 'PCA', 'TSNE', or 'UMAP'.")
|
| 259 |
|
|
|
|
| 199 |
effective_workers = -1 if n_workers > 1 else n_workers
|
| 200 |
|
| 201 |
if method.upper() == "PCA":
|
| 202 |
+
# Pass random_state so the randomized SVD solver (auto-selected for
|
| 203 |
+
# large inputs) is reproducible when a seed is set; None keeps it random.
|
| 204 |
+
reducer = PCA(n_components=2, random_state=seed)
|
| 205 |
elif method.upper() == "TSNE":
|
| 206 |
# Adjust perplexity to be valid for the sample size
|
| 207 |
n_samples = embeddings.shape[0]
|
|
|
|
| 246 |
|
| 247 |
if method.upper() == "PCA":
|
| 248 |
from cuml.decomposition import PCA as cuPCA
|
| 249 |
+
# cuML PCA takes no random_state and needs none: its full-SVD solver
|
| 250 |
+
# is deterministic, so results are already reproducible run-to-run.
|
| 251 |
reducer = cuPCA(n_components=2)
|
| 252 |
elif method.upper() == "TSNE":
|
| 253 |
from cuml.manifold import TSNE as cuTSNE
|
| 254 |
n_samples = embeddings.shape[0]
|
| 255 |
perplexity = min(30, max(5, n_samples // 3))
|
| 256 |
|
| 257 |
+
# Force the exact solver: cuML's default Barnes-Hut collapses to a
|
| 258 |
+
# ~1D line on near-homogeneous data (#40). exact is O(N^2) but fine
|
| 259 |
+
# at our interactive scale; a faster Barnes-Hut-with-guard can come later.
|
| 260 |
if seed is not None:
|
| 261 |
+
reducer = cuTSNE(n_components=2, perplexity=perplexity, method="exact", random_state=seed)
|
| 262 |
else:
|
| 263 |
+
reducer = cuTSNE(n_components=2, perplexity=perplexity, method="exact")
|
| 264 |
else:
|
| 265 |
raise ValueError("Unsupported method. Choose 'PCA', 'TSNE', or 'UMAP'.")
|
| 266 |
|
shared/utils/image_pipeline.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thread-parallel image embedding pipeline.
|
| 2 |
+
|
| 3 |
+
Turns a list of image paths into embeddings on a single machine. Preprocessing
|
| 4 |
+
(decode + transform) runs on a thread pool; the model forward runs on the
|
| 5 |
+
calling thread that owns the device. Each batch is preprocessed while the
|
| 6 |
+
previous batch runs through the model (a one-batch prefetch), so CPU decoding
|
| 7 |
+
and the device forward overlap.
|
| 8 |
+
|
| 9 |
+
Threads — rather than worker processes — carry the preprocessing because the
|
| 10 |
+
work is GIL-light: PIL decode and torchvision tensor ops release the GIL, so a
|
| 11 |
+
thread pool scales nearly linearly. Staying in one process means no per-image
|
| 12 |
+
data crosses a process boundary and there is no worker-spawn cost, so small
|
| 13 |
+
folders are cheap and behavior does not depend on the OS.
|
| 14 |
+
|
| 15 |
+
This module is Streamlit-free and unit-testable.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import concurrent.futures as cf
|
| 21 |
+
from collections import deque
|
| 22 |
+
from typing import Callable, List, Optional, Tuple
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import torch
|
| 26 |
+
from PIL import Image
|
| 27 |
+
|
| 28 |
+
from shared.utils.logging_config import get_logger
|
| 29 |
+
|
| 30 |
+
logger = get_logger(__name__)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _output_dim(model) -> int:
|
| 34 |
+
"""Best-effort embedding width, for shaping an empty result."""
|
| 35 |
+
return int(getattr(getattr(model, "visual", None), "output_dim", 0) or 0)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _preprocess_one(path: str, preprocess: Callable, color_mode: str):
|
| 39 |
+
"""Decode + preprocess a single image.
|
| 40 |
+
|
| 41 |
+
Returns ``(path, tensor)`` on success or ``(path, None)`` if the file can't
|
| 42 |
+
be read/decoded. Pure and device-free, so it is safe on worker threads.
|
| 43 |
+
"""
|
| 44 |
+
try:
|
| 45 |
+
with Image.open(path) as im:
|
| 46 |
+
img = im.convert(color_mode)
|
| 47 |
+
return path, preprocess(img)
|
| 48 |
+
except Exception as e:
|
| 49 |
+
logger.warning(f"[Embed] Skipping unreadable image {path}: {e}")
|
| 50 |
+
return path, None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def embed_image_folder(
|
| 54 |
+
image_paths: List[str],
|
| 55 |
+
model,
|
| 56 |
+
preprocess: Callable,
|
| 57 |
+
device: torch.device,
|
| 58 |
+
*,
|
| 59 |
+
batch_size: int = 32,
|
| 60 |
+
n_workers: int = 8,
|
| 61 |
+
prefetch_batches: int = 1,
|
| 62 |
+
color_mode: str = "RGB",
|
| 63 |
+
progress_callback: Optional[Callable[[float, str], None]] = None,
|
| 64 |
+
) -> Tuple[np.ndarray, List[str]]:
|
| 65 |
+
"""Embed a list of image paths, overlapping preprocessing with the forward.
|
| 66 |
+
|
| 67 |
+
Preprocessing runs on a ``ThreadPoolExecutor``; the model forward runs on the
|
| 68 |
+
calling thread (which owns ``device``). Up to ``prefetch_batches`` batches are
|
| 69 |
+
preprocessed ahead of the batch currently being run through the model.
|
| 70 |
+
|
| 71 |
+
Unreadable images are skipped (and logged), so the returned embeddings may
|
| 72 |
+
have fewer rows than ``image_paths``. ``embeddings[i]`` corresponds to
|
| 73 |
+
``valid_paths[i]``.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
image_paths: Image file paths to embed.
|
| 77 |
+
model: Model exposing ``encode_image(tensor) -> tensor``.
|
| 78 |
+
preprocess: Callable mapping a PIL image to a CHW tensor.
|
| 79 |
+
device: Torch device the model lives on.
|
| 80 |
+
batch_size: Images per forward pass.
|
| 81 |
+
n_workers: Preprocessing threads.
|
| 82 |
+
prefetch_batches: Batches to preprocess ahead of the forward (overlap).
|
| 83 |
+
color_mode: PIL convert mode applied before preprocessing.
|
| 84 |
+
progress_callback: Optional ``(fraction, message)`` progress sink.
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
``(embeddings [N, D] float array, valid_paths [N])``.
|
| 88 |
+
"""
|
| 89 |
+
total = len(image_paths)
|
| 90 |
+
if total == 0:
|
| 91 |
+
return np.empty((0, _output_dim(model)), dtype=np.float32), []
|
| 92 |
+
|
| 93 |
+
batches = [image_paths[i:i + batch_size] for i in range(0, total, batch_size)]
|
| 94 |
+
window = max(1, prefetch_batches + 1)
|
| 95 |
+
|
| 96 |
+
emb_chunks: List[np.ndarray] = []
|
| 97 |
+
valid_paths: List[str] = []
|
| 98 |
+
processed = 0
|
| 99 |
+
|
| 100 |
+
# concurrent.futures.ThreadPoolExecutor(max_workers=n_workers)
|
| 101 |
+
# spins up `n_workers` OS threads sitting idle, waiting for work...
|
| 102 |
+
# hand it work with ex.submit(fn, *args), which returns a Future immediately,
|
| 103 |
+
# and runs fn(*args) on a worker thread when it gets scheduled by the OS...
|
| 104 |
+
with cf.ThreadPoolExecutor(max_workers=n_workers) as ex:
|
| 105 |
+
|
| 106 |
+
# non-blocking, starts the preprocessing of a batch on the pool
|
| 107 |
+
def submit(batch: List[str]) -> List[cf.Future]:
|
| 108 |
+
return [ex.submit(_preprocess_one, p, preprocess, color_mode) for p in batch]
|
| 109 |
+
|
| 110 |
+
# Prime the pipeline so the first forward already has successors decoding.
|
| 111 |
+
# pending is a queue of lists of futures, one list per batch.
|
| 112 |
+
pending: deque = deque()
|
| 113 |
+
next_idx = 0
|
| 114 |
+
while next_idx < len(batches) and len(pending) < window:
|
| 115 |
+
pending.append(submit(batches[next_idx]))
|
| 116 |
+
next_idx += 1
|
| 117 |
+
# pending is now a full window of batches being preprocessed
|
| 118 |
+
|
| 119 |
+
with torch.no_grad():
|
| 120 |
+
while pending:
|
| 121 |
+
# Take the oldest in-flight batch
|
| 122 |
+
futures = pending.popleft()
|
| 123 |
+
# Refill the window first: these batches preprocess on the pool
|
| 124 |
+
# while we run the current batch through the model below.
|
| 125 |
+
if next_idx < len(batches):
|
| 126 |
+
pending.append(submit(batches[next_idx]))
|
| 127 |
+
next_idx += 1
|
| 128 |
+
|
| 129 |
+
# If the worker alreadt=y finished, returns immediately;
|
| 130 |
+
# otherwise blocks until the batch is ready.
|
| 131 |
+
results = [f.result() for f in futures]
|
| 132 |
+
batch_paths = [p for p, t in results if t is not None]
|
| 133 |
+
tensors = [t for _, t in results if t is not None]
|
| 134 |
+
|
| 135 |
+
if tensors:
|
| 136 |
+
x = torch.stack(tensors).to(device)
|
| 137 |
+
feats = model.encode_image(x).cpu().numpy()
|
| 138 |
+
emb_chunks.append(feats)
|
| 139 |
+
valid_paths.extend(batch_paths)
|
| 140 |
+
|
| 141 |
+
processed += len(futures)
|
| 142 |
+
if progress_callback:
|
| 143 |
+
progress_callback(processed / total, f"Embedding {processed}/{total}")
|
| 144 |
+
|
| 145 |
+
if emb_chunks:
|
| 146 |
+
embeddings = np.vstack(emb_chunks)
|
| 147 |
+
else:
|
| 148 |
+
embeddings = np.empty((0, _output_dim(model)), dtype=np.float32)
|
| 149 |
+
|
| 150 |
+
logger.info(
|
| 151 |
+
f"[Embed] {embeddings.shape[0]}/{total} images embedded "
|
| 152 |
+
f"({total - embeddings.shape[0]} skipped)"
|
| 153 |
+
)
|
| 154 |
+
return embeddings, valid_paths
|
shared/utils/images.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared image-fetching utilities.
|
| 2 |
+
|
| 3 |
+
App-agnostic helpers for resolving and fetching record images from remote
|
| 4 |
+
URLs. Kept free of Streamlit so the helpers run safely on worker threads and
|
| 5 |
+
in any app (precalculated, a URL-based embed_explore, the demo Space, ...).
|
| 6 |
+
|
| 7 |
+
In-app fetch flow
|
| 8 |
+
-----------------
|
| 9 |
+
A record (parquet row) holds an image URL in one of ``IMAGE_URL_COLUMNS``.
|
| 10 |
+
Two call paths consume these:
|
| 11 |
+
|
| 12 |
+
1. Cluster representatives (bulk, eager).
|
| 13 |
+
``render_cluster_representatives`` resolves a URL per candidate with
|
| 14 |
+
``resolve_record_image_url`` and warms the cache up front via
|
| 15 |
+
``fetch_images_concurrent`` (thread pool, 8 workers). Each thread calls
|
| 16 |
+
``download_image_bytes`` -> ``bytes_to_image`` and stores the PIL image
|
| 17 |
+
(or ``None`` on failure) in ``_IMAGE_CACHE``. The renderer then reads
|
| 18 |
+
results straight from the cache; broken URLs are skipped and the next
|
| 19 |
+
candidate is tried.
|
| 20 |
+
|
| 21 |
+
2. Click preview (single, lazy).
|
| 22 |
+
``render_data_preview`` resolves one URL and calls ``get_image_from_url``,
|
| 23 |
+
which serves the cached image if present and otherwise does a single
|
| 24 |
+
synchronous ``download_image_bytes`` -> ``bytes_to_image`` and caches it.
|
| 25 |
+
|
| 26 |
+
So both paths share one fetch primitive and one cache; the only difference is
|
| 27 |
+
concurrent prefetch vs. on-demand single fetch.
|
| 28 |
+
|
| 29 |
+
Why a process-level cache (not ``@st.cache_data``)
|
| 30 |
+
--------------------------------------------------
|
| 31 |
+
- The bulk path fetches from worker threads, where ``st.*`` calls are unsafe;
|
| 32 |
+
a plain module-level dict is thread-friendly and lets both paths share the
|
| 33 |
+
same entries.
|
| 34 |
+
- It survives Streamlit reruns within the process, so panning/clicking does
|
| 35 |
+
not refetch. A soft FIFO cap (``_IMAGE_CACHE_MAX``) bounds memory.
|
| 36 |
+
Trimming only happens at the end of a fetch call, not on every insertion.
|
| 37 |
+
- ``None`` is cached as a known miss, so a dead URL is fetched at most once.
|
| 38 |
+
|
| 39 |
+
The single shared ``requests.Session`` carries the project User-Agent so data
|
| 40 |
+
hosts can identify / allowlist us.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
import concurrent.futures
|
| 44 |
+
import time
|
| 45 |
+
from io import BytesIO
|
| 46 |
+
from typing import Dict, Iterable, Optional
|
| 47 |
+
|
| 48 |
+
import requests
|
| 49 |
+
from PIL import Image
|
| 50 |
+
|
| 51 |
+
from shared import __version__ as _EMB_EXPLORER_VERSION
|
| 52 |
+
from shared.utils.logging_config import get_logger
|
| 53 |
+
|
| 54 |
+
logger = get_logger(__name__)
|
| 55 |
+
|
| 56 |
+
# Columns checked, in order, for an image URL when resolving a record's image.
|
| 57 |
+
IMAGE_URL_COLUMNS = ['identifier', 'image_url', 'url', 'img_url', 'image']
|
| 58 |
+
|
| 59 |
+
# Be a polite client: identify the app and link the repo so data hosts can
|
| 60 |
+
# contact us / allowlist us if needed.
|
| 61 |
+
USER_AGENT = (
|
| 62 |
+
f"emb-explorer/{_EMB_EXPLORER_VERSION} "
|
| 63 |
+
"(+https://github.com/Imageomics/emb-explorer)"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
_session: Optional[requests.Session] = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _get_session() -> requests.Session:
|
| 70 |
+
"""Lazily build a shared requests.Session carrying our User-Agent."""
|
| 71 |
+
global _session
|
| 72 |
+
if _session is None:
|
| 73 |
+
s = requests.Session()
|
| 74 |
+
s.headers.update({"User-Agent": USER_AGENT})
|
| 75 |
+
_session = s
|
| 76 |
+
return _session
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def download_image_bytes(url: str, timeout: int = 5) -> Optional[bytes]:
|
| 80 |
+
"""Fetch raw image bytes via the shared session. None on any failure.
|
| 81 |
+
|
| 82 |
+
Contains no Streamlit calls, so it is safe to run from worker threads.
|
| 83 |
+
"""
|
| 84 |
+
if not isinstance(url, str) or not url.startswith(('http://', 'https://')):
|
| 85 |
+
return None
|
| 86 |
+
try:
|
| 87 |
+
resp = _get_session().get(url, timeout=timeout, stream=True)
|
| 88 |
+
resp.raise_for_status()
|
| 89 |
+
if not resp.headers.get('content-type', '').lower().startswith('image/'):
|
| 90 |
+
return None
|
| 91 |
+
return resp.content
|
| 92 |
+
except Exception:
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def bytes_to_image(data: Optional[bytes]) -> Optional[Image.Image]:
|
| 97 |
+
"""Decode image bytes to a PIL image, or None on failure."""
|
| 98 |
+
if not data:
|
| 99 |
+
return None
|
| 100 |
+
try:
|
| 101 |
+
return Image.open(BytesIO(data))
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.error(f"[Image] Failed to open: {e}")
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# Process-level cache for fetched images. Survives Streamlit reruns within the
|
| 108 |
+
# process; value is a PIL image or None (known miss).
|
| 109 |
+
_IMAGE_CACHE: Dict[str, Optional[Image.Image]] = {}
|
| 110 |
+
_IMAGE_CACHE_MAX = 512
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _trim_cache() -> None:
|
| 114 |
+
"""Soft FIFO cap so the cache doesn't grow unbounded across sessions."""
|
| 115 |
+
if len(_IMAGE_CACHE) > _IMAGE_CACHE_MAX:
|
| 116 |
+
for k in list(_IMAGE_CACHE.keys())[: len(_IMAGE_CACHE) - _IMAGE_CACHE_MAX]:
|
| 117 |
+
_IMAGE_CACHE.pop(k, None)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def fetch_images_concurrent(
|
| 121 |
+
urls: Iterable[str], max_workers: int = 8, timeout: int = 5
|
| 122 |
+
) -> Dict[str, Optional[Image.Image]]:
|
| 123 |
+
"""Fetch many image URLs concurrently with a thread pool.
|
| 124 |
+
|
| 125 |
+
Returns {url: PIL image or None}. Per-URL results are cached in a
|
| 126 |
+
process-level dict so reruns and overlapping clusters don't refetch.
|
| 127 |
+
Threads only do HTTP + PIL decode (no st.* calls), which is Streamlit-safe.
|
| 128 |
+
"""
|
| 129 |
+
unique = [u for u in dict.fromkeys(urls) if isinstance(u, str) and u]
|
| 130 |
+
missing = [u for u in unique if u not in _IMAGE_CACHE]
|
| 131 |
+
|
| 132 |
+
if missing:
|
| 133 |
+
t0 = time.time()
|
| 134 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as ex:
|
| 135 |
+
future_to_url = {
|
| 136 |
+
ex.submit(download_image_bytes, u, timeout): u for u in missing
|
| 137 |
+
}
|
| 138 |
+
for fut in concurrent.futures.as_completed(future_to_url):
|
| 139 |
+
u = future_to_url[fut]
|
| 140 |
+
try:
|
| 141 |
+
_IMAGE_CACHE[u] = bytes_to_image(fut.result())
|
| 142 |
+
except Exception:
|
| 143 |
+
_IMAGE_CACHE[u] = None
|
| 144 |
+
ok = sum(1 for u in missing if _IMAGE_CACHE.get(u) is not None)
|
| 145 |
+
logger.info(
|
| 146 |
+
f"[Image] Concurrently fetched {len(missing)} url(s) in "
|
| 147 |
+
f"{time.time() - t0:.2f}s ({ok} ok)"
|
| 148 |
+
)
|
| 149 |
+
_trim_cache()
|
| 150 |
+
|
| 151 |
+
return {u: _IMAGE_CACHE.get(u) for u in unique}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def get_image_from_url(url: str, timeout: int = 5) -> Optional[Image.Image]:
|
| 155 |
+
"""Get a single image from a URL, using the process cache.
|
| 156 |
+
|
| 157 |
+
Logs the request; results (including misses) are cached so repeated
|
| 158 |
+
lookups and the concurrent path share one cache.
|
| 159 |
+
"""
|
| 160 |
+
if not url or not isinstance(url, str):
|
| 161 |
+
return None
|
| 162 |
+
if url in _IMAGE_CACHE:
|
| 163 |
+
return _IMAGE_CACHE[url]
|
| 164 |
+
if not url.startswith(('http://', 'https://')):
|
| 165 |
+
logger.warning(f"[Image] Invalid URL scheme: {url[:50]}...")
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
logger.info(f"[Image] Fetching: {url[:80]}...")
|
| 169 |
+
start_time = time.time()
|
| 170 |
+
image = bytes_to_image(download_image_bytes(url, timeout))
|
| 171 |
+
elapsed = time.time() - start_time
|
| 172 |
+
if image is not None:
|
| 173 |
+
logger.info(f"[Image] Loaded in {elapsed:.3f}s")
|
| 174 |
+
else:
|
| 175 |
+
logger.warning(f"[Image] Failed to load: {url[:50]}...")
|
| 176 |
+
|
| 177 |
+
_IMAGE_CACHE[url] = image
|
| 178 |
+
_trim_cache()
|
| 179 |
+
return image
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def resolve_record_image_url(row) -> Optional[str]:
|
| 183 |
+
"""Return the first valid HTTP(S) image URL from a record/row, else None.
|
| 184 |
+
|
| 185 |
+
`row` is anything supporting `col in row` membership and `row[col]`
|
| 186 |
+
indexing (e.g. a pandas Series or a dict).
|
| 187 |
+
"""
|
| 188 |
+
for col in IMAGE_URL_COLUMNS:
|
| 189 |
+
try:
|
| 190 |
+
present = col in row.index
|
| 191 |
+
except AttributeError:
|
| 192 |
+
present = col in row
|
| 193 |
+
if present:
|
| 194 |
+
val = row[col]
|
| 195 |
+
if isinstance(val, str) and val.startswith(('http://', 'https://')):
|
| 196 |
+
return val
|
| 197 |
+
return None
|
shared/utils/io.py
CHANGED
|
@@ -1,22 +1,37 @@
|
|
| 1 |
import os
|
| 2 |
import shutil
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
List image file paths in a directory with allowed extensions.
|
| 7 |
|
| 8 |
Args:
|
| 9 |
image_dir (str): Path to the directory containing images.
|
| 10 |
-
allowed_extensions (tuple, optional): Allowed file extensions
|
|
|
|
|
|
|
| 11 |
|
| 12 |
Returns:
|
| 13 |
-
list:
|
| 14 |
"""
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
def copy_image(row, repartition_dir):
|
| 22 |
"""
|
|
|
|
| 1 |
import os
|
| 2 |
import shutil
|
| 3 |
|
| 4 |
+
# Image extensions we attempt to load (PIL-decodable raster formats).
|
| 5 |
+
IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff', '.webp')
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def list_image_files(image_dir, allowed_extensions=IMAGE_EXTENSIONS, recursive=False):
|
| 9 |
"""
|
| 10 |
List image file paths in a directory with allowed extensions.
|
| 11 |
|
| 12 |
Args:
|
| 13 |
image_dir (str): Path to the directory containing images.
|
| 14 |
+
allowed_extensions (tuple, optional): Allowed file extensions (lowercase,
|
| 15 |
+
leading dot). Defaults to IMAGE_EXTENSIONS.
|
| 16 |
+
recursive (bool, optional): Recurse into subdirectories. Defaults to False.
|
| 17 |
|
| 18 |
Returns:
|
| 19 |
+
list: Sorted list of full file paths for images with allowed extensions.
|
| 20 |
"""
|
| 21 |
+
if recursive:
|
| 22 |
+
paths = [
|
| 23 |
+
os.path.join(root, f)
|
| 24 |
+
for root, _, files in os.walk(image_dir)
|
| 25 |
+
for f in files
|
| 26 |
+
if f.lower().endswith(allowed_extensions)
|
| 27 |
+
]
|
| 28 |
+
else:
|
| 29 |
+
paths = [
|
| 30 |
+
os.path.join(image_dir, f)
|
| 31 |
+
for f in os.listdir(image_dir)
|
| 32 |
+
if f.lower().endswith(allowed_extensions)
|
| 33 |
+
]
|
| 34 |
+
return sorted(paths)
|
| 35 |
|
| 36 |
def copy_image(row, repartition_dir):
|
| 37 |
"""
|
shared/utils/models.py
CHANGED
|
@@ -4,9 +4,11 @@ def list_available_models():
|
|
| 4 |
# Create list of all models
|
| 5 |
models_data = []
|
| 6 |
|
| 7 |
-
# Add special models first
|
| 8 |
models_data.extend([
|
| 9 |
{"name": "hf-hub:imageomics/bioclip-2", "pretrained": None},
|
|
|
|
|
|
|
| 10 |
{"name": "hf-hub:imageomics/bioclip", "pretrained": None}
|
| 11 |
])
|
| 12 |
|
|
|
|
| 4 |
# Create list of all models
|
| 5 |
models_data = []
|
| 6 |
|
| 7 |
+
# Add special models first (Imageomics BioCLIP family)
|
| 8 |
models_data.extend([
|
| 9 |
{"name": "hf-hub:imageomics/bioclip-2", "pretrained": None},
|
| 10 |
+
{"name": "hf-hub:imageomics/bioclip-2.5-vith14", "pretrained": None},
|
| 11 |
+
{"name": "hf-hub:imageomics/biocap", "pretrained": None},
|
| 12 |
{"name": "hf-hub:imageomics/bioclip", "pretrained": None}
|
| 13 |
])
|
| 14 |
|
shared/utils/representatives.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Find representative members of clusters.
|
| 2 |
+
|
| 3 |
+
Given embeddings and cluster labels, rank each cluster's members by proximity
|
| 4 |
+
to the cluster centroid. Returns more candidates than strictly requested
|
| 5 |
+
(oversampled) so callers that render images can skip candidates whose image
|
| 6 |
+
fails to load and still show the desired number per cluster.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Dict, List
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from shared.utils.logging_config import get_logger
|
| 14 |
+
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def find_cluster_representatives(
|
| 19 |
+
embeddings: np.ndarray,
|
| 20 |
+
labels,
|
| 21 |
+
n_per_cluster: int = 3,
|
| 22 |
+
oversample: int = 4,
|
| 23 |
+
) -> Dict[object, List[int]]:
|
| 24 |
+
"""Rank each cluster's members by closeness to the cluster centroid.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
embeddings: (N, D) array of embeddings (row i aligns with label i).
|
| 28 |
+
labels: array-like of length N with cluster labels (int or str).
|
| 29 |
+
n_per_cluster: how many representatives the caller intends to show.
|
| 30 |
+
oversample: multiplier for how many candidate indices to return per
|
| 31 |
+
cluster (n_per_cluster * oversample), so failed image loads can be
|
| 32 |
+
skipped while still surfacing n_per_cluster images.
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Dict mapping each cluster label to a list of global indices into
|
| 36 |
+
`embeddings`, ordered closest-to-centroid first, capped at
|
| 37 |
+
n_per_cluster * oversample (or the cluster size, whichever is smaller).
|
| 38 |
+
"""
|
| 39 |
+
labels = np.asarray(labels)
|
| 40 |
+
embeddings = np.asarray(embeddings)
|
| 41 |
+
n_candidates = max(n_per_cluster * oversample, n_per_cluster)
|
| 42 |
+
|
| 43 |
+
representatives: Dict[object, List[int]] = {}
|
| 44 |
+
for cluster_id in np.unique(labels):
|
| 45 |
+
member_idxs = np.where(labels == cluster_id)[0]
|
| 46 |
+
if member_idxs.size == 0:
|
| 47 |
+
continue
|
| 48 |
+
cluster_embeds = embeddings[member_idxs]
|
| 49 |
+
centroid = cluster_embeds.mean(axis=0)
|
| 50 |
+
|
| 51 |
+
# Compute squared Euclidean distance to the centroid for each member.
|
| 52 |
+
dists = np.sum((cluster_embeds - centroid) ** 2, axis=1)
|
| 53 |
+
order = np.argsort(dists)[:n_candidates]
|
| 54 |
+
# Keep the label's native Python type for clean dict keys / display.
|
| 55 |
+
key = cluster_id.item() if hasattr(cluster_id, "item") else cluster_id
|
| 56 |
+
representatives[key] = member_idxs[order].tolist()
|
| 57 |
+
|
| 58 |
+
logger.debug(
|
| 59 |
+
f"Found representatives for {len(representatives)} clusters "
|
| 60 |
+
f"(up to {n_candidates} candidates each)"
|
| 61 |
+
)
|
| 62 |
+
return representatives
|