napari-tmidas 0.1.6__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.
@@ -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.writer import write_image # https://github.com/ome/ome-zarr-py
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
- if metadata:
188
- self.info_label.setText(
189
- f"File contains {series_count} series."
190
- )
191
- else:
192
- self.info_label.setText(
193
- f"File contains {series_count} series. No additional metadata available."
194
- )
195
- else:
196
- self.info_label.setText("No series found in this file.")
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
- # Convert to numpy array
386
- return nd2_file.asarray()
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
- return {
395
- "axes": "".join(nd2_file.sizes.keys()),
396
- "resolution": (
397
- 1 / nd2_file.voxel_size().x,
398
- 1 / nd2_file.voxel_size().y,
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": 1 / nd2_file.voxel_size().z,
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
- use_zarr = self.use_zarr or (
919
- estimated_size_bytes > 4 * 1024 * 1024 * 1024
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
- # First try with metadata
941
- try:
942
- if metadata:
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
- # First try with metadata
956
- try:
957
- if metadata:
958
- self._save_tif(
959
- image_data, output_path, metadata
960
- )
961
- else:
962
- self._save_tif(image_data, output_path)
963
- except (ValueError, FileNotFoundError) as e:
964
- print(
965
- f"Warning: Failed to save with metadata, trying without: {str(e)}"
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
- """Save image data as TIFF with optional metadata"""
999
- try:
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
- # Always preserve resolution if it exists
1006
- resolution = None
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
- axes = metadata.get("axes", "")
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
- # Handle different dimension cases appropriately
1013
- if len(image_data.shape) > 2 and any(ax in axes for ax in "ZC"):
1014
- # Hyperstack case (3D+ with channels or z-slices)
1015
- imagej_order = "TZCYX"
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
- if axes != imagej_order:
1018
- print(
1019
- f"Original axes: {axes}, Target order: {imagej_order}"
1020
- )
1204
+ # Get image dimensions and axis order
1205
+ ndim = len(image_data.shape)
1206
+ axes = metadata.get("axes", "")
1021
1207
 
1022
- # Filter to valid axes
1023
- valid_axes = [ax for ax in axes if ax in imagej_order]
1024
- if len(valid_axes) < len(axes):
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
- # Add missing dims
1037
- for ax in reversed(imagej_order):
1038
- if ax not in axes:
1039
- print(f"Adding {ax} dimension")
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
- # Final reordering
1044
- source_idx = [axes.index(ax) for ax in imagej_order]
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, range(len(imagej_order))
1258
+ image_data, source_idx, target_idx
1047
1259
  )
1048
- metadata["axes"] = imagej_order
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
- # 2D case - save without hyperstack metadata but keep resolution
1060
- save_metadata = (
1061
- {"resolution": metadata["resolution"]}
1062
- if "resolution" in metadata
1063
- else None
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
- metadata=save_metadata,
1306
+ imagej=True,
1069
1307
  resolution=resolution,
1070
- imagej=False,
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: {str(e)}")
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
- """Save image data as OME-Zarr format with optional metadata."""
1082
- try:
1083
- # Determine optimal chunk size
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
- # Determine appropriate axes based on dimensions
1091
- ndim = len(image_data.shape)
1092
- default_axes = "TCZYX"
1093
- axes = (
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
- # Create the zarr store
1100
- store = zarr.DirectoryStore(output_path)
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
- # Set up transformations if possible
1103
- coordinate_transformations = None
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
- # Add transformations if available
1145
- if coordinate_transformations:
1146
- write_options["coordinate_transformations"] = (
1147
- coordinate_transformations
1148
- )
1338
+ ndim = len(image_data.shape)
1149
1339
 
1150
- write_image(**write_options)
1151
- print(f"Saved OME-Zarr image data: {output_path}")
1340
+ axes = metadata.get("axes").lower() if metadata else None
1152
1341
 
1153
- # Try to add metadata
1154
- if metadata:
1155
- try:
1156
- # Access the root group
1157
- root = zarr.group(store)
1158
-
1159
- # Add OMERO metadata
1160
- if "omero" not in root:
1161
- root.create_group("omero")
1162
- omero_metadata = root["omero"]
1163
- omero_metadata.attrs["version"] = "0.4"
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
- metadata_group = root["original_metadata"]
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
- print(f"Added metadata to OME-Zarr file: {output_path}")
1188
- except (ValueError, FileNotFoundError) as e:
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
- return output_path
1192
- except (ValueError, FileNotFoundError) as e:
1193
- print(f"Error in _save_zarr: {str(e)}")
1194
- # For zarr, we don't have a simpler fallback method, so re-raise
1195
- raise
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
- def _calculate_chunks(self, shape, target_size, item_size):
1198
- """Calculate appropriate chunk sizes for zarr storage"""
1382
+ # Write the image data as OME-Zarr
1199
1383
  try:
1200
- total_elements = np.prod(shape)
1201
- elements_per_chunk = target_size // item_size
1202
-
1203
- chunk_shape = list(shape)
1204
- for i in reversed(range(len(chunk_shape))):
1205
- # Guard against division by zero
1206
- if total_elements > 0 and chunk_shape[i] > 0:
1207
- chunk_shape[i] = max(
1208
- 1,
1209
- int(
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
- # Ensure chunks aren't larger than dimensions
1217
- for i in range(len(chunk_shape)):
1218
- chunk_shape[i] = min(chunk_shape[i], shape[i])
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"Warning: Error calculating chunks: {str(e)}")
1223
- # Return a default chunk size that's safe
1224
- return tuple(min(512, d) for d in shape)
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
- lambda checked: (
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(