netzhang commited on
Commit
269ea1f
·
verified ·
1 Parent(s): 51545af

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 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
- ENV EMB_EXPLORER_DEMO_DATA_ROOT=/data
 
 
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
- selected_idx = st.session_state.get("selected_image_idx", 0)
 
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
- logger.info(f"[Image] Loading local file: {os.path.basename(img_path)} (cluster={cluster})")
 
 
 
36
  _last_displayed_path = img_path
37
 
38
- st.image(img_path, caption=f"Cluster {cluster}: {os.path.basename(img_path)}", width='stretch')
 
 
 
 
39
  st.markdown(f"**File:** `{os.path.basename(img_path)}`")
40
- st.markdown(f"**Cluster:** `{cluster}`")
 
41
  else:
42
- st.info("Image preview will appear here after you select a cluster point.")
 
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
- from typing import Tuple, List, Optional
 
 
 
 
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 render_clustering_backend_controls, render_basic_clustering_controls
 
 
 
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 = 0
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 render_clustering_section(n_workers: int = 1) -> Tuple[bool, int, str]:
93
- """
94
- Render the clustering section of the sidebar.
 
 
95
 
96
- Args:
97
- n_workers: Number of workers for parallel processing
 
98
 
99
- Returns:
100
- Tuple of (cluster_button_clicked, n_clusters, reduction_method)
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
- Uses ClusteringService.run_clustering_safe() which transparently
141
- handles GPU errors by falling back to CPU-based sklearn backends.
142
- """
143
- cuda_available, device_info = check_cuda_available()
144
- actual_dim_backend = resolve_backend(dim_reduction_backend, "reduction")
145
- actual_cluster_backend = resolve_backend(clustering_backend, "clustering")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- with st.spinner(f"Running {reduction_method} + KMeans ({actual_dim_backend}/{actual_cluster_backend})..."):
153
- df_plot, labels = ClusteringService.run_clustering_safe(
154
- embeddings, valid_paths, n_clusters, reduction_method,
155
- n_workers, actual_dim_backend, actual_cluster_backend, seed
 
 
 
156
  )
157
 
158
- # Store results
 
 
 
159
  st.session_state.data = df_plot
160
  st.session_state.labels = labels
161
- st.session_state.selected_image_idx = 0
162
 
163
- # Compute and store clustering summary
 
 
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.clustering_summary = summary_df
169
- st.session_state.clustering_representatives = representatives
170
- logger.info(f"Clustering summary computed: {len(summary_df)} clusters")
 
 
 
 
171
 
172
- st.success(f"Clustering complete! Found {n_clusters} clusters.")
 
173
 
174
  except (RuntimeError, OSError) as e:
175
  if is_oom_error(e):
176
- st.error("**GPU Out of Memory** - Dataset too large for GPU")
177
- st.info("Try: Reduce dataset size, or select 'sklearn' backend")
178
- logger.exception("GPU OOM error during clustering")
179
  else:
180
- st.error(f"Error during clustering: {e}")
181
- logger.exception("Clustering error")
182
-
183
  except MemoryError:
184
  st.error("**System Out of Memory** - Reduce dataset size")
185
- logger.exception("System memory exhausted during clustering")
186
-
187
  except Exception as e:
188
- st.error(f"Error during clustering: {e}")
189
- logger.exception("Unexpected clustering error")
190
 
191
 
192
- def render_save_section():
193
- """Render the save operations section of the sidebar."""
194
- # --- Save images from a specific cluster utility ---
195
- save_status_placeholder = st.empty()
196
- with st.expander("Save Images from Specific Cluster", expanded=True):
197
- df_plot = st.session_state.get("data", None)
198
- labels = st.session_state.get("labels", None)
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
- with StreamlitProgressContext(
221
- save_status_placeholder,
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
- elif save_cluster_button:
234
- save_status_placeholder.warning("Please select at least one cluster.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
- else:
237
- st.info("Run clustering first to enable this utility.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- df_plot = st.session_state.get("data", None)
261
-
262
- if df_plot is None or len(df_plot) < 1:
263
- repartition_status_placeholder.warning("Please run clustering first before repartitioning images.")
264
- else:
265
- with StreamlitProgressContext(
266
- repartition_status_placeholder,
267
- f"Repartition complete! Images organized in {repartition_dir}"
268
- ) as progress:
269
- try:
270
- repartition_summary_df, csv_path = FileService.repartition_images_by_cluster(
271
- df_plot, repartition_dir, max_workers, progress_callback=progress
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 clustering sidebar with all sections."""
281
  tab_compute, tab_save = st.tabs(["Compute", "Save"])
282
 
283
  with tab_compute:
284
- embed_button, image_dir, model_name, n_workers, batch_size = render_embedding_section()
285
- cluster_button, n_clusters, reduction_method = render_clustering_section(n_workers)
 
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
- st.title("📊 Precalculated Embeddings Explorer")
50
- st.markdown(
51
- "Load parquet files with embeddings, apply dynamic filters, and cluster for visualization. "
52
- "Filters are automatically generated based on your data columns."
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 identifier/url column exists (cached to prevent re-fetch)
114
- image_cols = ['identifier', 'image_url', 'url', 'img_url', 'image']
115
- for img_col in image_cols:
116
- if img_col in record.index and pd.notna(record[img_col]):
117
- url = record[img_col]
118
- image = get_image_from_url(url)
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
- __version__ = "0.1.0"
 
 
 
 
 
 
 
 
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&nbsp;2</a> model '
62
+ f'creation, see our <a href="{BIOCLIP2_SITE_URL}">BioCLIP&nbsp;2 Project website</a>, '
63
+ 'and for easier programmatic integration of BioCLIP&nbsp;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&nbsp;#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 from clustering action."""
 
 
 
 
 
148
  df_plot = st.session_state.get("data", None)
149
- labels = st.session_state.get("labels", None)
150
 
151
- # Get pre-computed summary from session state (computed when clustering was run)
152
- summary_df = st.session_state.get("clustering_summary", None)
153
- representatives = st.session_state.get("clustering_representatives", None)
154
-
155
- if df_plot is not None:
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
- st.info("Summary will appear here after projection.")
 
 
 
 
 
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
- if is_precalculated:
82
- # Build list of colorable columns
83
- skip_color_cols = {'x', 'y', 'idx', 'uuid', 'emb', 'embedding', 'embeddings', 'vector',
84
- 'identifier', 'image_url', 'url', 'img_url', 'image'}
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
  else:
126
- # embed_explore app: always color by cluster
127
- color_col = 'cluster' if 'cluster' in df_plot.columns else None
 
 
 
 
 
 
 
 
 
 
 
 
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 cluster/cluster_name in tooltip
137
- if not is_precalculated:
138
- if 'cluster_name' in df_plot.columns:
139
- tooltip_fields.append('cluster_name:N')
140
- elif 'cluster' in df_plot.columns:
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 processing
91
- n_workers: Number of worker processes
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 hpc_inference.datasets.image_folder_dataset import ImageFolderDataset
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
- logger.info(f"Found {len(image_paths)} images in {image_dir}")
 
109
 
110
  if progress_callback:
111
- progress_callback(0.1, f"Found {len(image_paths)} images. Loading model...")
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
- if progress_callback:
118
- progress_callback(0.2, "Creating dataset...")
119
-
120
- # Create dataset & DataLoader
121
- dataset = ImageFolderDataset(
122
- image_dir=image_dir,
123
- preprocess=preprocess,
124
- uuid_mode="fullpath",
125
- rank=0,
126
- world_size=1,
127
- evenly_distribute=True,
128
- validate=True
129
- )
130
- dataloader = torch.utils.data.DataLoader(
131
- dataset,
132
- batch_size=batch_size,
133
- shuffle=False,
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
- embeddings = np.empty((0, model.visual.output_dim))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- logger.info(f"Embedding generation completed: {embeddings.shape[0]} embeddings in {total_elapsed:.2f}s "
166
- f"({embeddings.shape[0] / total_elapsed:.1f} images/sec)")
 
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
- has_cuml = check_cuml_available()
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
- reducer = PCA(n_components=2)
 
 
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
- def list_image_files(image_dir, allowed_extensions=('jpg', 'jpeg', 'png')):
 
 
 
 
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. Defaults to ('jpg', 'jpeg', 'png').
 
 
11
 
12
  Returns:
13
- list: List of full file paths for images with allowed extensions.
14
  """
15
- return [
16
- os.path.join(image_dir, f)
17
- for f in os.listdir(image_dir)
18
- if f.lower().endswith(allowed_extensions)
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