napari-tmidas 0.2.2__py3-none-any.whl → 0.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. napari_tmidas/__init__.py +35 -5
  2. napari_tmidas/_crop_anything.py +1520 -609
  3. napari_tmidas/_env_manager.py +76 -0
  4. napari_tmidas/_file_conversion.py +1646 -1131
  5. napari_tmidas/_file_selector.py +1455 -216
  6. napari_tmidas/_label_inspection.py +83 -8
  7. napari_tmidas/_processing_worker.py +309 -0
  8. napari_tmidas/_reader.py +6 -10
  9. napari_tmidas/_registry.py +2 -2
  10. napari_tmidas/_roi_colocalization.py +1221 -84
  11. napari_tmidas/_tests/test_crop_anything.py +123 -0
  12. napari_tmidas/_tests/test_env_manager.py +89 -0
  13. napari_tmidas/_tests/test_grid_view_overlay.py +193 -0
  14. napari_tmidas/_tests/test_init.py +98 -0
  15. napari_tmidas/_tests/test_intensity_label_filter.py +222 -0
  16. napari_tmidas/_tests/test_label_inspection.py +86 -0
  17. napari_tmidas/_tests/test_processing_basic.py +500 -0
  18. napari_tmidas/_tests/test_processing_worker.py +142 -0
  19. napari_tmidas/_tests/test_regionprops_analysis.py +547 -0
  20. napari_tmidas/_tests/test_registry.py +70 -2
  21. napari_tmidas/_tests/test_scipy_filters.py +168 -0
  22. napari_tmidas/_tests/test_skimage_filters.py +259 -0
  23. napari_tmidas/_tests/test_split_channels.py +217 -0
  24. napari_tmidas/_tests/test_spotiflow.py +87 -0
  25. napari_tmidas/_tests/test_tyx_display_fix.py +142 -0
  26. napari_tmidas/_tests/test_ui_utils.py +68 -0
  27. napari_tmidas/_tests/test_widget.py +30 -0
  28. napari_tmidas/_tests/test_windows_basic.py +66 -0
  29. napari_tmidas/_ui_utils.py +57 -0
  30. napari_tmidas/_version.py +16 -3
  31. napari_tmidas/_widget.py +41 -4
  32. napari_tmidas/processing_functions/basic.py +557 -20
  33. napari_tmidas/processing_functions/careamics_env_manager.py +72 -99
  34. napari_tmidas/processing_functions/cellpose_env_manager.py +415 -112
  35. napari_tmidas/processing_functions/cellpose_segmentation.py +132 -191
  36. napari_tmidas/processing_functions/colocalization.py +513 -56
  37. napari_tmidas/processing_functions/grid_view_overlay.py +703 -0
  38. napari_tmidas/processing_functions/intensity_label_filter.py +422 -0
  39. napari_tmidas/processing_functions/regionprops_analysis.py +1280 -0
  40. napari_tmidas/processing_functions/sam2_env_manager.py +53 -69
  41. napari_tmidas/processing_functions/sam2_mp4.py +274 -195
  42. napari_tmidas/processing_functions/scipy_filters.py +403 -8
  43. napari_tmidas/processing_functions/skimage_filters.py +424 -212
  44. napari_tmidas/processing_functions/spotiflow_detection.py +949 -0
  45. napari_tmidas/processing_functions/spotiflow_env_manager.py +591 -0
  46. napari_tmidas/processing_functions/timepoint_merger.py +334 -86
  47. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/METADATA +71 -30
  48. napari_tmidas-0.2.5.dist-info/RECORD +63 -0
  49. napari_tmidas/_tests/__init__.py +0 -0
  50. napari_tmidas-0.2.2.dist-info/RECORD +0 -40
  51. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/WHEEL +0 -0
  52. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/entry_points.txt +0 -0
  53. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/licenses/LICENSE +0 -0
  54. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,703 @@
1
+ # processing_functions/grid_view_overlay.py
2
+ """
3
+ Processing function for displaying grid view of intensity images overlaid with labels.
4
+ """
5
+ import concurrent.futures
6
+ import inspect
7
+ import os
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ from tqdm import tqdm
12
+
13
+ from napari_tmidas._registry import BatchProcessingRegistry
14
+
15
+ # Lazy imports for optional dependencies
16
+ try:
17
+ import tifffile
18
+
19
+ _HAS_TIFFFILE = True
20
+ except ImportError:
21
+ tifffile = None
22
+ _HAS_TIFFFILE = False
23
+
24
+ # Global flags to ensure grid is created and saved only once per batch
25
+ _grid_created = False
26
+ _cached_grid = None
27
+ _grid_saved = False
28
+ _grid_output_path = None
29
+
30
+
31
+ def _get_intensity_filename(label_filename: str) -> str:
32
+ """
33
+ Get intensity filename from label filename by removing label suffix.
34
+
35
+ Parameters
36
+ ----------
37
+ label_filename : str
38
+ Label image filename
39
+
40
+ Returns
41
+ -------
42
+ str
43
+ Intensity image filename
44
+ """
45
+ # Remove common label suffixes (handle both with and without .tif extension)
46
+ suffixes_to_remove = [
47
+ "_convpaint_labels_filtered.tif",
48
+ "_labels_filtered.tif",
49
+ "_labels.tif",
50
+ "_intensity_filtered.tif",
51
+ "_convpaint_labels_filtered",
52
+ "_labels_filtered",
53
+ "_labels",
54
+ "_intensity_filtered",
55
+ ]
56
+
57
+ for suffix in suffixes_to_remove:
58
+ if label_filename.endswith(suffix):
59
+ base = label_filename.replace(suffix, "")
60
+ # Ensure .tif extension
61
+ if not base.endswith(".tif"):
62
+ base += ".tif"
63
+ return base
64
+
65
+ # If no known suffix found, just use the filename as-is (already has .tif)
66
+ return label_filename
67
+
68
+
69
+ def _downsample_image(image: np.ndarray, target_size: int) -> np.ndarray:
70
+ """
71
+ Downsample image to target size while preserving aspect ratio.
72
+
73
+ Uses skimage which handles all dtypes including uint32.
74
+
75
+ Parameters
76
+ ----------
77
+ image : np.ndarray
78
+ Input image
79
+ target_size : int
80
+ Target size for the larger dimension
81
+
82
+ Returns
83
+ -------
84
+ np.ndarray
85
+ Downsampled image
86
+ """
87
+ from skimage.transform import resize
88
+
89
+ h, w = image.shape[:2]
90
+ max_dim = max(h, w)
91
+
92
+ if max_dim <= target_size:
93
+ return image # No downsampling needed
94
+
95
+ scale = target_size / max_dim
96
+ new_h = int(h * scale)
97
+ new_w = int(w * scale)
98
+
99
+ # skimage handles all dtypes including uint32
100
+ if len(image.shape) == 2:
101
+ downsampled = resize(
102
+ image,
103
+ (new_h, new_w),
104
+ order=1,
105
+ preserve_range=True,
106
+ anti_aliasing=True,
107
+ )
108
+ else:
109
+ downsampled = resize(
110
+ image,
111
+ (new_h, new_w, image.shape[2]),
112
+ order=1,
113
+ preserve_range=True,
114
+ anti_aliasing=True,
115
+ )
116
+
117
+ return downsampled.astype(image.dtype)
118
+
119
+
120
+ def _create_overlay(
121
+ intensity_image: np.ndarray,
122
+ label_image: np.ndarray,
123
+ target_size: int = None,
124
+ label_opacity: float = 0.6,
125
+ show_overlay: bool = True,
126
+ ) -> np.ndarray:
127
+ """
128
+ Create an overlay of intensity and label images with transparency.
129
+
130
+ Parameters
131
+ ----------
132
+ intensity_image : np.ndarray
133
+ Intensity image
134
+ label_image : np.ndarray
135
+ Label image
136
+ target_size : int, optional
137
+ Target size for downsampling (max dimension). If None, no downsampling.
138
+ label_opacity : float, optional
139
+ Opacity of label overlay (0-1). Default is 0.6 (60%).
140
+ show_overlay : bool, optional
141
+ If True, show colored label overlay on intensity (default).
142
+ If False, show only intensity in grayscale.
143
+
144
+ Returns
145
+ -------
146
+ np.ndarray
147
+ RGB overlay image with intensity in grayscale and optional colored label regions
148
+ """
149
+ # Downsample if target size specified
150
+ if target_size is not None:
151
+ intensity_image = _downsample_image(intensity_image, target_size)
152
+ # Use nearest neighbor for labels to preserve label IDs
153
+ h, w = intensity_image.shape
154
+ if label_image.shape != (h, w):
155
+ from skimage.transform import resize
156
+
157
+ # skimage handles uint32 natively, use order=0 for nearest neighbor
158
+ label_image = resize(
159
+ label_image,
160
+ (h, w),
161
+ order=0,
162
+ preserve_range=True,
163
+ anti_aliasing=False,
164
+ ).astype(label_image.dtype)
165
+
166
+ # Normalize intensity image to 0-1 range
167
+ if (
168
+ intensity_image.dtype != np.float32
169
+ and intensity_image.dtype != np.float64
170
+ ):
171
+ intensity_norm = intensity_image.astype(np.float32)
172
+ else:
173
+ intensity_norm = intensity_image.copy()
174
+
175
+ if intensity_norm.max() > 0:
176
+ intensity_norm = (intensity_norm - intensity_norm.min()) / (
177
+ intensity_norm.max() - intensity_norm.min()
178
+ )
179
+
180
+ # Create RGB image with intensity in grayscale (all channels)
181
+ h, w = intensity_norm.shape
182
+ rgb = np.zeros((h, w, 3), dtype=np.float32)
183
+ rgb[:, :, 0] = intensity_norm # Red
184
+ rgb[:, :, 1] = intensity_norm # Green
185
+ rgb[:, :, 2] = intensity_norm # Blue
186
+
187
+ # Create colored label overlay using simple colormap (only if show_overlay is True)
188
+ if show_overlay:
189
+ # Generate colors for each unique label (excluding background)
190
+ unique_labels = np.unique(label_image)
191
+ unique_labels = unique_labels[unique_labels > 0] # Exclude background
192
+
193
+ if len(unique_labels) == 0:
194
+ # No labels found - image will be grayscale only
195
+ pass # rgb already has intensity in all channels (grayscale)
196
+ elif len(unique_labels) > 0:
197
+ # Create a simple colormap using hue variation
198
+ # Use modulo to cycle through distinct colors even with many labels
199
+ for i, label_id in enumerate(unique_labels):
200
+ mask = label_image == label_id
201
+
202
+ # Generate color by cycling through hue values (0-360 degrees)
203
+ hue = (
204
+ i * 137.5
205
+ ) % 360 # Golden angle for better color distribution
206
+
207
+ # Convert HSV to RGB (H=hue, S=1, V=1)
208
+ h_norm = hue / 60.0
209
+ h_int = int(h_norm) % 6
210
+ f = h_norm - int(h_norm)
211
+
212
+ if h_int == 0:
213
+ r, g, b = 1.0, f, 0.0
214
+ elif h_int == 1:
215
+ r, g, b = 1.0 - f, 1.0, 0.0
216
+ elif h_int == 2:
217
+ r, g, b = 0.0, 1.0, f
218
+ elif h_int == 3:
219
+ r, g, b = 0.0, 1.0 - f, 1.0
220
+ elif h_int == 4:
221
+ r, g, b = f, 0.0, 1.0
222
+ else:
223
+ r, g, b = 1.0, 0.0, 1.0 - f
224
+
225
+ # Blend with opacity
226
+ rgb[mask, 0] = (1 - label_opacity) * rgb[
227
+ mask, 0
228
+ ] + label_opacity * r
229
+ rgb[mask, 1] = (1 - label_opacity) * rgb[
230
+ mask, 1
231
+ ] + label_opacity * g
232
+ rgb[mask, 2] = (1 - label_opacity) * rgb[
233
+ mask, 2
234
+ ] + label_opacity * b
235
+
236
+ # Convert to uint8
237
+ rgb_uint8 = (rgb * 255).astype(np.uint8)
238
+
239
+ return rgb_uint8
240
+
241
+
242
+ def _create_grid(images: list, grid_cols: int = 4) -> np.ndarray:
243
+ """
244
+ Arrange images in a grid layout.
245
+
246
+ Parameters
247
+ ----------
248
+ images : list
249
+ List of images to arrange
250
+ grid_cols : int
251
+ Number of columns in grid
252
+
253
+ Returns
254
+ -------
255
+ np.ndarray
256
+ Grid image
257
+ """
258
+ if not images:
259
+ return None
260
+
261
+ # Calculate grid dimensions
262
+ n_images = len(images)
263
+ grid_rows = (n_images + grid_cols - 1) // grid_cols
264
+
265
+ # Get dimensions from first image
266
+ h, w = images[0].shape[:2]
267
+ has_channels = len(images[0].shape) == 3
268
+ n_channels = images[0].shape[2] if has_channels else 1
269
+
270
+ # Create grid
271
+ if has_channels:
272
+ grid = np.zeros(
273
+ (grid_rows * h, grid_cols * w, n_channels), dtype=images[0].dtype
274
+ )
275
+ else:
276
+ grid = np.zeros((grid_rows * h, grid_cols * w), dtype=images[0].dtype)
277
+
278
+ # Fill grid
279
+ for idx, img in enumerate(images):
280
+ row = idx // grid_cols
281
+ col = idx % grid_cols
282
+ y_start = row * h
283
+ y_end = (row + 1) * h
284
+ x_start = col * w
285
+ x_end = (col + 1) * w
286
+ grid[y_start:y_end, x_start:x_end] = img
287
+
288
+ return grid
289
+
290
+
291
+ @BatchProcessingRegistry.register(
292
+ name="Grid View: Intensity + Labels Overlay",
293
+ suffix="_grid_overlay.tif",
294
+ description="Create grid view of intensity images with optional colored label overlay for selected files",
295
+ parameters={
296
+ "label_suffix": {
297
+ "type": str,
298
+ "default": "_labels.tif",
299
+ "description": "Example: _labels.tif. Leave empty for intensity-only grid.",
300
+ }
301
+ },
302
+ )
303
+ def create_grid_overlay(
304
+ image: np.ndarray, label_suffix: str = "_labels.tif"
305
+ ) -> np.ndarray:
306
+ """
307
+ Create a grid view showing intensity images with optional colored label overlay.
308
+
309
+ This function processes all files selected in the batch processing queue.
310
+ If label_suffix is provided, it finds corresponding label files and creates
311
+ overlays. If label_suffix is empty, it creates a grid of intensity images only.
312
+
313
+ Parameters
314
+ ----------
315
+ image : np.ndarray
316
+ Input image (processed as part of the batch)
317
+ label_suffix : str, optional
318
+ Suffix pattern to identify label files (e.g., "_labels.tif", "_segmentation.tif").
319
+ If empty string, creates intensity-only grid without looking for labels.
320
+ Default is "_labels.tif".
321
+
322
+ Returns
323
+ -------
324
+ np.ndarray
325
+ Grid image with intensity images and optional overlays (RGB uint8)
326
+
327
+ Notes
328
+ -----
329
+ - Intensity is shown in grayscale
330
+ - When labels are used: each label gets a unique color with 60% opacity
331
+ - Images are automatically normalized for display
332
+ - Grid columns are automatically determined based on number of images
333
+ - Only processes files selected by user's suffix filter in batch processing
334
+ """
335
+ global _grid_created, _cached_grid
336
+
337
+ # Determine mode based on label_suffix
338
+ intensity_only_mode = label_suffix == "" or label_suffix is None
339
+ mode_str = (
340
+ "intensity only"
341
+ if intensity_only_mode
342
+ else f"intensity + labels (suffix: '{label_suffix}')"
343
+ )
344
+
345
+ # If grid has already been created in this batch, return None to skip saving
346
+ if _grid_created:
347
+ return None
348
+
349
+ if not _HAS_TIFFFILE:
350
+ print(
351
+ "⚠️ tifffile not available. Please install it: pip install tifffile"
352
+ )
353
+ return image
354
+
355
+ # Mark that we're creating the grid to prevent concurrent calls
356
+ _grid_created = True
357
+
358
+ # Suppress any stdout from this point to avoid verbose output
359
+ import io
360
+ import sys
361
+
362
+ old_stdout = sys.stdout
363
+ sys.stdout = io.StringIO()
364
+
365
+ try:
366
+ # Get the filepath, files list, and output folder from the call stack
367
+ current_filepath = None
368
+ file_list = None
369
+ output_folder = None
370
+
371
+ for frame_info in inspect.stack():
372
+ frame_locals = frame_info.frame.f_locals
373
+ if "filepath" in frame_locals:
374
+ current_filepath = Path(frame_locals["filepath"])
375
+ if "file_list" in frame_locals:
376
+ file_list = frame_locals["file_list"]
377
+ if "self" in frame_locals:
378
+ obj = frame_locals["self"]
379
+ if hasattr(obj, "output_folder"):
380
+ output_folder = obj.output_folder
381
+ if (
382
+ current_filepath is not None
383
+ and file_list is not None
384
+ and output_folder is not None
385
+ ):
386
+ break
387
+ finally:
388
+ # Restore stdout
389
+ sys.stdout = old_stdout
390
+
391
+ if current_filepath is None:
392
+ print("⚠️ Could not determine current file path")
393
+ return image
394
+
395
+ label_folder = current_filepath.parent
396
+
397
+ # Use output folder from batch processing if available; default to parent folder
398
+ if output_folder is None:
399
+ output_folder = str(label_folder)
400
+
401
+ output_folder = Path(output_folder)
402
+
403
+ # When the batch UI output matches the input folder (blank value), save to parent
404
+ try:
405
+ same_as_input = output_folder.resolve() == label_folder.resolve()
406
+ except FileNotFoundError:
407
+ same_as_input = False
408
+
409
+ if same_as_input:
410
+ output_folder = label_folder.parent
411
+
412
+ output_folder.mkdir(parents=True, exist_ok=True)
413
+
414
+ # Use the file_list from batch processing if available
415
+ if file_list is not None:
416
+ label_files = [Path(f) for f in file_list]
417
+ else:
418
+ # Fallback: determine files based on mode
419
+ label_files = []
420
+
421
+ if intensity_only_mode:
422
+ # Intensity-only mode: use all TIFF files in folder
423
+ patterns = ["*.tif", "*.tiff"]
424
+ else:
425
+ # Label mode: honor user-provided suffix while keeping legacy patterns
426
+ patterns = []
427
+ if label_suffix:
428
+ suffixes = {label_suffix}
429
+ if label_suffix.lower().endswith(".tif"):
430
+ suffixes.add(label_suffix[:-4] + ".tiff")
431
+ for suffix in suffixes:
432
+ patterns.append(f"*{suffix}")
433
+
434
+ # Legacy fallback patterns
435
+ patterns.extend(["*_labels*.tif", "*_labels*.tiff"])
436
+
437
+ for pattern in patterns:
438
+ label_files.extend(label_folder.glob(pattern))
439
+
440
+ if not label_files:
441
+ msg = "intensity files" if intensity_only_mode else "label files"
442
+ print(f"⚠️ No {msg} found in folder")
443
+ return image
444
+
445
+ # Filter out any grid overlay files to prevent reprocessing
446
+ label_files = [f for f in label_files if "_grid_overlay" not in f.name]
447
+
448
+ # Deduplicate and sort for deterministic ordering
449
+ label_files = sorted(set(label_files))
450
+
451
+ if not label_files:
452
+ print("⚠️ No valid label files found after filtering")
453
+ return image
454
+
455
+ # Calculate square grid dimensions
456
+ import math
457
+
458
+ # For square grid: use sqrt to get equal rows and columns
459
+ grid_cols = max(1, math.ceil(math.sqrt(len(label_files))))
460
+ grid_rows = grid_cols # Square grid
461
+
462
+ # Target final dimensions (aim for ~12000px max dimension for PNG compatibility)
463
+ # Square grid means we can use same calculation for both dimensions
464
+ max_grid_dimension = 12000
465
+ target_per_image = max_grid_dimension // grid_cols
466
+
467
+ # Clamp to reasonable range (not too small, not too large)
468
+ target_per_image = max(100, min(target_per_image, 500))
469
+
470
+ print(
471
+ f"\n📊 Processing {len(label_files)} images → {target_per_image}px per image, {grid_cols}×{grid_rows} grid (square), {mode_str}"
472
+ )
473
+
474
+ # Create overlays for each pair using parallel processing
475
+ def process_image_pair(file_path):
476
+ """Process a single image file (with or without labels)."""
477
+ filename = file_path.name
478
+
479
+ if intensity_only_mode:
480
+ # Intensity-only mode: use the file itself as intensity, no labels
481
+ intensity_path = file_path
482
+ label_path = None
483
+ else:
484
+ # Label mode: file is a label, find corresponding intensity
485
+ intensity_filename = _get_intensity_filename(filename)
486
+ intensity_path = label_folder / intensity_filename
487
+ label_path = file_path
488
+
489
+ if not intensity_path.exists():
490
+ return (
491
+ None,
492
+ f"⚠️ Skipping {filename}: no intensity image found",
493
+ )
494
+
495
+ try:
496
+ # Load intensity image
497
+ intensity_img = tifffile.imread(str(intensity_path))
498
+
499
+ # Load label image if in label mode
500
+ if label_path is not None:
501
+ label_img = tifffile.imread(str(label_path))
502
+ else:
503
+ label_img = None
504
+
505
+ # Handle 3D data by taking max projection
506
+ if len(intensity_img.shape) > 2:
507
+ intensity_img = np.max(intensity_img, axis=0)
508
+
509
+ if label_img is not None:
510
+ if len(label_img.shape) > 2:
511
+ label_img = np.max(label_img, axis=0)
512
+
513
+ # Check for labels
514
+ unique_labels = np.unique(label_img)
515
+ n_labels = len(unique_labels[unique_labels > 0])
516
+
517
+ # Ensure matching dimensions
518
+ if intensity_img.shape != label_img.shape:
519
+ return None, (
520
+ f"⚠️ Skipping {filename}: dimension mismatch "
521
+ f"(intensity: {intensity_img.shape}, labels: {label_img.shape})"
522
+ )
523
+ else:
524
+ # Intensity-only mode: create dummy zero label image
525
+ label_img = np.zeros(intensity_img.shape, dtype=np.uint16)
526
+ n_labels = 0
527
+
528
+ # Create overlay with intelligent downsampling and 60% label opacity
529
+ # When label_img is all zeros (intensity_only_mode), this creates grayscale output
530
+ overlay = _create_overlay(
531
+ intensity_img,
532
+ label_img,
533
+ target_size=target_per_image,
534
+ label_opacity=0.6,
535
+ show_overlay=(not intensity_only_mode),
536
+ )
537
+
538
+ # Explicitly delete large arrays to free memory immediately
539
+ del intensity_img, label_img
540
+
541
+ return (overlay, n_labels), None
542
+
543
+ except (FileNotFoundError, OSError) as e:
544
+ return None, f"⚠️ Error processing {filename}: {e}"
545
+
546
+ # Process in parallel with ThreadPoolExecutor
547
+ # Use max 4 workers for better memory management with large datasets
548
+ max_workers = min(4, os.cpu_count() or 4)
549
+
550
+ # Process in batches to manage memory
551
+ batch_size = 100 # Process 100 images at a time
552
+ sorted_label_files = sorted(label_files)
553
+ all_overlays = []
554
+ valid_pairs = 0
555
+ errors = []
556
+ total_labels = 0
557
+ images_with_labels = 0
558
+
559
+ # Progress bar for overall processing
560
+ with tqdm(
561
+ total=len(sorted_label_files), desc="Creating overlays", unit="pair"
562
+ ) as pbar:
563
+ for batch_start in range(0, len(sorted_label_files), batch_size):
564
+ batch_end = min(batch_start + batch_size, len(sorted_label_files))
565
+ batch_files = sorted_label_files[batch_start:batch_end]
566
+
567
+ batch_overlays = []
568
+
569
+ with concurrent.futures.ThreadPoolExecutor(
570
+ max_workers=max_workers
571
+ ) as executor:
572
+ # Submit batch tasks
573
+ future_to_path = {
574
+ executor.submit(process_image_pair, label_path): label_path
575
+ for label_path in batch_files
576
+ }
577
+
578
+ # Collect batch results as they complete
579
+ for future in concurrent.futures.as_completed(future_to_path):
580
+ result, error_msg = future.result()
581
+
582
+ if error_msg:
583
+ errors.append(error_msg)
584
+ elif result is not None:
585
+ overlay, n_labels = result
586
+ batch_overlays.append(overlay)
587
+ valid_pairs += 1
588
+ total_labels += n_labels
589
+ if n_labels > 0:
590
+ images_with_labels += 1
591
+
592
+ pbar.update(1)
593
+
594
+ # Add batch results to main list
595
+ all_overlays.extend(batch_overlays)
596
+
597
+ # Clear batch to free memory
598
+ del batch_overlays
599
+
600
+ # Print diagnostics after progress bar completes
601
+ print(f"\n✓ Processed {valid_pairs} images")
602
+ if not intensity_only_mode:
603
+ print(
604
+ f" Labels found: {images_with_labels}/{valid_pairs} images ({total_labels} total labels)"
605
+ )
606
+
607
+ if total_labels == 0:
608
+ print(" ⚠️ WARNING: No labels detected in any image!")
609
+ print(
610
+ " Output will be grayscale intensity only (no colored regions)"
611
+ )
612
+ else:
613
+ print(" Mode: Intensity only (no labels)")
614
+
615
+ if errors:
616
+ print(f"\n⚠️ {len(errors)} files skipped:")
617
+ for error in errors[:10]: # Show first 10 errors
618
+ print(f" {error}")
619
+ if len(errors) > 10:
620
+ print(f" ... and {len(errors) - 10} more")
621
+
622
+ if not all_overlays:
623
+ print("⚠️ No valid image pairs found")
624
+ return image
625
+
626
+ overlays = all_overlays
627
+
628
+ mode_desc = (
629
+ "intensity only"
630
+ if intensity_only_mode
631
+ else "intensity + colored labels at 60% opacity"
632
+ )
633
+ print(
634
+ f"\n✨ Creating final grid: {valid_pairs} images, {grid_cols}×{grid_cols} square grid ({mode_desc})"
635
+ )
636
+
637
+ # Create grid
638
+ grid = _create_grid(overlays, grid_cols=grid_cols)
639
+
640
+ if grid is None:
641
+ print("⚠️ ERROR: Grid creation returned None!")
642
+ return image
643
+
644
+ print(f"✅ Complete! Grid shape: {grid.shape}")
645
+
646
+ # Cache the result for subsequent calls in the same batch
647
+ _cached_grid = grid
648
+
649
+ # Save the grid to file (only once)
650
+ global _grid_saved, _grid_output_path
651
+ if not _grid_saved:
652
+ # Save as compressed TIF for napari viewing
653
+ output_filename = f"{sorted_label_files[0].stem}_grid_overlay.tif"
654
+ output_path = output_folder / output_filename
655
+
656
+ try:
657
+ # Ensure output directory exists
658
+ output_folder.mkdir(parents=True, exist_ok=True)
659
+
660
+ # Save as compressed TIFF
661
+ tifffile.imwrite(
662
+ str(output_path),
663
+ grid,
664
+ compression="zlib",
665
+ compressionargs={"level": 6},
666
+ )
667
+ _grid_output_path = str(output_path)
668
+ _grid_saved = True
669
+
670
+ # Verify file was actually saved
671
+ if output_path.exists():
672
+ file_size = output_path.stat().st_size
673
+ print("\n" + "=" * 80)
674
+ print("💾 SAVED GRID IMAGE TO:")
675
+ print(f" {output_path}")
676
+ print(f" File size: {file_size / 1024 / 1024:.2f} MB")
677
+ print(" (Compressed TIF format)")
678
+ print("=" * 80 + "\n")
679
+ else:
680
+ print(
681
+ f"⚠️ WARNING: File save appeared to succeed but file not found at {output_path}"
682
+ )
683
+ except (
684
+ OSError,
685
+ RuntimeError,
686
+ ValueError,
687
+ tifffile.TiffFileError,
688
+ ) as e:
689
+ print(f"⚠️ Error saving grid: {type(e).__name__}: {e}")
690
+ import traceback
691
+
692
+ traceback.print_exc()
693
+
694
+ return grid
695
+
696
+
697
+ def reset_grid_cache():
698
+ """Reset the grid creation cache for a new batch run."""
699
+ global _grid_created, _cached_grid, _grid_saved, _grid_output_path
700
+ _grid_created = False
701
+ _cached_grid = None
702
+ _grid_saved = False
703
+ _grid_output_path = None