napari-tmidas 0.2.2__py3-none-any.whl → 0.2.4__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.
- napari_tmidas/__init__.py +35 -5
- napari_tmidas/_crop_anything.py +1520 -609
- napari_tmidas/_env_manager.py +76 -0
- napari_tmidas/_file_conversion.py +1646 -1131
- napari_tmidas/_file_selector.py +1455 -216
- napari_tmidas/_label_inspection.py +83 -8
- napari_tmidas/_processing_worker.py +309 -0
- napari_tmidas/_reader.py +6 -10
- napari_tmidas/_registry.py +2 -2
- napari_tmidas/_roi_colocalization.py +1221 -84
- napari_tmidas/_tests/test_crop_anything.py +123 -0
- napari_tmidas/_tests/test_env_manager.py +89 -0
- napari_tmidas/_tests/test_grid_view_overlay.py +193 -0
- napari_tmidas/_tests/test_init.py +98 -0
- napari_tmidas/_tests/test_intensity_label_filter.py +222 -0
- napari_tmidas/_tests/test_label_inspection.py +86 -0
- napari_tmidas/_tests/test_processing_basic.py +500 -0
- napari_tmidas/_tests/test_processing_worker.py +142 -0
- napari_tmidas/_tests/test_regionprops_analysis.py +547 -0
- napari_tmidas/_tests/test_registry.py +70 -2
- napari_tmidas/_tests/test_scipy_filters.py +168 -0
- napari_tmidas/_tests/test_skimage_filters.py +259 -0
- napari_tmidas/_tests/test_split_channels.py +217 -0
- napari_tmidas/_tests/test_spotiflow.py +87 -0
- napari_tmidas/_tests/test_tyx_display_fix.py +142 -0
- napari_tmidas/_tests/test_ui_utils.py +68 -0
- napari_tmidas/_tests/test_widget.py +30 -0
- napari_tmidas/_tests/test_windows_basic.py +66 -0
- napari_tmidas/_ui_utils.py +57 -0
- napari_tmidas/_version.py +16 -3
- napari_tmidas/_widget.py +41 -4
- napari_tmidas/processing_functions/basic.py +557 -20
- napari_tmidas/processing_functions/careamics_env_manager.py +72 -99
- napari_tmidas/processing_functions/cellpose_env_manager.py +415 -112
- napari_tmidas/processing_functions/cellpose_segmentation.py +132 -191
- napari_tmidas/processing_functions/colocalization.py +513 -56
- napari_tmidas/processing_functions/grid_view_overlay.py +703 -0
- napari_tmidas/processing_functions/intensity_label_filter.py +422 -0
- napari_tmidas/processing_functions/regionprops_analysis.py +1280 -0
- napari_tmidas/processing_functions/sam2_env_manager.py +53 -69
- napari_tmidas/processing_functions/sam2_mp4.py +274 -195
- napari_tmidas/processing_functions/scipy_filters.py +403 -8
- napari_tmidas/processing_functions/skimage_filters.py +424 -212
- napari_tmidas/processing_functions/spotiflow_detection.py +949 -0
- napari_tmidas/processing_functions/spotiflow_env_manager.py +591 -0
- napari_tmidas/processing_functions/timepoint_merger.py +334 -86
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/METADATA +70 -30
- napari_tmidas-0.2.4.dist-info/RECORD +63 -0
- napari_tmidas/_tests/__init__.py +0 -0
- napari_tmidas-0.2.2.dist-info/RECORD +0 -40
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/WHEEL +0 -0
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/entry_points.txt +0 -0
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.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
|