napari-tmidas 0.1.5__py3-none-any.whl → 0.1.7__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 +3 -0
- napari_tmidas/_crop_anything.py +1113 -0
- napari_tmidas/_file_conversion.py +488 -256
- napari_tmidas/_file_selector.py +267 -101
- napari_tmidas/_label_inspection.py +10 -0
- napari_tmidas/_roi_colocalization.py +1175 -0
- napari_tmidas/_version.py +2 -2
- napari_tmidas/napari.yaml +10 -0
- napari_tmidas/processing_functions/basic.py +83 -0
- napari_tmidas/processing_functions/colocalization.py +242 -0
- napari_tmidas/processing_functions/skimage_filters.py +17 -32
- {napari_tmidas-0.1.5.dist-info → napari_tmidas-0.1.7.dist-info}/METADATA +44 -14
- napari_tmidas-0.1.7.dist-info/RECORD +29 -0
- napari_tmidas-0.1.5.dist-info/RECORD +0 -26
- {napari_tmidas-0.1.5.dist-info → napari_tmidas-0.1.7.dist-info}/WHEEL +0 -0
- {napari_tmidas-0.1.5.dist-info → napari_tmidas-0.1.7.dist-info}/entry_points.txt +0 -0
- {napari_tmidas-0.1.5.dist-info → napari_tmidas-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {napari_tmidas-0.1.5.dist-info → napari_tmidas-0.1.7.dist-info}/top_level.txt +0 -0
|
@@ -1,16 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Batch Microscopy Image File Conversion
|
|
3
|
+
=======================================
|
|
4
|
+
This module provides a GUI for batch conversion of microscopy image files to a common format.
|
|
5
|
+
The user can select a folder containing microscopy image files, preview the images, and convert them to an open format for image processing.
|
|
6
|
+
The supported input formats include Leica LIF, Nikon ND2, Zeiss CZI, and TIFF-based whole slide images (NDPI, etc.).
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
|
|
1
10
|
import concurrent.futures
|
|
2
11
|
import os
|
|
3
12
|
import re
|
|
13
|
+
import shutil
|
|
4
14
|
from pathlib import Path
|
|
5
15
|
from typing import Dict, List, Optional, Tuple
|
|
6
16
|
|
|
17
|
+
import dask.array as da
|
|
7
18
|
import napari
|
|
8
19
|
import nd2 # https://github.com/tlambert03/nd2
|
|
9
20
|
import numpy as np
|
|
10
21
|
import tifffile
|
|
11
22
|
import zarr
|
|
23
|
+
from dask.diagnostics import ProgressBar
|
|
12
24
|
from magicgui import magicgui
|
|
13
|
-
from ome_zarr.
|
|
25
|
+
from ome_zarr.io import parse_url
|
|
26
|
+
from ome_zarr.writer import write_image
|
|
14
27
|
from pylibCZIrw import czi # https://github.com/ZEISS/pylibczirw
|
|
15
28
|
from qtpy.QtCore import Qt, QThread, Signal
|
|
16
29
|
from qtpy.QtWidgets import (
|
|
@@ -183,17 +196,30 @@ class SeriesDetailWidget(QWidget):
|
|
|
183
196
|
|
|
184
197
|
# Set info text
|
|
185
198
|
if series_count > 0:
|
|
186
|
-
metadata = file_loader.get_metadata(filepath, 0)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
199
|
+
# metadata = file_loader.get_metadata(filepath, 0)
|
|
200
|
+
|
|
201
|
+
# Estimate file size and set appropriate format radio button
|
|
202
|
+
file_type = self.parent.get_file_type(filepath)
|
|
203
|
+
if file_type == "ND2":
|
|
204
|
+
try:
|
|
205
|
+
with nd2.ND2File(filepath) as nd2_file:
|
|
206
|
+
dims = dict(nd2_file.sizes)
|
|
207
|
+
pixel_size = nd2_file.dtype.itemsize
|
|
208
|
+
total_elements = np.prod(
|
|
209
|
+
[dims[dim] for dim in dims]
|
|
210
|
+
)
|
|
211
|
+
size_GB = (total_elements * pixel_size) / (
|
|
212
|
+
1024**3
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
self.info_label.setText(
|
|
216
|
+
f"File contains {series_count} series (size: {size_GB:.2f}GB)"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Update format buttons
|
|
220
|
+
self.parent.update_format_buttons(size_GB > 4)
|
|
221
|
+
except (ValueError, FileNotFoundError) as e:
|
|
222
|
+
print(f"Error estimating file size: {e}")
|
|
197
223
|
except FileNotFoundError:
|
|
198
224
|
self.info_label.setText("File not found.")
|
|
199
225
|
except PermissionError:
|
|
@@ -220,6 +246,30 @@ class SeriesDetailWidget(QWidget):
|
|
|
220
246
|
# Update parent with selected series
|
|
221
247
|
self.parent.set_selected_series(self.current_file, series_index)
|
|
222
248
|
|
|
249
|
+
# Automatically set the appropriate format radio button based on file size
|
|
250
|
+
file_loader = self.parent.get_file_loader(self.current_file)
|
|
251
|
+
if file_loader:
|
|
252
|
+
try:
|
|
253
|
+
# Estimate file size based on metadata
|
|
254
|
+
# metadata = file_loader.get_metadata(
|
|
255
|
+
# self.current_file, series_index
|
|
256
|
+
# )
|
|
257
|
+
|
|
258
|
+
# For ND2 files, we can directly check the size
|
|
259
|
+
if self.parent.get_file_type(self.current_file) == "ND2":
|
|
260
|
+
with nd2.ND2File(self.current_file) as nd2_file:
|
|
261
|
+
dims = dict(nd2_file.sizes)
|
|
262
|
+
pixel_size = nd2_file.dtype.itemsize
|
|
263
|
+
total_elements = np.prod(
|
|
264
|
+
[dims[dim] for dim in dims]
|
|
265
|
+
)
|
|
266
|
+
size_GB = (total_elements * pixel_size) / (1024**3)
|
|
267
|
+
|
|
268
|
+
# Automatically set the appropriate radio button based on size
|
|
269
|
+
self.parent.update_format_buttons(size_GB > 4)
|
|
270
|
+
except (ValueError, FileNotFoundError) as e:
|
|
271
|
+
print(f"Error estimating file size: {e}")
|
|
272
|
+
|
|
223
273
|
def preview_series(self):
|
|
224
274
|
"""Preview the selected series in Napari"""
|
|
225
275
|
if self.current_file and self.series_selector.currentIndex() >= 0:
|
|
@@ -237,11 +287,25 @@ class SeriesDetailWidget(QWidget):
|
|
|
237
287
|
file_loader = self.parent.get_file_loader(self.current_file)
|
|
238
288
|
|
|
239
289
|
try:
|
|
290
|
+
# First get metadata to understand dimensions
|
|
291
|
+
metadata = file_loader.get_metadata(
|
|
292
|
+
self.current_file, series_index
|
|
293
|
+
)
|
|
294
|
+
|
|
240
295
|
# Load the series
|
|
241
296
|
image_data = file_loader.load_series(
|
|
242
297
|
self.current_file, series_index
|
|
243
298
|
)
|
|
244
299
|
|
|
300
|
+
# Reorder dimensions for Napari based on metadata
|
|
301
|
+
if metadata and "axes" in metadata:
|
|
302
|
+
print(f"File has dimension order: {metadata['axes']}")
|
|
303
|
+
# Target dimension order for Napari
|
|
304
|
+
napari_order = "CTZYX"[: len(image_data.shape)]
|
|
305
|
+
image_data = self._reorder_dimensions(
|
|
306
|
+
image_data, metadata, napari_order
|
|
307
|
+
)
|
|
308
|
+
|
|
245
309
|
# Clear existing layers and display the image
|
|
246
310
|
self.viewer.layers.clear()
|
|
247
311
|
self.viewer.add_image(
|
|
@@ -257,6 +321,67 @@ class SeriesDetailWidget(QWidget):
|
|
|
257
321
|
self, "Error", f"Could not load series: {str(e)}"
|
|
258
322
|
)
|
|
259
323
|
|
|
324
|
+
def _reorder_dimensions(self, image_data, metadata, target_order="YXZTC"):
|
|
325
|
+
"""Reorder dimensions based on metadata axes information
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
image_data: The numpy or dask array to reorder
|
|
329
|
+
metadata: Metadata dictionary containing axes information
|
|
330
|
+
target_order: Target dimension order (e.g., "YXZTC")
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
Reordered array
|
|
334
|
+
"""
|
|
335
|
+
# Early exit if no metadata or no axes information
|
|
336
|
+
if not metadata or "axes" not in metadata:
|
|
337
|
+
print("No axes information in metadata - returning original")
|
|
338
|
+
return image_data
|
|
339
|
+
|
|
340
|
+
# Get source order from metadata
|
|
341
|
+
source_order = metadata["axes"]
|
|
342
|
+
|
|
343
|
+
# Ensure dimensions match
|
|
344
|
+
ndim = len(image_data.shape)
|
|
345
|
+
if len(source_order) != ndim:
|
|
346
|
+
print(
|
|
347
|
+
f"Dimension mismatch - array has {ndim} dims but axes metadata indicates {len(source_order)}"
|
|
348
|
+
)
|
|
349
|
+
return image_data
|
|
350
|
+
|
|
351
|
+
# Ensure target order has the same number of dimensions
|
|
352
|
+
if len(target_order) != ndim:
|
|
353
|
+
print(
|
|
354
|
+
f"Target order {target_order} doesn't match array dimensions {ndim}"
|
|
355
|
+
)
|
|
356
|
+
return image_data
|
|
357
|
+
|
|
358
|
+
# Create reordering index list
|
|
359
|
+
reorder_indices = []
|
|
360
|
+
for axis in target_order:
|
|
361
|
+
if axis in source_order:
|
|
362
|
+
reorder_indices.append(source_order.index(axis))
|
|
363
|
+
else:
|
|
364
|
+
print(f"Axis {axis} not found in source order {source_order}")
|
|
365
|
+
return image_data
|
|
366
|
+
|
|
367
|
+
# Reorder the array using appropriate method
|
|
368
|
+
try:
|
|
369
|
+
print(f"Reordering from {source_order} to {target_order}")
|
|
370
|
+
|
|
371
|
+
# Check if using Dask array
|
|
372
|
+
if hasattr(image_data, "dask"):
|
|
373
|
+
# Use Dask's transpose to preserve lazy computation
|
|
374
|
+
reordered = image_data.transpose(reorder_indices)
|
|
375
|
+
else:
|
|
376
|
+
# Use numpy transpose
|
|
377
|
+
reordered = np.transpose(image_data, reorder_indices)
|
|
378
|
+
|
|
379
|
+
print(f"Reordered shape: {reordered.shape}")
|
|
380
|
+
return reordered
|
|
381
|
+
except (ValueError, IndexError) as e:
|
|
382
|
+
print(f"Error reordering dimensions: {e}")
|
|
383
|
+
return image_data
|
|
384
|
+
|
|
260
385
|
|
|
261
386
|
class FormatLoader:
|
|
262
387
|
"""Base class for format loaders"""
|
|
@@ -372,7 +497,6 @@ class ND2Loader(FormatLoader):
|
|
|
372
497
|
|
|
373
498
|
@staticmethod
|
|
374
499
|
def get_series_count(filepath: str) -> int:
|
|
375
|
-
|
|
376
500
|
# ND2 files typically have a single series with multiple channels/dimensions
|
|
377
501
|
return 1
|
|
378
502
|
|
|
@@ -381,9 +505,29 @@ class ND2Loader(FormatLoader):
|
|
|
381
505
|
if series_index != 0:
|
|
382
506
|
raise ValueError("ND2 files only support series index 0")
|
|
383
507
|
|
|
508
|
+
# First open the file to check metadata
|
|
384
509
|
with nd2.ND2File(filepath) as nd2_file:
|
|
385
|
-
#
|
|
386
|
-
|
|
510
|
+
# Calculate size in GB
|
|
511
|
+
total_size = np.prod(
|
|
512
|
+
[nd2_file.sizes[dim] for dim in nd2_file.sizes]
|
|
513
|
+
)
|
|
514
|
+
pixel_size = nd2_file.dtype.itemsize
|
|
515
|
+
size_GB = (total_size * pixel_size) / (1024**3)
|
|
516
|
+
|
|
517
|
+
print(f"ND2 file dimensions: {nd2_file.sizes}")
|
|
518
|
+
print(f"Pixel type: {nd2_file.dtype}, size: {pixel_size} bytes")
|
|
519
|
+
print(f"Estimated file size: {size_GB:.2f} GB")
|
|
520
|
+
|
|
521
|
+
if size_GB > 4:
|
|
522
|
+
print("Using Dask for large file")
|
|
523
|
+
# Load as Dask array for large files
|
|
524
|
+
data = nd2.imread(filepath, dask=True)
|
|
525
|
+
return data
|
|
526
|
+
else:
|
|
527
|
+
print("Using direct loading for smaller file")
|
|
528
|
+
# Load directly into memory for smaller files
|
|
529
|
+
data = nd2.imread(filepath)
|
|
530
|
+
return data
|
|
387
531
|
|
|
388
532
|
@staticmethod
|
|
389
533
|
def get_metadata(filepath: str, series_index: int) -> Dict:
|
|
@@ -391,16 +535,60 @@ class ND2Loader(FormatLoader):
|
|
|
391
535
|
return {}
|
|
392
536
|
|
|
393
537
|
with nd2.ND2File(filepath) as nd2_file:
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
538
|
+
# Get all dimensions and their sizes
|
|
539
|
+
dims = dict(nd2_file.sizes)
|
|
540
|
+
|
|
541
|
+
# Create a more detailed axes representation
|
|
542
|
+
axes = "".join(dims.keys())
|
|
543
|
+
|
|
544
|
+
print(f"ND2 metadata - dims: {dims}")
|
|
545
|
+
print(f"ND2 metadata - axes: {axes}")
|
|
546
|
+
|
|
547
|
+
# Get spatial dimensions and convert to resolution
|
|
548
|
+
try:
|
|
549
|
+
voxel = nd2_file.voxel_size()
|
|
550
|
+
x_res = 1 / voxel.x if voxel.x > 0 else 1.0
|
|
551
|
+
y_res = 1 / voxel.y if voxel.y > 0 else 1.0
|
|
552
|
+
z_spacing = 1 / voxel.z if voxel.z > 0 else 1.0
|
|
553
|
+
|
|
554
|
+
print(f"Voxel size: x={voxel.x}, y={voxel.y}, z={voxel.z}")
|
|
555
|
+
except (ValueError, AttributeError) as e:
|
|
556
|
+
print(f"Error getting voxel size: {e}")
|
|
557
|
+
x_res, y_res, z_spacing = 1.0, 1.0, 1.0
|
|
558
|
+
|
|
559
|
+
# Create scale information for all dimensions
|
|
560
|
+
scales = {}
|
|
561
|
+
if "T" in dims:
|
|
562
|
+
scales["scale_t"] = 1.0
|
|
563
|
+
if "Z" in dims:
|
|
564
|
+
scales["scale_z"] = z_spacing
|
|
565
|
+
if "C" in dims:
|
|
566
|
+
scales["scale_c"] = 1.0
|
|
567
|
+
if "Y" in dims:
|
|
568
|
+
scales["scale_y"] = y_res
|
|
569
|
+
if "X" in dims:
|
|
570
|
+
scales["scale_x"] = x_res
|
|
571
|
+
|
|
572
|
+
# Build comprehensive metadata
|
|
573
|
+
metadata = {
|
|
574
|
+
"axes": axes,
|
|
575
|
+
"dimensions": dims,
|
|
576
|
+
"resolution": (x_res, y_res),
|
|
400
577
|
"unit": "um",
|
|
401
|
-
"spacing":
|
|
578
|
+
"spacing": z_spacing,
|
|
579
|
+
**scales, # Add all scale information
|
|
402
580
|
}
|
|
403
581
|
|
|
582
|
+
# Add extra metadata for zarr transformations
|
|
583
|
+
ordered_scales = []
|
|
584
|
+
for ax in axes:
|
|
585
|
+
scale_key = f"scale_{ax.lower()}"
|
|
586
|
+
ordered_scales.append(scales.get(scale_key, 1.0))
|
|
587
|
+
|
|
588
|
+
metadata["scales"] = ordered_scales
|
|
589
|
+
|
|
590
|
+
return metadata
|
|
591
|
+
|
|
404
592
|
|
|
405
593
|
class TIFFSlideLoader(FormatLoader):
|
|
406
594
|
"""Loader for whole slide TIFF images (NDPI, etc.)"""
|
|
@@ -915,10 +1103,21 @@ class ConversionWorker(QThread):
|
|
|
915
1103
|
estimated_size_bytes = (
|
|
916
1104
|
np.prod(image_data.shape) * image_data.itemsize
|
|
917
1105
|
)
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
)
|
|
921
|
-
|
|
1106
|
+
file_size_GB = estimated_size_bytes / (1024**3)
|
|
1107
|
+
|
|
1108
|
+
# If file is very large (>4GB), force zarr format regardless of setting
|
|
1109
|
+
use_zarr = self.use_zarr
|
|
1110
|
+
if file_size_GB > 4:
|
|
1111
|
+
use_zarr = True
|
|
1112
|
+
if not self.use_zarr:
|
|
1113
|
+
print(
|
|
1114
|
+
f"File size ({file_size_GB:.2f}GB) exceeds 4GB limit for TIF, automatically using ZARR format"
|
|
1115
|
+
)
|
|
1116
|
+
self.file_done.emit(
|
|
1117
|
+
filepath,
|
|
1118
|
+
True,
|
|
1119
|
+
f"File size ({file_size_GB:.2f}GB) exceeds 4GB, using ZARR format",
|
|
1120
|
+
)
|
|
922
1121
|
# Set up the output path
|
|
923
1122
|
if use_zarr:
|
|
924
1123
|
output_path = os.path.join(
|
|
@@ -937,48 +1136,25 @@ class ConversionWorker(QThread):
|
|
|
937
1136
|
|
|
938
1137
|
try:
|
|
939
1138
|
if use_zarr:
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
self._save_zarr(
|
|
944
|
-
image_data, output_path, metadata
|
|
945
|
-
)
|
|
946
|
-
else:
|
|
947
|
-
self._save_zarr(image_data, output_path)
|
|
948
|
-
except (ValueError, FileNotFoundError) as e:
|
|
949
|
-
print(
|
|
950
|
-
f"Warning: Failed to save with metadata, trying without: {str(e)}"
|
|
951
|
-
)
|
|
952
|
-
# If that fails, try without metadata
|
|
953
|
-
self._save_zarr(image_data, output_path, None)
|
|
1139
|
+
save_success = self._save_zarr(
|
|
1140
|
+
image_data, output_path, metadata
|
|
1141
|
+
)
|
|
954
1142
|
else:
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
)
|
|
967
|
-
# If that fails, try without metadata
|
|
968
|
-
self._save_tif(image_data, output_path, None)
|
|
1143
|
+
self._save_tif(image_data, output_path, metadata)
|
|
1144
|
+
save_success = os.path.exists(output_path)
|
|
1145
|
+
|
|
1146
|
+
if save_success:
|
|
1147
|
+
success_count += 1
|
|
1148
|
+
self.file_done.emit(
|
|
1149
|
+
filepath, True, f"Saved to {output_path}"
|
|
1150
|
+
)
|
|
1151
|
+
else:
|
|
1152
|
+
error_message = "Failed to save file - unknown error"
|
|
1153
|
+
self.file_done.emit(filepath, False, error_message)
|
|
969
1154
|
|
|
970
|
-
save_success = True
|
|
971
1155
|
except (ValueError, FileNotFoundError) as e:
|
|
972
1156
|
error_message = f"Failed to save file: {str(e)}"
|
|
973
1157
|
print(f"Error in save operation: {error_message}")
|
|
974
|
-
save_success = False
|
|
975
|
-
|
|
976
|
-
if save_success:
|
|
977
|
-
success_count += 1
|
|
978
|
-
self.file_done.emit(
|
|
979
|
-
filepath, True, f"Saved to {output_path}"
|
|
980
|
-
)
|
|
981
|
-
else:
|
|
982
1158
|
self.file_done.emit(filepath, False, error_message)
|
|
983
1159
|
|
|
984
1160
|
except (ValueError, FileNotFoundError) as e:
|
|
@@ -995,233 +1171,256 @@ class ConversionWorker(QThread):
|
|
|
995
1171
|
def _save_tif(
|
|
996
1172
|
self, image_data: np.ndarray, output_path: str, metadata: dict = None
|
|
997
1173
|
):
|
|
998
|
-
"""
|
|
999
|
-
|
|
1000
|
-
# Basic save without metadata
|
|
1001
|
-
if metadata is None:
|
|
1002
|
-
tifffile.imwrite(output_path, image_data, compression="zstd")
|
|
1003
|
-
return
|
|
1174
|
+
"""Enhanced TIF saving with proper dimension handling"""
|
|
1175
|
+
import tifffile
|
|
1004
1176
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
if "resolution" in metadata:
|
|
1008
|
-
resolution = tuple(float(r) for r in metadata["resolution"])
|
|
1177
|
+
print(f"Saving TIF file: {output_path}")
|
|
1178
|
+
print(f"Image data shape: {image_data.shape}")
|
|
1009
1179
|
|
|
1010
|
-
|
|
1180
|
+
if metadata:
|
|
1181
|
+
print(f"Metadata keys: {list(metadata.keys())}")
|
|
1182
|
+
if "axes" in metadata:
|
|
1183
|
+
print(f"Original axes: {metadata['axes']}")
|
|
1011
1184
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1185
|
+
# Handle Dask arrays
|
|
1186
|
+
if hasattr(image_data, "compute"):
|
|
1187
|
+
print("Computing Dask array before saving")
|
|
1188
|
+
# For large arrays, compute block by block
|
|
1189
|
+
try:
|
|
1190
|
+
# Convert to numpy array in memory
|
|
1191
|
+
image_data = image_data.compute()
|
|
1192
|
+
except (MemoryError, ValueError) as e:
|
|
1193
|
+
print(f"Error computing dask array: {e}")
|
|
1194
|
+
# Alternative: write block by block
|
|
1195
|
+
# This would require custom implementation
|
|
1196
|
+
raise
|
|
1197
|
+
|
|
1198
|
+
# Basic save if no metadata
|
|
1199
|
+
if metadata is None:
|
|
1200
|
+
print("No metadata provided, using basic save")
|
|
1201
|
+
tifffile.imwrite(output_path, image_data, compression="zstd")
|
|
1202
|
+
return
|
|
1016
1203
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
)
|
|
1204
|
+
# Get image dimensions and axis order
|
|
1205
|
+
ndim = len(image_data.shape)
|
|
1206
|
+
axes = metadata.get("axes", "")
|
|
1021
1207
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
print(f"Dropping axes: {set(axes)-set(imagej_order)}")
|
|
1026
|
-
source_idx = [
|
|
1027
|
-
i
|
|
1028
|
-
for i, ax in enumerate(axes)
|
|
1029
|
-
if ax in imagej_order
|
|
1030
|
-
]
|
|
1031
|
-
image_data = np.moveaxis(
|
|
1032
|
-
image_data, source_idx, range(len(valid_axes))
|
|
1033
|
-
)
|
|
1034
|
-
axes = "".join(valid_axes)
|
|
1208
|
+
print(f"Number of dimensions: {ndim}")
|
|
1209
|
+
if axes:
|
|
1210
|
+
print(f"Axes from metadata: {axes}")
|
|
1035
1211
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
axes = ax + axes
|
|
1041
|
-
image_data = np.expand_dims(image_data, axis=0)
|
|
1212
|
+
# Handle ImageJ compatibility for dimensions
|
|
1213
|
+
if ndim > 2:
|
|
1214
|
+
# Get target order for ImageJ
|
|
1215
|
+
imagej_order = "TZCYX"
|
|
1042
1216
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1217
|
+
# If axes information is incomplete, try to infer from shape
|
|
1218
|
+
if len(axes) != ndim:
|
|
1219
|
+
print(
|
|
1220
|
+
f"Warning: Axes length ({len(axes)}) doesn't match dimensions ({ndim})"
|
|
1221
|
+
)
|
|
1222
|
+
# For your specific case with shape (45, 101, 4, 1024, 1024)
|
|
1223
|
+
# Infer TZCYX if shape matches
|
|
1224
|
+
if (
|
|
1225
|
+
ndim == 5
|
|
1226
|
+
and image_data.shape[2] <= 10
|
|
1227
|
+
and image_data.shape[3] > 100
|
|
1228
|
+
and image_data.shape[4] > 100
|
|
1229
|
+
):
|
|
1230
|
+
print("Inferring TZCYX from shape")
|
|
1231
|
+
axes = "TZCYX"
|
|
1232
|
+
|
|
1233
|
+
if axes and axes != imagej_order:
|
|
1234
|
+
print(f"Reordering: {axes} -> {imagej_order}")
|
|
1235
|
+
|
|
1236
|
+
# Map dimensions from original to target order
|
|
1237
|
+
dim_map = {}
|
|
1238
|
+
for i, ax in enumerate(axes):
|
|
1239
|
+
if ax in imagej_order:
|
|
1240
|
+
dim_map[ax] = i
|
|
1241
|
+
|
|
1242
|
+
# Handle missing dimensions
|
|
1243
|
+
for ax in imagej_order:
|
|
1244
|
+
if ax not in dim_map:
|
|
1245
|
+
print(f"Adding missing dimension: {ax}")
|
|
1246
|
+
image_data = np.expand_dims(image_data, axis=0)
|
|
1247
|
+
dim_map[ax] = image_data.shape[0] - 1
|
|
1248
|
+
|
|
1249
|
+
# Create reordering indices
|
|
1250
|
+
source_idx = [dim_map[ax] for ax in imagej_order]
|
|
1251
|
+
target_idx = list(range(len(imagej_order)))
|
|
1252
|
+
|
|
1253
|
+
print(f"Reordering dimensions: {source_idx} -> {target_idx}")
|
|
1254
|
+
|
|
1255
|
+
# Reorder dimensions
|
|
1256
|
+
try:
|
|
1045
1257
|
image_data = np.moveaxis(
|
|
1046
|
-
image_data, source_idx,
|
|
1258
|
+
image_data, source_idx, target_idx
|
|
1047
1259
|
)
|
|
1048
|
-
|
|
1260
|
+
except (ValueError, IndexError) as e:
|
|
1261
|
+
print(f"Error reordering dimensions: {e}")
|
|
1262
|
+
# Fall back to simple save without reordering
|
|
1263
|
+
tifffile.imwrite(
|
|
1264
|
+
output_path, image_data, compression="zstd"
|
|
1265
|
+
)
|
|
1266
|
+
return
|
|
1267
|
+
|
|
1268
|
+
# Update axes information
|
|
1269
|
+
metadata["axes"] = imagej_order
|
|
1049
1270
|
|
|
1271
|
+
# Extract resolution information for ImageJ
|
|
1272
|
+
resolution = None
|
|
1273
|
+
if "resolution" in metadata:
|
|
1274
|
+
try:
|
|
1275
|
+
res_x, res_y = metadata["resolution"]
|
|
1276
|
+
resolution = (float(res_x), float(res_y))
|
|
1277
|
+
print(f"Using resolution: {resolution}")
|
|
1278
|
+
except (ValueError, TypeError) as e:
|
|
1279
|
+
print(f"Error processing resolution: {e}")
|
|
1280
|
+
|
|
1281
|
+
# Handle saving with metadata
|
|
1282
|
+
try:
|
|
1283
|
+
if ndim <= 2:
|
|
1284
|
+
# 2D case - simpler saving
|
|
1285
|
+
print("Saving as 2D image")
|
|
1050
1286
|
tifffile.imwrite(
|
|
1051
1287
|
output_path,
|
|
1052
1288
|
image_data,
|
|
1053
|
-
metadata=metadata,
|
|
1054
1289
|
resolution=resolution,
|
|
1055
|
-
imagej=True,
|
|
1056
1290
|
compression="zstd",
|
|
1057
1291
|
)
|
|
1058
1292
|
else:
|
|
1059
|
-
#
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1293
|
+
# Hyperstack case
|
|
1294
|
+
print("Saving as hyperstack with ImageJ metadata")
|
|
1295
|
+
|
|
1296
|
+
# Create clean metadata dict with only needed keys
|
|
1297
|
+
imagej_metadata = {}
|
|
1298
|
+
if "unit" in metadata:
|
|
1299
|
+
imagej_metadata["unit"] = metadata["unit"]
|
|
1300
|
+
if "spacing" in metadata:
|
|
1301
|
+
imagej_metadata["spacing"] = float(metadata["spacing"])
|
|
1302
|
+
|
|
1065
1303
|
tifffile.imwrite(
|
|
1066
1304
|
output_path,
|
|
1067
1305
|
image_data,
|
|
1068
|
-
|
|
1306
|
+
imagej=True,
|
|
1069
1307
|
resolution=resolution,
|
|
1070
|
-
|
|
1308
|
+
metadata=imagej_metadata,
|
|
1071
1309
|
compression="zstd",
|
|
1072
1310
|
)
|
|
1073
1311
|
|
|
1312
|
+
print(f"Successfully saved TIF file: {output_path}")
|
|
1074
1313
|
except (ValueError, FileNotFoundError) as e:
|
|
1075
|
-
print(f"Error: {
|
|
1314
|
+
print(f"Error saving TIF file: {e}")
|
|
1315
|
+
# Try simple save as fallback
|
|
1076
1316
|
tifffile.imwrite(output_path, image_data)
|
|
1077
1317
|
|
|
1078
1318
|
def _save_zarr(
|
|
1079
1319
|
self, image_data: np.ndarray, output_path: str, metadata: dict = None
|
|
1080
1320
|
):
|
|
1081
|
-
"""
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
target_chunk_size = 1024 * 1024 # 1MB
|
|
1085
|
-
item_size = image_data.itemsize
|
|
1086
|
-
chunks = self._calculate_chunks(
|
|
1087
|
-
image_data.shape, target_chunk_size, item_size
|
|
1088
|
-
)
|
|
1321
|
+
"""Enhanced ZARR saving with proper metadata storage and specific exceptions"""
|
|
1322
|
+
print(f"Saving ZARR file: {output_path}")
|
|
1323
|
+
print(f"Image data shape: {image_data.shape}")
|
|
1089
1324
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
default_axes[-ndim:]
|
|
1095
|
-
if ndim <= 5
|
|
1096
|
-
else "".join([f"D{i}" for i in range(ndim)])
|
|
1097
|
-
)
|
|
1325
|
+
metadata = metadata or {}
|
|
1326
|
+
print(
|
|
1327
|
+
f"Metadata keys: {list(metadata.keys()) if metadata else 'No metadata'}"
|
|
1328
|
+
)
|
|
1098
1329
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1330
|
+
# Handle overwriting by deleting the directory if it exists
|
|
1331
|
+
if os.path.exists(output_path):
|
|
1332
|
+
print(f"Deleting existing Zarr directory: {output_path}")
|
|
1333
|
+
shutil.rmtree(output_path)
|
|
1101
1334
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
if metadata:
|
|
1105
|
-
try:
|
|
1106
|
-
# Extract scale information if present
|
|
1107
|
-
scales = []
|
|
1108
|
-
for _i, ax in enumerate(axes):
|
|
1109
|
-
scale = 1.0 # Default scale
|
|
1110
|
-
|
|
1111
|
-
# Try to find scale for this axis
|
|
1112
|
-
scale_key = f"scale_{ax.lower()}"
|
|
1113
|
-
if scale_key in metadata:
|
|
1114
|
-
try:
|
|
1115
|
-
scale_value = float(metadata[scale_key])
|
|
1116
|
-
if scale_value > 0: # Only use valid values
|
|
1117
|
-
scale = scale_value
|
|
1118
|
-
except (ValueError, TypeError):
|
|
1119
|
-
pass
|
|
1120
|
-
|
|
1121
|
-
scales.append(scale)
|
|
1122
|
-
|
|
1123
|
-
# Only create transformations if we have non-default scales
|
|
1124
|
-
if any(s != 1.0 for s in scales):
|
|
1125
|
-
coordinate_transformations = [
|
|
1126
|
-
{"type": "scale", "scale": scales}
|
|
1127
|
-
]
|
|
1128
|
-
except (ValueError, FileNotFoundError) as e:
|
|
1129
|
-
print(
|
|
1130
|
-
f"Warning: Could not process coordinate transformations: {str(e)}"
|
|
1131
|
-
)
|
|
1132
|
-
coordinate_transformations = None
|
|
1133
|
-
|
|
1134
|
-
# Write the image data using the OME-Zarr writer
|
|
1135
|
-
write_options = {
|
|
1136
|
-
"image": image_data,
|
|
1137
|
-
"group": store,
|
|
1138
|
-
"axes": axes,
|
|
1139
|
-
"chunks": chunks,
|
|
1140
|
-
"compression": "zstd",
|
|
1141
|
-
"compression_opts": {"level": 3},
|
|
1142
|
-
}
|
|
1335
|
+
# Explicitly create a DirectoryStore
|
|
1336
|
+
store = parse_url(output_path, mode="w").store
|
|
1143
1337
|
|
|
1144
|
-
|
|
1145
|
-
if coordinate_transformations:
|
|
1146
|
-
write_options["coordinate_transformations"] = (
|
|
1147
|
-
coordinate_transformations
|
|
1148
|
-
)
|
|
1338
|
+
ndim = len(image_data.shape)
|
|
1149
1339
|
|
|
1150
|
-
|
|
1151
|
-
print(f"Saved OME-Zarr image data: {output_path}")
|
|
1340
|
+
axes = metadata.get("axes").lower() if metadata else None
|
|
1152
1341
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
# Add original metadata in a separate group
|
|
1166
|
-
if "original_metadata" not in root:
|
|
1167
|
-
metadata_group = root.create_group("original_metadata")
|
|
1342
|
+
# Standardize axes order to 'ctzyx' if possible, regardless of Z presence
|
|
1343
|
+
target_axes = "tczyx"
|
|
1344
|
+
if axes != target_axes[:ndim]:
|
|
1345
|
+
print(f"Reordering axes from {axes} to {target_axes[:ndim]}")
|
|
1346
|
+
try:
|
|
1347
|
+
# Create a mapping from original axes to target axes
|
|
1348
|
+
axes_map = {ax: i for i, ax in enumerate(axes)}
|
|
1349
|
+
reorder_list = []
|
|
1350
|
+
for _i, target_ax in enumerate(target_axes[:ndim]):
|
|
1351
|
+
if target_ax in axes_map:
|
|
1352
|
+
reorder_list.append(axes_map[target_ax])
|
|
1168
1353
|
else:
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
# Add metadata as attributes, safely converting types
|
|
1172
|
-
for key, value in metadata.items():
|
|
1173
|
-
try:
|
|
1174
|
-
# Try to store directly if it's a simple type
|
|
1175
|
-
if isinstance(
|
|
1176
|
-
value, (str, int, float, bool, type(None))
|
|
1177
|
-
):
|
|
1178
|
-
metadata_group.attrs[key] = value
|
|
1179
|
-
else:
|
|
1180
|
-
# Otherwise convert to string
|
|
1181
|
-
metadata_group.attrs[key] = str(value)
|
|
1182
|
-
except (ValueError, TypeError) as e:
|
|
1183
|
-
print(
|
|
1184
|
-
f"Warning: Could not store metadata key '{key}': {str(e)}"
|
|
1185
|
-
)
|
|
1354
|
+
print(f"Axis {target_ax} not found in original axes")
|
|
1355
|
+
reorder_list.append(None)
|
|
1186
1356
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
print(f"Warning: Could not add metadata to Zarr: {str(e)}")
|
|
1357
|
+
# Filter out None values (missing axes)
|
|
1358
|
+
reorder_list = [i for i in reorder_list if i is not None]
|
|
1190
1359
|
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1360
|
+
if len(reorder_list) != len(axes):
|
|
1361
|
+
raise ValueError(
|
|
1362
|
+
"Reordering failed: Mismatch between original and reordered dimensions."
|
|
1363
|
+
)
|
|
1364
|
+
image_data = np.moveaxis(
|
|
1365
|
+
image_data, range(len(axes)), reorder_list
|
|
1366
|
+
)
|
|
1367
|
+
axes = "".join(
|
|
1368
|
+
[axes[i] for i in reorder_list]
|
|
1369
|
+
) # Update axes to reflect new order
|
|
1370
|
+
print(f"New axes order after reordering: {axes}")
|
|
1371
|
+
except (ValueError, IndexError) as e:
|
|
1372
|
+
print(f"Error during reordering: {e}")
|
|
1373
|
+
raise
|
|
1374
|
+
|
|
1375
|
+
# Convert to Dask array
|
|
1376
|
+
if not hasattr(image_data, "dask"):
|
|
1377
|
+
print("Converting to dask array with auto chunks...")
|
|
1378
|
+
image_data = da.from_array(image_data, chunks="auto")
|
|
1379
|
+
else:
|
|
1380
|
+
print("Using existing dask array")
|
|
1196
1381
|
|
|
1197
|
-
|
|
1198
|
-
"""Calculate appropriate chunk sizes for zarr storage"""
|
|
1382
|
+
# Write the image data as OME-Zarr
|
|
1199
1383
|
try:
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
elements_per_chunk
|
|
1211
|
-
/ (total_elements / chunk_shape[i])
|
|
1212
|
-
),
|
|
1213
|
-
)
|
|
1214
|
-
break
|
|
1384
|
+
print("Writing image data using ome_zarr.writer.write_image...")
|
|
1385
|
+
with ProgressBar():
|
|
1386
|
+
root = zarr.group(store=store)
|
|
1387
|
+
write_image(
|
|
1388
|
+
image_data,
|
|
1389
|
+
group=root,
|
|
1390
|
+
axes=axes,
|
|
1391
|
+
scaler=None,
|
|
1392
|
+
storage_options={"compression": "zstd"},
|
|
1393
|
+
)
|
|
1215
1394
|
|
|
1216
|
-
#
|
|
1217
|
-
|
|
1218
|
-
|
|
1395
|
+
# Add basic OME-Zarr metadata
|
|
1396
|
+
root = zarr.open(store)
|
|
1397
|
+
root.attrs["multiscales"] = [
|
|
1398
|
+
{
|
|
1399
|
+
"version": "0.4",
|
|
1400
|
+
"datasets": [{"path": "0"}],
|
|
1401
|
+
"axes": [
|
|
1402
|
+
{
|
|
1403
|
+
"name": ax,
|
|
1404
|
+
"type": (
|
|
1405
|
+
"space"
|
|
1406
|
+
if ax in "xyz"
|
|
1407
|
+
else "time" if ax == "t" else "channel"
|
|
1408
|
+
),
|
|
1409
|
+
}
|
|
1410
|
+
for ax in axes
|
|
1411
|
+
],
|
|
1412
|
+
}
|
|
1413
|
+
]
|
|
1414
|
+
|
|
1415
|
+
print("OME-Zarr file saved successfully.")
|
|
1416
|
+
return True
|
|
1219
1417
|
|
|
1220
|
-
return tuple(chunk_shape)
|
|
1221
1418
|
except (ValueError, FileNotFoundError) as e:
|
|
1222
|
-
print(f"
|
|
1223
|
-
|
|
1224
|
-
|
|
1419
|
+
print(f"Error during Zarr writing: {e}")
|
|
1420
|
+
import traceback
|
|
1421
|
+
|
|
1422
|
+
traceback.print_exc()
|
|
1423
|
+
return False
|
|
1225
1424
|
|
|
1226
1425
|
|
|
1227
1426
|
class MicroscopyImageConverterWidget(QWidget):
|
|
@@ -1250,6 +1449,9 @@ class MicroscopyImageConverterWidget(QWidget):
|
|
|
1250
1449
|
self.scan_worker = None
|
|
1251
1450
|
self.conversion_worker = None
|
|
1252
1451
|
|
|
1452
|
+
# Flag to prevent recursive radio button updates
|
|
1453
|
+
self.updating_format_buttons = False
|
|
1454
|
+
|
|
1253
1455
|
# Create layout
|
|
1254
1456
|
main_layout = QVBoxLayout()
|
|
1255
1457
|
self.setLayout(main_layout)
|
|
@@ -1311,16 +1513,8 @@ class MicroscopyImageConverterWidget(QWidget):
|
|
|
1311
1513
|
self.zarr_radio = QCheckBox("ZARR (> 4GB)")
|
|
1312
1514
|
|
|
1313
1515
|
# Make checkboxes mutually exclusive like radio buttons
|
|
1314
|
-
self.tif_radio.toggled.connect(
|
|
1315
|
-
|
|
1316
|
-
self.zarr_radio.setChecked(not checked) if checked else None
|
|
1317
|
-
)
|
|
1318
|
-
)
|
|
1319
|
-
self.zarr_radio.toggled.connect(
|
|
1320
|
-
lambda checked: (
|
|
1321
|
-
self.tif_radio.setChecked(not checked) if checked else None
|
|
1322
|
-
)
|
|
1323
|
-
)
|
|
1516
|
+
self.tif_radio.toggled.connect(self.handle_format_toggle)
|
|
1517
|
+
self.zarr_radio.toggled.connect(self.handle_format_toggle)
|
|
1324
1518
|
|
|
1325
1519
|
format_layout.addWidget(format_label)
|
|
1326
1520
|
format_layout.addWidget(self.tif_radio)
|
|
@@ -1679,6 +1873,44 @@ class MicroscopyImageConverterWidget(QWidget):
|
|
|
1679
1873
|
else:
|
|
1680
1874
|
self.status_label.setText("No files were converted")
|
|
1681
1875
|
|
|
1876
|
+
def update_format_buttons(self, use_zarr=False):
|
|
1877
|
+
"""Update format radio buttons based on file size
|
|
1878
|
+
|
|
1879
|
+
Args:
|
|
1880
|
+
use_zarr: True if file size > 4GB, otherwise False
|
|
1881
|
+
"""
|
|
1882
|
+
if self.updating_format_buttons:
|
|
1883
|
+
return
|
|
1884
|
+
|
|
1885
|
+
self.updating_format_buttons = True
|
|
1886
|
+
try:
|
|
1887
|
+
if use_zarr:
|
|
1888
|
+
self.zarr_radio.setChecked(True)
|
|
1889
|
+
self.tif_radio.setChecked(False)
|
|
1890
|
+
print("Auto-selected ZARR format for large file (>4GB)")
|
|
1891
|
+
else:
|
|
1892
|
+
self.tif_radio.setChecked(True)
|
|
1893
|
+
self.zarr_radio.setChecked(False)
|
|
1894
|
+
print("Auto-selected TIF format for smaller file (<4GB)")
|
|
1895
|
+
finally:
|
|
1896
|
+
self.updating_format_buttons = False
|
|
1897
|
+
|
|
1898
|
+
def handle_format_toggle(self, checked):
|
|
1899
|
+
"""Handle format radio button toggle"""
|
|
1900
|
+
if self.updating_format_buttons:
|
|
1901
|
+
return
|
|
1902
|
+
|
|
1903
|
+
self.updating_format_buttons = True
|
|
1904
|
+
try:
|
|
1905
|
+
# Make checkboxes mutually exclusive like radio buttons
|
|
1906
|
+
sender = self.sender()
|
|
1907
|
+
if sender == self.tif_radio and checked:
|
|
1908
|
+
self.zarr_radio.setChecked(False)
|
|
1909
|
+
elif sender == self.zarr_radio and checked:
|
|
1910
|
+
self.tif_radio.setChecked(False)
|
|
1911
|
+
finally:
|
|
1912
|
+
self.updating_format_buttons = False
|
|
1913
|
+
|
|
1682
1914
|
|
|
1683
1915
|
# Create a MagicGUI widget that creates and returns the converter widget
|
|
1684
1916
|
@magicgui(
|