napari-tmidas 0.2.1__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.
Files changed (56) hide show
  1. napari_tmidas/__init__.py +35 -5
  2. napari_tmidas/_crop_anything.py +1458 -499
  3. napari_tmidas/_env_manager.py +76 -0
  4. napari_tmidas/_file_conversion.py +1646 -1131
  5. napari_tmidas/_file_selector.py +1464 -223
  6. napari_tmidas/_label_inspection.py +83 -8
  7. napari_tmidas/_processing_worker.py +309 -0
  8. napari_tmidas/_reader.py +6 -10
  9. napari_tmidas/_registry.py +15 -14
  10. napari_tmidas/_roi_colocalization.py +1221 -84
  11. napari_tmidas/_tests/test_crop_anything.py +123 -0
  12. napari_tmidas/_tests/test_env_manager.py +89 -0
  13. napari_tmidas/_tests/test_file_selector.py +90 -0
  14. napari_tmidas/_tests/test_grid_view_overlay.py +193 -0
  15. napari_tmidas/_tests/test_init.py +98 -0
  16. napari_tmidas/_tests/test_intensity_label_filter.py +222 -0
  17. napari_tmidas/_tests/test_label_inspection.py +86 -0
  18. napari_tmidas/_tests/test_processing_basic.py +500 -0
  19. napari_tmidas/_tests/test_processing_worker.py +142 -0
  20. napari_tmidas/_tests/test_regionprops_analysis.py +547 -0
  21. napari_tmidas/_tests/test_registry.py +135 -0
  22. napari_tmidas/_tests/test_scipy_filters.py +168 -0
  23. napari_tmidas/_tests/test_skimage_filters.py +259 -0
  24. napari_tmidas/_tests/test_split_channels.py +217 -0
  25. napari_tmidas/_tests/test_spotiflow.py +87 -0
  26. napari_tmidas/_tests/test_tyx_display_fix.py +142 -0
  27. napari_tmidas/_tests/test_ui_utils.py +68 -0
  28. napari_tmidas/_tests/test_widget.py +30 -0
  29. napari_tmidas/_tests/test_windows_basic.py +66 -0
  30. napari_tmidas/_ui_utils.py +57 -0
  31. napari_tmidas/_version.py +16 -3
  32. napari_tmidas/_widget.py +41 -4
  33. napari_tmidas/processing_functions/basic.py +557 -20
  34. napari_tmidas/processing_functions/careamics_env_manager.py +72 -99
  35. napari_tmidas/processing_functions/cellpose_env_manager.py +415 -112
  36. napari_tmidas/processing_functions/cellpose_segmentation.py +132 -191
  37. napari_tmidas/processing_functions/colocalization.py +513 -56
  38. napari_tmidas/processing_functions/grid_view_overlay.py +703 -0
  39. napari_tmidas/processing_functions/intensity_label_filter.py +422 -0
  40. napari_tmidas/processing_functions/regionprops_analysis.py +1280 -0
  41. napari_tmidas/processing_functions/sam2_env_manager.py +53 -69
  42. napari_tmidas/processing_functions/sam2_mp4.py +274 -195
  43. napari_tmidas/processing_functions/scipy_filters.py +403 -8
  44. napari_tmidas/processing_functions/skimage_filters.py +424 -212
  45. napari_tmidas/processing_functions/spotiflow_detection.py +949 -0
  46. napari_tmidas/processing_functions/spotiflow_env_manager.py +591 -0
  47. napari_tmidas/processing_functions/timepoint_merger.py +334 -86
  48. napari_tmidas/processing_functions/trackastra_tracking.py +24 -5
  49. {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/METADATA +92 -39
  50. napari_tmidas-0.2.4.dist-info/RECORD +63 -0
  51. napari_tmidas/_tests/__init__.py +0 -0
  52. napari_tmidas-0.2.1.dist-info/RECORD +0 -38
  53. {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/WHEEL +0 -0
  54. {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/entry_points.txt +0 -0
  55. {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/licenses/LICENSE +0 -0
  56. {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/top_level.txt +0 -0
@@ -1,22 +1,24 @@
1
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.).
2
+ Enhanced Batch Microscopy Image File Conversion
3
+ ===============================================
4
+ This module provides batch conversion of microscopy image files to a common format.
7
5
 
6
+ Supported formats: Leica LIF, Nikon ND2, Zeiss CZI, TIFF-based whole slide images (NDPI), Acquifer datasets
8
7
  """
9
8
 
10
9
  import concurrent.futures
10
+ import contextlib
11
+ import gc
11
12
  import os
12
13
  import re
13
14
  import shutil
15
+ import traceback
14
16
  from pathlib import Path
15
- from typing import Dict, List, Optional, Tuple
17
+ from typing import Dict, List, Optional, Tuple, Union
16
18
 
17
19
  import dask.array as da
18
20
  import napari
19
- import nd2 # https://github.com/tlambert03/nd2
21
+ import nd2
20
22
  import numpy as np
21
23
  import tifffile
22
24
  import zarr
@@ -24,7 +26,7 @@ from dask.diagnostics import ProgressBar
24
26
  from magicgui import magicgui
25
27
  from ome_zarr.io import parse_url
26
28
  from ome_zarr.writer import write_image
27
- from pylibCZIrw import czi # https://github.com/ZEISS/pylibczirw
29
+ from pylibCZIrw import czi as pyczi
28
30
  from qtpy.QtCore import Qt, QThread, Signal
29
31
  from qtpy.QtWidgets import (
30
32
  QApplication,
@@ -43,38 +45,37 @@ from qtpy.QtWidgets import (
43
45
  QVBoxLayout,
44
46
  QWidget,
45
47
  )
48
+ from readlif.reader import LifFile
49
+ from tiffslide import TiffSlide
46
50
 
47
- # Format-specific readers
48
- from readlif.reader import (
49
- LifFile, # https://github.com/Arcadia-Science/readlif
50
- )
51
- from tiffslide import TiffSlide # https://github.com/Bayer-Group/tiffslide
51
+
52
+ # Custom exceptions for better error handling
53
+ class FileFormatError(Exception):
54
+ """Raised when file format is not supported or corrupted"""
55
+
56
+
57
+ class SeriesIndexError(Exception):
58
+ """Raised when series index is out of range"""
59
+
60
+
61
+ class ConversionError(Exception):
62
+ """Raised when file conversion fails"""
52
63
 
53
64
 
54
65
  class SeriesTableWidget(QTableWidget):
55
- """
56
- Custom table widget to display original files and their series
57
- """
66
+ """Custom table widget to display original files and their series"""
58
67
 
59
68
  def __init__(self, viewer: napari.Viewer):
60
69
  super().__init__()
61
70
  self.viewer = viewer
62
-
63
- # Configure table
64
71
  self.setColumnCount(2)
65
72
  self.setHorizontalHeaderLabels(["Original Files", "Series"])
66
73
  self.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
67
74
 
68
- # Track file mappings
69
- self.file_data = (
70
- {}
71
- ) # {filepath: {type: file_type, series: [list_of_series]}}
72
-
73
- # Currently loaded images
75
+ self.file_data = {} # {filepath: {type, series_count, row}}
74
76
  self.current_file = None
75
77
  self.current_series = None
76
78
 
77
- # Connect selection signals
78
79
  self.cellClicked.connect(self.handle_cell_click)
79
80
 
80
81
  def add_file(self, filepath: str, file_type: str, series_count: int):
@@ -89,9 +90,7 @@ class SeriesTableWidget(QTableWidget):
89
90
 
90
91
  # Series info
91
92
  series_info = (
92
- f"{series_count} series"
93
- if series_count >= 0
94
- else "Not a series file"
93
+ f"{series_count} series" if series_count > 0 else "Single image"
95
94
  )
96
95
  series_item = QTableWidgetItem(series_info)
97
96
  self.setItem(row, 1, series_item)
@@ -106,20 +105,17 @@ class SeriesTableWidget(QTableWidget):
106
105
  def handle_cell_click(self, row: int, column: int):
107
106
  """Handle cell click to show series details or load image"""
108
107
  if column == 0:
109
- # Get filepath from the clicked cell
110
108
  item = self.item(row, 0)
111
109
  if item:
112
110
  filepath = item.data(Qt.UserRole)
113
111
  file_info = self.file_data.get(filepath)
114
112
 
115
113
  if file_info and file_info["series_count"] > 0:
116
- # Update the current file
117
114
  self.current_file = filepath
118
-
119
- # Signal to show series details
115
+ self.parent().set_selected_series(filepath, 0)
120
116
  self.parent().show_series_details(filepath)
121
117
  else:
122
- # Not a series file, just load the image
118
+ self.parent().set_selected_series(filepath, 0)
123
119
  self.parent().load_image(filepath)
124
120
 
125
121
 
@@ -133,253 +129,192 @@ class SeriesDetailWidget(QWidget):
133
129
  self.current_file = None
134
130
  self.max_series = 0
135
131
 
136
- # Create layout
137
132
  layout = QVBoxLayout()
138
133
  self.setLayout(layout)
139
134
 
140
- # Series selection widgets
135
+ # Series selection
141
136
  self.series_label = QLabel("Select Series:")
142
137
  layout.addWidget(self.series_label)
143
138
 
144
139
  self.series_selector = QComboBox()
145
140
  layout.addWidget(self.series_selector)
146
141
 
147
- # Add "Export All Series" checkbox
142
+ # Export all series option
148
143
  self.export_all_checkbox = QCheckBox("Export All Series")
149
144
  self.export_all_checkbox.toggled.connect(self.toggle_export_all)
150
145
  layout.addWidget(self.export_all_checkbox)
151
146
 
152
- # Connect series selector
153
147
  self.series_selector.currentIndexChanged.connect(self.series_selected)
154
148
 
155
- # Add preview button
149
+ # Preview button
156
150
  preview_button = QPushButton("Preview Selected Series")
157
151
  preview_button.clicked.connect(self.preview_series)
158
152
  layout.addWidget(preview_button)
159
153
 
160
- # Add info label
154
+ # Info label
161
155
  self.info_label = QLabel("")
162
156
  layout.addWidget(self.info_label)
163
157
 
164
158
  def toggle_export_all(self, checked):
165
159
  """Handle toggle of export all checkbox"""
166
- if self.current_file and checked:
167
- # Disable series selector when exporting all
160
+ if self.current_file:
168
161
  self.series_selector.setEnabled(not checked)
169
- # Update parent with export all setting
170
162
  self.parent.set_export_all_series(self.current_file, checked)
171
- elif self.current_file:
172
- # Re-enable series selector
173
- self.series_selector.setEnabled(True)
174
- # Update parent with currently selected series only
175
- self.series_selected(self.series_selector.currentIndex())
176
- # Update parent to not export all
177
- self.parent.set_export_all_series(self.current_file, False)
163
+ if not checked:
164
+ self.series_selected(self.series_selector.currentIndex())
165
+ else:
166
+ # When export all is enabled, ensure selected_series is also set
167
+ self.parent.set_selected_series(self.current_file, 0)
178
168
 
179
169
  def set_file(self, filepath: str):
180
170
  """Set the current file and update series list"""
181
171
  self.current_file = filepath
182
172
  self.series_selector.clear()
183
173
 
184
- # Reset export all checkbox
185
- self.export_all_checkbox.setChecked(False)
186
- self.series_selector.setEnabled(True)
174
+ # Block signals to avoid triggering toggle_export_all during initialization
175
+ self.export_all_checkbox.blockSignals(True)
187
176
 
188
- # Try to get series information
189
- file_loader = self.parent.get_file_loader(filepath)
190
- if file_loader:
191
- try:
192
- series_count = file_loader.get_series_count(filepath)
193
- self.max_series = series_count
194
- for i in range(series_count):
195
- self.series_selector.addItem(f"Series {i}", i)
196
-
197
- # Set info text
198
- if series_count > 0:
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
- )
177
+ # Check if this file already has export_all flag set
178
+ export_all = self.parent.export_all_series.get(filepath, False)
179
+ self.export_all_checkbox.setChecked(export_all)
180
+ self.series_selector.setEnabled(not export_all)
214
181
 
215
- self.info_label.setText(
216
- f"File contains {series_count} series (size: {size_GB:.2f}GB)"
217
- )
182
+ # Re-enable signals
183
+ self.export_all_checkbox.blockSignals(False)
218
184
 
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}")
223
- except FileNotFoundError:
224
- self.info_label.setText("File not found.")
225
- except PermissionError:
226
- self.info_label.setText(
227
- "Permission denied when accessing the file."
228
- )
229
- except ValueError as e:
230
- self.info_label.setText(f"Invalid data in file: {str(e)}")
231
- except OSError as e:
232
- self.info_label.setText(f"I/O error occurred: {str(e)}")
185
+ try:
186
+ file_loader = self.parent.get_file_loader(filepath)
187
+ if not file_loader:
188
+ raise FileFormatError(f"No loader available for {filepath}")
189
+
190
+ series_count = file_loader.get_series_count(filepath)
191
+ self.max_series = series_count
192
+
193
+ for i in range(series_count):
194
+ self.series_selector.addItem(f"Series {i}", i)
195
+
196
+ # Estimate file size for format recommendation
197
+ if series_count > 0:
198
+ try:
199
+ size_gb = self._estimate_file_size(filepath, file_loader)
200
+ self.info_label.setText(
201
+ f"File contains {series_count} series (estimated size: {size_gb:.2f}GB)"
202
+ )
203
+ self.parent.update_format_buttons(size_gb > 4)
204
+ except (MemoryError, OverflowError, OSError) as e:
205
+ self.info_label.setText(
206
+ f"File contains {series_count} series"
207
+ )
208
+ print(f"Size estimation failed: {e}")
209
+
210
+ except (FileNotFoundError, PermissionError, FileFormatError) as e:
211
+ self.info_label.setText(f"Error: {str(e)}")
212
+
213
+ def _estimate_file_size(self, filepath: str, file_loader) -> float:
214
+ """Estimate file size in GB"""
215
+ file_type = self.parent.get_file_type(filepath)
216
+
217
+ if file_type == "ND2":
218
+ try:
219
+ with nd2.ND2File(filepath) as nd2_file:
220
+ dims = dict(nd2_file.sizes)
221
+ pixel_size = nd2_file.dtype.itemsize
222
+ total_elements = np.prod([dims[dim] for dim in dims])
223
+ return (total_elements * pixel_size) / (1024**3)
224
+ except (OSError, AttributeError, ValueError):
225
+ pass
226
+
227
+ # Fallback estimation based on file size
228
+ try:
229
+ file_size = os.path.getsize(filepath)
230
+ return file_size / (1024**3)
231
+ except OSError:
232
+ return 0.0
233
233
 
234
234
  def series_selected(self, index: int):
235
235
  """Handle series selection"""
236
236
  if index >= 0 and self.current_file:
237
237
  series_index = self.series_selector.itemData(index)
238
238
 
239
- # Validate series index
240
239
  if series_index >= self.max_series:
241
- self.info_label.setText(
242
- f"Error: Series index {series_index} out of range (max: {self.max_series-1})"
240
+ raise SeriesIndexError(
241
+ f"Series index {series_index} out of range (max: {self.max_series-1})"
243
242
  )
244
- return
245
243
 
246
- # Update parent with selected series
247
244
  self.parent.set_selected_series(self.current_file, series_index)
248
245
 
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
-
273
246
  def preview_series(self):
274
247
  """Preview the selected series in Napari"""
275
- if self.current_file and self.series_selector.currentIndex() >= 0:
276
- series_index = self.series_selector.itemData(
277
- self.series_selector.currentIndex()
278
- )
279
-
280
- # Validate series index
281
- if series_index >= self.max_series:
282
- self.info_label.setText(
283
- f"Error: Series index {series_index} out of range (max: {self.max_series-1})"
284
- )
285
- return
286
-
287
- file_loader = self.parent.get_file_loader(self.current_file)
248
+ if not self.current_file or self.series_selector.currentIndex() < 0:
249
+ return
288
250
 
289
- try:
290
- # First get metadata to understand dimensions
291
- metadata = file_loader.get_metadata(
292
- self.current_file, series_index
293
- )
251
+ series_index = self.series_selector.itemData(
252
+ self.series_selector.currentIndex()
253
+ )
294
254
 
295
- # Load the series
296
- image_data = file_loader.load_series(
297
- self.current_file, series_index
298
- )
255
+ if series_index >= self.max_series:
256
+ self.info_label.setText("Error: Series index out of range")
257
+ return
299
258
 
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
- )
259
+ try:
260
+ file_loader = self.parent.get_file_loader(self.current_file)
261
+ metadata = file_loader.get_metadata(
262
+ self.current_file, series_index
263
+ )
264
+ image_data = file_loader.load_series(
265
+ self.current_file, series_index
266
+ )
308
267
 
309
- # Clear existing layers and display the image
310
- self.viewer.layers.clear()
311
- self.viewer.add_image(
312
- image_data,
313
- name=f"{Path(self.current_file).stem} - Series {series_index}",
268
+ # Reorder dimensions for Napari if needed
269
+ if metadata and "axes" in metadata:
270
+ napari_order = "CTZYX"[: len(image_data.shape)]
271
+ image_data = self._reorder_dimensions(
272
+ image_data, metadata, napari_order
314
273
  )
315
274
 
316
- # Update status
317
- self.viewer.status = f"Previewing {Path(self.current_file).name} - Series {series_index}"
318
- except (ValueError, FileNotFoundError) as e:
319
- self.viewer.status = f"Error loading series: {str(e)}"
320
- QMessageBox.warning(
321
- self, "Error", f"Could not load series: {str(e)}"
322
- )
275
+ self.viewer.layers.clear()
276
+ layer_name = (
277
+ f"{Path(self.current_file).stem}_series_{series_index}"
278
+ )
279
+ self.viewer.add_image(image_data, name=layer_name)
280
+ self.viewer.status = f"Previewing {layer_name}"
281
+
282
+ except (
283
+ FileNotFoundError,
284
+ SeriesIndexError,
285
+ MemoryError,
286
+ FileFormatError,
287
+ ) as e:
288
+ error_msg = f"Error loading series: {str(e)}"
289
+ self.viewer.status = error_msg
290
+ QMessageBox.warning(self, "Preview Error", error_msg)
323
291
 
324
292
  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
293
+ """Reorder dimensions based on metadata axes information"""
336
294
  if not metadata or "axes" not in metadata:
337
- print("No axes information in metadata - returning original")
338
295
  return image_data
339
296
 
340
- # Get source order from metadata
341
297
  source_order = metadata["axes"]
342
-
343
- # Ensure dimensions match
344
298
  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
299
 
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
- )
300
+ if len(source_order) != ndim or len(target_order) != ndim:
356
301
  return image_data
357
302
 
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
303
  try:
369
- print(f"Reordering from {source_order} to {target_order}")
304
+ reorder_indices = []
305
+ for axis in target_order:
306
+ if axis in source_order:
307
+ reorder_indices.append(source_order.index(axis))
308
+ else:
309
+ return image_data
370
310
 
371
- # Check if using Dask array
372
311
  if hasattr(image_data, "dask"):
373
- # Use Dask's transpose to preserve lazy computation
374
- reordered = image_data.transpose(reorder_indices)
312
+ return image_data.transpose(reorder_indices)
375
313
  else:
376
- # Use numpy transpose
377
- reordered = np.transpose(image_data, reorder_indices)
314
+ return np.transpose(image_data, reorder_indices)
378
315
 
379
- print(f"Reordered shape: {reordered.shape}")
380
- return reordered
381
316
  except (ValueError, IndexError) as e:
382
- print(f"Error reordering dimensions: {e}")
317
+ print(f"Dimension reordering failed: {e}")
383
318
  return image_data
384
319
 
385
320
 
@@ -395,7 +330,9 @@ class FormatLoader:
395
330
  raise NotImplementedError()
396
331
 
397
332
  @staticmethod
398
- def load_series(filepath: str, series_index: int) -> np.ndarray:
333
+ def load_series(
334
+ filepath: str, series_index: int
335
+ ) -> Union[np.ndarray, da.Array]:
399
336
  raise NotImplementedError()
400
337
 
401
338
  @staticmethod
@@ -404,92 +341,401 @@ class FormatLoader:
404
341
 
405
342
 
406
343
  class LIFLoader(FormatLoader):
407
- """Loader for Leica LIF files"""
344
+ """
345
+ Leica LIF loader based on readlif API
346
+
347
+ """
408
348
 
409
349
  @staticmethod
410
350
  def can_load(filepath: str) -> bool:
411
- return filepath.lower().endswith(".lif")
351
+ """Check if file can be loaded as LIF"""
352
+ if not filepath.lower().endswith(".lif"):
353
+ return False
354
+
355
+ try:
356
+ # Quick validation by attempting to open
357
+ lif_file = LifFile(filepath)
358
+ # Check if we can at least get the image list
359
+ list(lif_file.get_iter_image())
360
+ return True
361
+ except (OSError, ValueError, ImportError, AttributeError) as e:
362
+ print(f"Cannot load LIF file {filepath}: {e}")
363
+ return False
412
364
 
413
365
  @staticmethod
414
366
  def get_series_count(filepath: str) -> int:
367
+ """Get number of series in LIF file with better error handling"""
415
368
  try:
416
369
  lif_file = LifFile(filepath)
417
- # Directly use the iterator, no need to load all images into a list
418
- return sum(1 for _ in lif_file.get_iter_image())
419
- except (ValueError, FileNotFoundError):
370
+ # Count images more safely
371
+ count = 0
372
+ for _ in lif_file.get_iter_image():
373
+ count += 1
374
+ return count
375
+ except (OSError, ValueError, ImportError, AttributeError) as e:
376
+ print(f"Error counting series in {filepath}: {e}")
420
377
  return 0
421
378
 
422
379
  @staticmethod
423
- def load_series(filepath: str, series_index: int) -> np.ndarray:
424
- lif_file = LifFile(filepath)
425
- image = lif_file.get_image(series_index)
426
-
427
- # Extract dimensions
428
- channels = image.channels
429
- z_stacks = image.nz
430
- timepoints = image.nt
431
- x_dim, y_dim = image.dims[0], image.dims[1]
432
-
433
- # Create an array to hold the entire series
434
- series_shape = (
435
- timepoints,
436
- z_stacks,
437
- channels,
438
- y_dim,
439
- x_dim,
440
- ) # Corrected shape
441
- series_data = np.zeros(series_shape, dtype=np.uint16)
442
-
443
- # Populate the array
380
+ def load_series(
381
+ filepath: str, series_index: int
382
+ ) -> Union[np.ndarray, da.Array]:
383
+ """
384
+ Load LIF series with improved memory management and error handling
385
+ """
386
+ lif_file = None
387
+ try:
388
+ print(f"Loading LIF series {series_index} from {filepath}")
389
+ lif_file = LifFile(filepath)
390
+
391
+ # Get the specific image
392
+ images = list(lif_file.get_iter_image())
393
+ if series_index >= len(images):
394
+ raise SeriesIndexError(
395
+ f"Series index {series_index} out of range (0-{len(images)-1})"
396
+ )
397
+
398
+ image = images[series_index]
399
+
400
+ # Get image properties
401
+ channels = image.channels
402
+ z_stacks = image.nz
403
+ timepoints = image.nt
404
+ x_dim, y_dim = image.dims[0], image.dims[1]
405
+
406
+ print(
407
+ f"LIF Image dimensions: T={timepoints}, Z={z_stacks}, C={channels}, Y={y_dim}, X={x_dim}"
408
+ )
409
+
410
+ # Calculate memory requirements
411
+ total_frames = timepoints * z_stacks * channels
412
+ estimated_size_gb = (total_frames * x_dim * y_dim * 2) / (
413
+ 1024**3
414
+ ) # Assuming 16-bit
415
+
416
+ print(
417
+ f"Estimated memory: {estimated_size_gb:.2f} GB for {total_frames} frames"
418
+ )
419
+
420
+ # Choose loading strategy based on size
421
+ if estimated_size_gb > 4.0:
422
+ print("Large dataset detected, using Dask lazy loading")
423
+ return LIFLoader._load_as_dask(
424
+ image, timepoints, z_stacks, channels, y_dim, x_dim
425
+ )
426
+ elif estimated_size_gb > 1.0:
427
+ print("Medium dataset, using chunked numpy loading")
428
+ return LIFLoader._load_chunked_numpy(
429
+ image, timepoints, z_stacks, channels, y_dim, x_dim
430
+ )
431
+ else:
432
+ print("Small dataset, using standard numpy loading")
433
+ return LIFLoader._load_numpy(
434
+ image, timepoints, z_stacks, channels, y_dim, x_dim
435
+ )
436
+
437
+ except (OSError, IndexError, ValueError, AttributeError) as e:
438
+ print("Full error traceback for LIF loading:")
439
+ traceback.print_exc()
440
+ raise FileFormatError(
441
+ f"Failed to load LIF series {series_index}: {str(e)}"
442
+ ) from e
443
+ finally:
444
+ # Cleanup
445
+ if lif_file is not None:
446
+ with contextlib.suppress(Exception):
447
+ # readlif doesn't have explicit close, but we can delete the reference
448
+ del lif_file
449
+ gc.collect()
450
+
451
+ @staticmethod
452
+ def _load_numpy(
453
+ image,
454
+ timepoints: int,
455
+ z_stacks: int,
456
+ channels: int,
457
+ y_dim: int,
458
+ x_dim: int,
459
+ ) -> np.ndarray:
460
+ """Load small datasets directly into numpy array"""
461
+
462
+ # Determine data type from first available frame
463
+ dtype = np.uint16 # Default
464
+ test_frame = None
465
+ for t in range(min(1, timepoints)):
466
+ for z in range(min(1, z_stacks)):
467
+ for c in range(min(1, channels)):
468
+ try:
469
+ test_frame = image.get_frame(z=z, t=t, c=c)
470
+ if test_frame is not None:
471
+ dtype = np.array(test_frame).dtype
472
+ break
473
+ except (OSError, ValueError, AttributeError):
474
+ continue
475
+ if test_frame is not None:
476
+ break
477
+ if test_frame is not None:
478
+ break
479
+
480
+ # Pre-allocate array
481
+ series_shape = (timepoints, z_stacks, channels, y_dim, x_dim)
482
+ series_data = np.zeros(series_shape, dtype=dtype)
483
+
484
+ # Load frames with better error handling
444
485
  missing_frames = 0
486
+ loaded_frames = 0
487
+
445
488
  for t in range(timepoints):
446
489
  for z in range(z_stacks):
447
490
  for c in range(channels):
448
- # Get the frame and convert to numpy array
449
- frame = image.get_frame(z=z, t=t, c=c)
450
- if frame:
451
- series_data[t, z, c, :, :] = np.array(frame)
452
- else:
491
+ try:
492
+ frame = image.get_frame(z=z, t=t, c=c)
493
+ if frame is not None:
494
+ frame_array = np.array(frame, dtype=dtype)
495
+ # Ensure correct dimensions
496
+ if frame_array.shape == (y_dim, x_dim):
497
+ series_data[t, z, c, :, :] = frame_array
498
+ loaded_frames += 1
499
+ else:
500
+ print(
501
+ f"Warning: Frame shape mismatch at T={t}, Z={z}, C={c}: "
502
+ f"expected {(y_dim, x_dim)}, got {frame_array.shape}"
503
+ )
504
+ missing_frames += 1
505
+ else:
506
+ missing_frames += 1
507
+ except (OSError, ValueError, AttributeError) as e:
508
+ print(f"Error loading frame T={t}, Z={z}, C={c}: {e}")
453
509
  missing_frames += 1
454
- series_data[t, z, c, :, :] = np.zeros(
455
- (y_dim, x_dim), dtype=np.uint16
456
- )
510
+
511
+ # Progress feedback for large datasets
512
+ if timepoints > 10 and (t + 1) % max(1, timepoints // 10) == 0:
513
+ print(f"Loaded {t + 1}/{timepoints} timepoints")
514
+
515
+ print(
516
+ f"Loading complete: {loaded_frames} frames loaded, {missing_frames} frames missing"
517
+ )
518
+
519
+ if loaded_frames == 0:
520
+ raise FileFormatError(
521
+ "No valid frames could be loaded from LIF file"
522
+ )
523
+
524
+ return series_data
525
+
526
+ @staticmethod
527
+ def _load_chunked_numpy(
528
+ image,
529
+ timepoints: int,
530
+ z_stacks: int,
531
+ channels: int,
532
+ y_dim: int,
533
+ x_dim: int,
534
+ ) -> np.ndarray:
535
+ """Load medium datasets with memory management"""
536
+
537
+ print("Using chunked loading strategy")
538
+
539
+ # Load in chunks to manage memory
540
+ chunk_size = max(
541
+ 1, min(10, timepoints // 2)
542
+ ) # Process multiple timepoints at once
543
+
544
+ # Determine data type
545
+ dtype = np.uint16
546
+ for t in range(min(1, timepoints)):
547
+ for z in range(min(1, z_stacks)):
548
+ for c in range(min(1, channels)):
549
+ try:
550
+ frame = image.get_frame(z=z, t=t, c=c)
551
+ if frame is not None:
552
+ dtype = np.array(frame).dtype
553
+ break
554
+ except (OSError, ValueError, AttributeError):
555
+ continue
556
+
557
+ # Pre-allocate final array
558
+ series_shape = (timepoints, z_stacks, channels, y_dim, x_dim)
559
+ series_data = np.zeros(series_shape, dtype=dtype)
560
+
561
+ missing_frames = 0
562
+
563
+ for t_start in range(0, timepoints, chunk_size):
564
+ t_end = min(t_start + chunk_size, timepoints)
565
+ print(f"Loading timepoints {t_start} to {t_end-1}")
566
+
567
+ for t in range(t_start, t_end):
568
+ for z in range(z_stacks):
569
+ for c in range(channels):
570
+ try:
571
+ frame = image.get_frame(z=z, t=t, c=c)
572
+ if frame is not None:
573
+ frame_array = np.array(frame, dtype=dtype)
574
+ if frame_array.shape == (y_dim, x_dim):
575
+ series_data[t, z, c, :, :] = frame_array
576
+ else:
577
+ missing_frames += 1
578
+ else:
579
+ missing_frames += 1
580
+ except (OSError, ValueError, AttributeError):
581
+ missing_frames += 1
582
+
583
+ # Force garbage collection after each chunk
584
+ gc.collect()
457
585
 
458
586
  if missing_frames > 0:
459
587
  print(
460
- f"Warning: {missing_frames} frames were missing and filled with zeros."
588
+ f"Warning: {missing_frames} frames were missing and filled with zeros"
461
589
  )
462
590
 
463
591
  return series_data
464
592
 
593
+ @staticmethod
594
+ def _load_as_dask(
595
+ image,
596
+ timepoints: int,
597
+ z_stacks: int,
598
+ channels: int,
599
+ y_dim: int,
600
+ x_dim: int,
601
+ ) -> da.Array:
602
+ """Load large datasets as dask arrays for lazy evaluation"""
603
+
604
+ print("Creating Dask array for lazy loading")
605
+
606
+ # Determine data type
607
+ dtype = np.uint16
608
+ for t in range(min(1, timepoints)):
609
+ for z in range(min(1, z_stacks)):
610
+ for c in range(min(1, channels)):
611
+ try:
612
+ frame = image.get_frame(z=z, t=t, c=c)
613
+ if frame is not None:
614
+ dtype = np.array(frame).dtype
615
+ break
616
+ except (OSError, ValueError, AttributeError):
617
+ continue
618
+
619
+ # Define chunk size for dask array
620
+ # Chunk by timepoints to make it memory efficient
621
+ time_chunk = (
622
+ max(1, min(5, timepoints // 4)) if timepoints > 4 else timepoints
623
+ )
624
+
625
+ def load_chunk(block_id):
626
+ """Load a specific chunk of the data"""
627
+ t_start = block_id[0] * time_chunk
628
+ t_end = min(t_start + time_chunk, timepoints)
629
+
630
+ chunk_shape = (t_end - t_start, z_stacks, channels, y_dim, x_dim)
631
+ chunk_data = np.zeros(chunk_shape, dtype=dtype)
632
+
633
+ for t_idx, t in enumerate(range(t_start, t_end)):
634
+ for z in range(z_stacks):
635
+ for c in range(channels):
636
+ try:
637
+ frame = image.get_frame(z=z, t=t, c=c)
638
+ if frame is not None:
639
+ frame_array = np.array(frame, dtype=dtype)
640
+ if frame_array.shape == (y_dim, x_dim):
641
+ chunk_data[t_idx, z, c, :, :] = frame_array
642
+ except (OSError, ValueError, AttributeError) as e:
643
+ print(
644
+ f"Error in chunk loading T={t}, Z={z}, C={c}: {e}"
645
+ )
646
+
647
+ return chunk_data
648
+
649
+ # Use da.from_delayed for custom loading function
650
+ from dask import delayed
651
+
652
+ # Create delayed objects for each chunk
653
+ delayed_chunks = []
654
+ for t_chunk_idx in range((timepoints + time_chunk - 1) // time_chunk):
655
+ delayed_chunk = delayed(load_chunk)((t_chunk_idx,))
656
+ delayed_chunks.append(delayed_chunk)
657
+
658
+ # Convert to dask arrays and concatenate
659
+ dask_chunks = []
660
+ for i, delayed_chunk in enumerate(delayed_chunks):
661
+ t_start = i * time_chunk
662
+ t_end = min(t_start + time_chunk, timepoints)
663
+ chunk_shape = (t_end - t_start, z_stacks, channels, y_dim, x_dim)
664
+
665
+ dask_chunk = da.from_delayed(
666
+ delayed_chunk, shape=chunk_shape, dtype=dtype
667
+ )
668
+ dask_chunks.append(dask_chunk)
669
+
670
+ # Concatenate along time axis
671
+ if len(dask_chunks) == 1:
672
+ return dask_chunks[0]
673
+ else:
674
+ return da.concatenate(dask_chunks, axis=0)
675
+
465
676
  @staticmethod
466
677
  def get_metadata(filepath: str, series_index: int) -> Dict:
678
+ """Extract metadata with better error handling"""
467
679
  try:
468
680
  lif_file = LifFile(filepath)
469
- image = lif_file.get_image(series_index)
470
- axes = "".join(image.dims._fields).upper()
471
- channels = image.channels
472
- if channels > 1:
473
- # add C to end of string
474
- axes += "C"
681
+ images = list(lif_file.get_iter_image())
682
+
683
+ if series_index >= len(images):
684
+ return {}
685
+
686
+ image = images[series_index]
475
687
 
476
688
  metadata = {
477
- # "channels": image.channels,
478
- # "z_stacks": image.nz,
479
- # "timepoints": image.nt,
480
- "axes": "TZCYX",
689
+ "axes": "TZCYX", # Standard microscopy order
481
690
  "unit": "um",
482
- "resolution": image.scale[:2],
483
691
  }
484
- if image.scale[2] is not None:
485
- metadata["spacing"] = image.scale[2]
692
+
693
+ # Try to get resolution information
694
+ try:
695
+ if hasattr(image, "scale") and image.scale:
696
+ # scale is typically [x_res, y_res, z_res] in micrometers per pixel
697
+ if len(image.scale) >= 2:
698
+ x_scale, y_scale = image.scale[0], image.scale[1]
699
+ if x_scale and y_scale and x_scale > 0 and y_scale > 0:
700
+ metadata["resolution"] = (
701
+ 1.0 / x_scale,
702
+ 1.0 / y_scale,
703
+ ) # Convert to pixels per micrometer
704
+
705
+ if (
706
+ len(image.scale) >= 3
707
+ and image.scale[2]
708
+ and image.scale[2] > 0
709
+ ):
710
+ metadata["spacing"] = image.scale[
711
+ 2
712
+ ] # Z spacing in micrometers
713
+ except (AttributeError, TypeError, IndexError):
714
+ pass
715
+
716
+ # Add image dimensions info
717
+ with contextlib.suppress(AttributeError, IndexError):
718
+ metadata.update(
719
+ {
720
+ "timepoints": image.nt,
721
+ "z_stacks": image.nz,
722
+ "channels": image.channels,
723
+ "width": image.dims[0],
724
+ "height": image.dims[1],
725
+ }
726
+ )
727
+
486
728
  return metadata
487
- except (ValueError, FileNotFoundError):
729
+
730
+ except (OSError, IndexError, AttributeError, ImportError) as e:
731
+ print(f"Warning: Could not extract metadata from {filepath}: {e}")
488
732
  return {}
489
733
 
490
734
 
491
735
  class ND2Loader(FormatLoader):
492
- """Loader for Nikon ND2 files"""
736
+ """
737
+ Loader for Nikon ND2 files based on nd2 API
738
+ """
493
739
 
494
740
  @staticmethod
495
741
  def can_load(filepath: str) -> bool:
@@ -497,97 +743,234 @@ class ND2Loader(FormatLoader):
497
743
 
498
744
  @staticmethod
499
745
  def get_series_count(filepath: str) -> int:
500
- # ND2 files typically have a single series with multiple channels/dimensions
501
- return 1
746
+ """Get number of series (positions) in ND2 file"""
747
+ try:
748
+ with nd2.ND2File(filepath) as nd2_file:
749
+ # The 'P' dimension represents positions/series
750
+ return nd2_file.sizes.get("P", 1)
751
+ except (OSError, ValueError, ImportError) as e:
752
+ print(
753
+ f"Warning: Could not determine series count for {filepath}: {e}"
754
+ )
755
+ return 0
502
756
 
503
757
  @staticmethod
504
- def load_series(filepath: str, series_index: int) -> np.ndarray:
505
- if series_index != 0:
506
- raise ValueError("ND2 files only support series index 0")
758
+ def load_series(
759
+ filepath: str, series_index: int
760
+ ) -> Union[np.ndarray, da.Array]:
761
+ """
762
+ Load a specific series from ND2 file
763
+ """
764
+ try:
765
+ # First, get basic info about the file
766
+ with nd2.ND2File(filepath) as nd2_file:
767
+ dims = nd2_file.sizes
768
+ max_series = dims.get("P", 1)
769
+
770
+ if series_index >= max_series:
771
+ raise SeriesIndexError(
772
+ f"Series index {series_index} out of range (0-{max_series-1})"
773
+ )
774
+
775
+ # Calculate memory requirements for decision making
776
+ total_voxels = np.prod([dims[k] for k in dims if k != "P"])
777
+ pixel_size = np.dtype(nd2_file.dtype).itemsize
778
+ size_gb = (total_voxels * pixel_size) / (1024**3)
779
+
780
+ print(f"ND2 file dimensions: {dims}")
781
+ print(f"Single series estimated size: {size_gb:.2f} GB")
782
+
783
+ # Now load the data using the appropriate method
784
+ use_dask = size_gb > 2.0
785
+
786
+ if "P" in dims and dims["P"] > 1:
787
+ # Multi-position file
788
+ return ND2Loader._load_multi_position(
789
+ filepath, series_index, use_dask, dims
790
+ )
791
+ else:
792
+ # Single position file
793
+ if series_index != 0:
794
+ raise SeriesIndexError(
795
+ "Single position file only supports series index 0"
796
+ )
797
+ return ND2Loader._load_single_position(filepath, use_dask)
798
+
799
+ except (FileNotFoundError, PermissionError) as e:
800
+ raise FileFormatError(
801
+ f"Cannot access ND2 file {filepath}: {str(e)}"
802
+ ) from e
803
+ except (
804
+ OSError,
805
+ ValueError,
806
+ AttributeError,
807
+ ImportError,
808
+ KeyError,
809
+ ) as e:
810
+ raise FileFormatError(
811
+ f"Failed to load ND2 series {series_index}: {str(e)}"
812
+ ) from e
813
+
814
+ @staticmethod
815
+ def _load_multi_position(
816
+ filepath: str, series_index: int, use_dask: bool, dims: dict
817
+ ):
818
+ """Load specific position from multi-position file"""
819
+
820
+ if use_dask:
821
+ # METHOD 1: Use nd2.imread with xarray for better indexing
822
+ try:
823
+ print("Loading multi-position file as dask-xarray...")
824
+ data_xr = nd2.imread(filepath, dask=True, xarray=True)
825
+
826
+ # Use xarray's isel to extract position - this stays lazy!
827
+ series_data = data_xr.isel(P=series_index)
828
+
829
+ # Return the underlying dask array
830
+ return (
831
+ series_data.data
832
+ if hasattr(series_data, "data")
833
+ else series_data.values
834
+ )
835
+
836
+ except (
837
+ OSError,
838
+ ValueError,
839
+ AttributeError,
840
+ MemoryError,
841
+ FileFormatError,
842
+ ) as e:
843
+ print(f"xarray method failed: {e}, trying alternative...")
507
844
 
508
- # First open the file to check metadata
845
+ # METHOD 2: Fallback - use direct indexing on ResourceBackedDaskArray
846
+ try:
847
+ print(
848
+ "Loading multi-position file with direct dask indexing..."
849
+ )
850
+ # We need to keep the file open for the duration of the dask operations
851
+ # This is tricky - we'll compute immediately for now to avoid file closure issues
852
+
853
+ with nd2.ND2File(filepath) as nd2_file:
854
+ dask_array = nd2_file.to_dask()
855
+
856
+ # Find position axis
857
+ axis_names = list(dims.keys())
858
+ p_axis = axis_names.index("P")
859
+
860
+ # Create slice tuple to extract the specific position
861
+ # This is the CORRECTED approach for ResourceBackedDaskArray
862
+ slices = [slice(None)] * len(dask_array.shape)
863
+ slices[p_axis] = series_index
864
+
865
+ # Extract the series - but we need to compute it while file is open
866
+ series_data = dask_array[tuple(slices)]
867
+
868
+ # For large arrays, we compute immediately to avoid file closure issues
869
+ # This is not ideal but necessary due to ResourceBackedDaskArray limitations
870
+ if hasattr(series_data, "compute"):
871
+ print(
872
+ "Computing dask array immediately due to file closure limitations..."
873
+ )
874
+ return series_data.compute()
875
+ else:
876
+ return series_data
877
+
878
+ except (
879
+ OSError,
880
+ ValueError,
881
+ AttributeError,
882
+ MemoryError,
883
+ FileFormatError,
884
+ ) as e:
885
+ print(f"Dask method failed: {e}, falling back to numpy...")
886
+
887
+ # METHOD 3: Load as numpy array (for small files or as fallback)
888
+ print("Loading multi-position file as numpy array...")
509
889
  with nd2.ND2File(filepath) as nd2_file:
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
890
+ # Use direct indexing on the ND2File object
891
+ if hasattr(nd2_file, "__getitem__"):
892
+ axis_names = list(dims.keys())
893
+ p_axis = axis_names.index("P")
894
+ slices = [slice(None)] * len(dims)
895
+ slices[p_axis] = series_index
896
+ return nd2_file[tuple(slices)]
897
+ else:
898
+ # Final fallback: load entire array and slice
899
+ full_data = nd2.imread(filepath, dask=False)
900
+ axis_names = list(dims.keys())
901
+ p_axis = axis_names.index("P")
902
+ return np.take(full_data, series_index, axis=p_axis)
903
+
904
+ @staticmethod
905
+ def _load_single_position(filepath: str, use_dask: bool):
906
+ """Load single position file"""
907
+ if use_dask:
908
+ # For single position, we can use imread directly
909
+ return nd2.imread(filepath, dask=True)
526
910
  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
911
+ return nd2.imread(filepath, dask=False)
531
912
 
532
913
  @staticmethod
533
914
  def get_metadata(filepath: str, series_index: int) -> Dict:
534
- if series_index != 0:
535
- return {}
536
-
537
- with nd2.ND2File(filepath) as nd2_file:
538
- # Get all dimensions and their sizes
539
- dims = dict(nd2_file.sizes)
915
+ """Extract metadata with proper handling of series information"""
916
+ try:
917
+ with nd2.ND2File(filepath) as nd2_file:
918
+ dims = nd2_file.sizes
919
+
920
+ # For multi-position files, get dimensions without P axis
921
+ if "P" in dims:
922
+ if series_index >= dims["P"]:
923
+ return {}
924
+ # Remove P dimension for series-specific metadata
925
+ series_dims = {k: v for k, v in dims.items() if k != "P"}
926
+ else:
927
+ if series_index != 0:
928
+ return {}
929
+ series_dims = dims
540
930
 
541
- # Create a more detailed axes representation
542
- axes = "".join(dims.keys())
931
+ # Create axis string (standard microscopy order: TZCYX)
932
+ axis_order = "TZCYX"
933
+ axes = "".join([ax for ax in axis_order if ax in series_dims])
543
934
 
544
- print(f"ND2 metadata - dims: {dims}")
545
- print(f"ND2 metadata - axes: {axes}")
935
+ # Get voxel/pixel size information
936
+ try:
937
+ voxel = nd2_file.voxel_size()
938
+ if voxel:
939
+ # Convert from micrometers (nd2 default) to resolution
940
+ x_res = 1 / voxel.x if voxel.x > 0 else 1.0
941
+ y_res = 1 / voxel.y if voxel.y > 0 else 1.0
942
+ z_spacing = voxel.z if voxel.z > 0 else 1.0
943
+ else:
944
+ x_res, y_res, z_spacing = 1.0, 1.0, 1.0
945
+ except (AttributeError, ValueError, TypeError):
946
+ x_res, y_res, z_spacing = 1.0, 1.0, 1.0
546
947
 
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),
577
- "unit": "um",
578
- "spacing": z_spacing,
579
- **scales, # Add all scale information
580
- }
948
+ metadata = {
949
+ "axes": axes,
950
+ "resolution": (x_res, y_res),
951
+ "unit": "um",
952
+ }
581
953
 
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))
954
+ # Add Z spacing if Z dimension exists
955
+ if "Z" in series_dims and z_spacing != 1.0:
956
+ metadata["spacing"] = z_spacing
957
+
958
+ # Add additional useful metadata
959
+ metadata.update(
960
+ {
961
+ "dtype": str(nd2_file.dtype),
962
+ "shape": tuple(
963
+ series_dims[ax] for ax in axes if ax in series_dims
964
+ ),
965
+ "is_rgb": getattr(nd2_file, "is_rgb", False),
966
+ }
967
+ )
587
968
 
588
- metadata["scales"] = ordered_scales
969
+ return metadata
589
970
 
590
- return metadata
971
+ except (OSError, AttributeError, ImportError) as e:
972
+ print(f"Warning: Could not extract metadata from {filepath}: {e}")
973
+ return {}
591
974
 
592
975
 
593
976
  class TIFFSlideLoader(FormatLoader):
@@ -595,252 +978,436 @@ class TIFFSlideLoader(FormatLoader):
595
978
 
596
979
  @staticmethod
597
980
  def can_load(filepath: str) -> bool:
598
- ext = filepath.lower()
599
- return ext.endswith(".ndpi")
981
+ return filepath.lower().endswith((".ndpi", ".svs"))
600
982
 
601
983
  @staticmethod
602
984
  def get_series_count(filepath: str) -> int:
603
985
  try:
604
986
  with TiffSlide(filepath) as slide:
605
- # NDPI typically has a main image and several levels (pyramid)
606
987
  return len(slide.level_dimensions)
607
- except (ValueError, FileNotFoundError):
608
- # Try standard tifffile if TiffSlide fails
988
+ except (OSError, ImportError, ValueError):
609
989
  try:
610
990
  with tifffile.TiffFile(filepath) as tif:
611
991
  return len(tif.series)
612
- except (ValueError, FileNotFoundError):
992
+ except (OSError, ValueError, ImportError):
613
993
  return 0
614
994
 
615
995
  @staticmethod
616
996
  def load_series(filepath: str, series_index: int) -> np.ndarray:
617
997
  try:
618
- # First try TiffSlide for whole slide images
619
998
  with TiffSlide(filepath) as slide:
620
- if series_index < 0 or series_index >= len(
621
- slide.level_dimensions
622
- ):
623
- raise ValueError(
999
+ if series_index >= len(slide.level_dimensions):
1000
+ raise SeriesIndexError(
624
1001
  f"Series index {series_index} out of range"
625
1002
  )
626
1003
 
627
- # Get dimensions for the level
628
1004
  width, height = slide.level_dimensions[series_index]
629
- # Read the entire level
630
1005
  return np.array(
631
1006
  slide.read_region((0, 0), series_index, (width, height))
632
1007
  )
633
- except (ValueError, FileNotFoundError):
634
- # Fall back to tifffile
635
- with tifffile.TiffFile(filepath) as tif:
636
- if series_index < 0 or series_index >= len(tif.series):
637
- raise ValueError(
638
- f"Series index {series_index} out of range"
639
- ) from None
640
-
641
- return tif.series[series_index].asarray()
1008
+ except (OSError, ImportError, AttributeError):
1009
+ try:
1010
+ with tifffile.TiffFile(filepath) as tif:
1011
+ if series_index >= len(tif.series):
1012
+ raise SeriesIndexError(
1013
+ f"Series index {series_index} out of range"
1014
+ )
1015
+ return tif.series[series_index].asarray()
1016
+ except (OSError, IndexError, ValueError, ImportError) as e:
1017
+ raise FileFormatError(
1018
+ f"Failed to load TIFF slide series {series_index}: {str(e)}"
1019
+ ) from e
642
1020
 
643
1021
  @staticmethod
644
1022
  def get_metadata(filepath: str, series_index: int) -> Dict:
645
1023
  try:
646
1024
  with TiffSlide(filepath) as slide:
647
- if series_index < 0 or series_index >= len(
648
- slide.level_dimensions
649
- ):
1025
+ if series_index >= len(slide.level_dimensions):
650
1026
  return {}
651
1027
 
652
1028
  return {
653
- "axes": slide.properties["tiffslide.series-axes"],
1029
+ "axes": slide.properties.get(
1030
+ "tiffslide.series-axes", "YX"
1031
+ ),
654
1032
  "resolution": (
655
- slide.properties["tiffslide.mpp-x"],
656
- slide.properties["tiffslide.mpp-y"],
1033
+ float(slide.properties.get("tiffslide.mpp-x", 1.0)),
1034
+ float(slide.properties.get("tiffslide.mpp-y", 1.0)),
657
1035
  ),
658
1036
  "unit": "um",
659
1037
  }
660
- except (ValueError, FileNotFoundError):
661
- # Fall back to tifffile
662
- with tifffile.TiffFile(filepath) as tif:
663
- if series_index < 0 or series_index >= len(tif.series):
664
- return {}
665
-
666
- series = tif.series[series_index]
667
- return {
668
- "shape": series.shape,
669
- "dtype": str(series.dtype),
670
- "axes": series.axes,
671
- }
1038
+ except (OSError, ImportError, ValueError, KeyError):
1039
+ return {}
672
1040
 
673
1041
 
674
1042
  class CZILoader(FormatLoader):
675
- """Loader for Zeiss CZI files
676
- https://github.com/ZEISS/pylibczirw
1043
+ """
1044
+ Loader for Zeiss CZI files using pylibCZIrw API
1045
+
677
1046
  """
678
1047
 
679
1048
  @staticmethod
680
1049
  def can_load(filepath: str) -> bool:
681
- return filepath.lower().endswith(".czi")
1050
+ if not filepath.lower().endswith(".czi"):
1051
+ return False
1052
+
1053
+ # Test if we can actually open the file
1054
+ try:
1055
+ with pyczi.open_czi(filepath) as czidoc:
1056
+ # Try to get basic info to validate the file
1057
+ _ = czidoc.total_bounding_box
1058
+ return True
1059
+ except (
1060
+ OSError,
1061
+ ImportError,
1062
+ ValueError,
1063
+ AttributeError,
1064
+ RuntimeError,
1065
+ ) as e:
1066
+ print(f"Cannot load CZI file {filepath}: {e}")
1067
+ return False
682
1068
 
683
1069
  @staticmethod
684
1070
  def get_series_count(filepath: str) -> int:
1071
+ """
1072
+ Get number of series in CZI file
1073
+
1074
+ For CZI files:
1075
+ - If scenes exist, each scene is a series
1076
+ - If no scenes, there's 1 series (the whole image)
1077
+ """
685
1078
  try:
686
- with czi.open_czi(filepath) as czi_file:
687
- scenes = czi_file.scenes_bounding_rectangle
688
- return len(scenes)
689
- except (ValueError, FileNotFoundError):
1079
+ with pyczi.open_czi(filepath) as czidoc:
1080
+ scenes_bbox = czidoc.scenes_bounding_rectangle
1081
+
1082
+ if scenes_bbox:
1083
+ # File has scenes - each scene is a series
1084
+ scene_count = len(scenes_bbox)
1085
+ print(f"CZI file has {scene_count} scenes")
1086
+ return scene_count
1087
+ else:
1088
+ # No scenes - single series
1089
+ print("CZI file has no scenes - treating as single series")
1090
+ return 1
1091
+
1092
+ except (
1093
+ OSError,
1094
+ ImportError,
1095
+ ValueError,
1096
+ AttributeError,
1097
+ RuntimeError,
1098
+ ) as e:
1099
+ print(f"Error getting series count for {filepath}: {e}")
690
1100
  return 0
691
1101
 
692
1102
  @staticmethod
693
- def load_series(filepath: str, series_index: int) -> np.ndarray:
1103
+ def load_series(
1104
+ filepath: str, series_index: int
1105
+ ) -> Union[np.ndarray, da.Array]:
1106
+ """
1107
+ Load a specific series from CZI file using correct pylibCZIrw API
1108
+ """
694
1109
  try:
695
- with czi.open_czi(filepath) as czi_file:
696
- scenes = czi_file.scenes_bounding_rectangle
1110
+ print(f"Loading CZI series {series_index} from {filepath}")
1111
+
1112
+ with pyczi.open_czi(filepath) as czidoc:
1113
+ # Get file information
1114
+ total_bbox = czidoc.total_bounding_box
1115
+ scenes_bbox = czidoc.scenes_bounding_rectangle
1116
+
1117
+ print(f"Total bounding box: {total_bbox}")
1118
+ print(f"Scenes: {len(scenes_bbox) if scenes_bbox else 0}")
1119
+
1120
+ # Determine if we're dealing with scenes or single image
1121
+ if scenes_bbox:
1122
+ # Multi-scene file
1123
+ scene_indices = list(scenes_bbox.keys())
1124
+ if series_index >= len(scene_indices):
1125
+ raise SeriesIndexError(
1126
+ f"Scene index {series_index} out of range (0-{len(scene_indices)-1})"
1127
+ )
697
1128
 
698
- if series_index < 0 or series_index >= len(scenes):
699
- raise ValueError(
700
- f"Scene index {series_index} out of range"
701
- )
1129
+ # Get the actual scene ID (may not be sequential 0,1,2...)
1130
+ scene_id = scene_indices[series_index]
1131
+ print(f"Loading scene ID: {scene_id}")
702
1132
 
703
- scene_keys = list(scenes.keys())
704
- scene_index = scene_keys[series_index]
1133
+ # Read the specific scene
1134
+ # The scene parameter in read() expects the actual scene ID
1135
+ image_data = czidoc.read(scene=scene_id)
705
1136
 
706
- # You might need to specify pixel_type if automatic detection fails
707
- image = czi_file.read(scene=scene_index)
708
- return image
709
- except (ValueError, FileNotFoundError) as e:
710
- print(f"Error loading series: {e}")
711
- raise # Re-raise the exception after logging
1137
+ else:
1138
+ # Single scene file
1139
+ if series_index != 0:
1140
+ raise SeriesIndexError(
1141
+ f"Single scene file only supports series index 0, got {series_index}"
1142
+ )
712
1143
 
713
- @staticmethod
714
- def get_scales(metadata_xml, dim):
715
- pattern = re.compile(
716
- r'<Distance[^>]*Id="'
717
- + re.escape(dim)
718
- + r'"[^>]*>.*?<Value[^>]*>(.*?)</Value>',
719
- re.DOTALL,
720
- )
721
- match = pattern.search(metadata_xml)
1144
+ print("Loading single scene CZI")
1145
+ # Read without specifying scene
1146
+ image_data = czidoc.read()
722
1147
 
723
- if match:
724
- scale = float(match.group(1))
725
- # convert to microns
726
- scale = scale * 1e6
727
- return scale
728
- else:
729
- return None # Fixed: return a single None value instead of (None, None, None)
1148
+ print(
1149
+ f"Raw CZI data shape: {image_data.shape}, dtype: {image_data.dtype}"
1150
+ )
1151
+
1152
+ # Simply squeeze out all singleton dimensions
1153
+ if hasattr(image_data, "dask"):
1154
+ image_data = da.squeeze(image_data)
1155
+ else:
1156
+ image_data = np.squeeze(image_data)
1157
+
1158
+ print(f"Final CZI data shape: {image_data.shape}")
1159
+
1160
+ # Check if we need to use Dask for large arrays
1161
+ size_gb = (
1162
+ image_data.nbytes
1163
+ if hasattr(image_data, "nbytes")
1164
+ else np.prod(image_data.shape) * 4
1165
+ ) / (1024**3)
1166
+
1167
+ if size_gb > 2.0 and not hasattr(image_data, "dask"):
1168
+ print(
1169
+ f"Large CZI data ({size_gb:.2f}GB), converting to Dask array"
1170
+ )
1171
+ return da.from_array(image_data, chunks="auto")
1172
+ else:
1173
+ return image_data
1174
+
1175
+ except (
1176
+ OSError,
1177
+ ImportError,
1178
+ AttributeError,
1179
+ ValueError,
1180
+ RuntimeError,
1181
+ ) as e:
1182
+ raise FileFormatError(
1183
+ f"Failed to load CZI series {series_index}: {str(e)}"
1184
+ ) from e
730
1185
 
731
1186
  @staticmethod
732
1187
  def get_metadata(filepath: str, series_index: int) -> Dict:
1188
+ """Extract metadata using correct pylibCZIrw API"""
733
1189
  try:
734
- with czi.open_czi(filepath) as czi_file:
735
- scenes = czi_file.scenes_bounding_rectangle
1190
+ with pyczi.open_czi(filepath) as czidoc:
1191
+ scenes_bbox = czidoc.scenes_bounding_rectangle
1192
+ total_bbox = czidoc.total_bounding_box
1193
+
1194
+ # Validate series index
1195
+ if scenes_bbox:
1196
+ if series_index >= len(scenes_bbox):
1197
+ return {}
1198
+ scene_indices = list(scenes_bbox.keys())
1199
+ scene_id = scene_indices[series_index]
1200
+ print(f"Getting metadata for scene {scene_id}")
1201
+ else:
1202
+ if series_index != 0:
1203
+ return {}
1204
+ scene_id = None
1205
+ print("Getting metadata for single scene CZI")
736
1206
 
737
- if series_index < 0 or series_index >= len(scenes):
738
- return {}
1207
+ # Get basic metadata
1208
+ metadata = {}
739
1209
 
740
- # scene_keys = list(scenes.keys())
741
- # scene_index = scene_keys[series_index]
742
- # scene = scenes[scene_index]
1210
+ try:
1211
+ # Get raw metadata XML
1212
+ raw_metadata = czidoc.metadata
1213
+ if raw_metadata:
1214
+ # Extract scale information from XML metadata
1215
+ scale_x = CZILoader._extract_scale_from_xml(
1216
+ raw_metadata, "X"
1217
+ )
1218
+ scale_y = CZILoader._extract_scale_from_xml(
1219
+ raw_metadata, "Y"
1220
+ )
1221
+ scale_z = CZILoader._extract_scale_from_xml(
1222
+ raw_metadata, "Z"
1223
+ )
743
1224
 
744
- dims = czi_file.total_bounding_box
1225
+ if scale_x and scale_y:
1226
+ metadata["resolution"] = (scale_x, scale_y)
1227
+ if scale_z:
1228
+ metadata["spacing"] = scale_z
745
1229
 
746
- # Extract the raw metadata as an XML string
747
- metadata_xml = czi_file.raw_metadata
1230
+ except (AttributeError, RuntimeError):
1231
+ print(
1232
+ "Warning: Could not extract scale information from metadata"
1233
+ )
748
1234
 
749
- # Initialize metadata with default values
1235
+ # Get actual data to determine final dimensions after squeezing
750
1236
  try:
751
- # scales are in meters, convert to microns
752
- scale_x = CZILoader.get_scales(metadata_xml, "X") * 1e6
753
- scale_y = CZILoader.get_scales(metadata_xml, "Y") * 1e6
1237
+ if scenes_bbox:
1238
+ scene_indices = list(scenes_bbox.keys())
1239
+ scene_id = scene_indices[series_index]
1240
+ sample_data = czidoc.read(scene=scene_id)
1241
+ else:
1242
+ sample_data = czidoc.read()
754
1243
 
755
- filtered_dims = {
756
- k: v for k, v in dims.items() if v != (0, 1)
757
- }
758
- axes = "".join(filtered_dims.keys())
759
- metadata = {
1244
+ # Squeeze to match what load_series() returns
1245
+ if hasattr(sample_data, "dask"):
1246
+ sample_data = da.squeeze(sample_data)
1247
+ else:
1248
+ sample_data = np.squeeze(sample_data)
1249
+
1250
+ actual_shape = sample_data.shape
1251
+ actual_ndim = len(actual_shape)
1252
+ print(
1253
+ f"Actual squeezed shape for metadata: {actual_shape}"
1254
+ )
1255
+
1256
+ # Create axes based on actual squeezed dimensions
1257
+ if actual_ndim == 2:
1258
+ axes = "YX"
1259
+ elif actual_ndim == 3:
1260
+ # Check which dimension survived the squeeze
1261
+ unsqueezed_dims = []
1262
+ for dim, (_start, size) in total_bbox.items():
1263
+ if size > 1 and dim in ["T", "Z", "C"]:
1264
+ unsqueezed_dims.append(dim)
1265
+
1266
+ if unsqueezed_dims:
1267
+ axes = f"{unsqueezed_dims[0]}YX" # First non-singleton dim + YX
1268
+ else:
1269
+ axes = "ZYX" # Default fallback
1270
+ elif actual_ndim == 4:
1271
+ axes = "TCYX" # Most common 4D case
1272
+ elif actual_ndim == 5:
1273
+ axes = "TZCYX"
1274
+ else:
1275
+ # Fallback: just use YX and pad with standard dims
1276
+ standard_dims = ["T", "Z", "C"]
1277
+ axes = "".join(standard_dims[: actual_ndim - 2]) + "YX"
1278
+
1279
+ # Ensure axes length matches actual dimensions
1280
+ axes = axes[:actual_ndim]
1281
+
1282
+ print(
1283
+ f"Final axes for squeezed data: '{axes}' (length: {len(axes)})"
1284
+ )
1285
+
1286
+ except (AttributeError, RuntimeError) as e:
1287
+ print(f"Could not get sample data for metadata: {e}")
1288
+ # Fallback to original logic
1289
+ filtered_dims = {}
1290
+ for dim, (_start, size) in total_bbox.items():
1291
+ if size > 1: # Only include dimensions with size > 1
1292
+ filtered_dims[dim] = size
1293
+
1294
+ # Standard microscopy axis order: TZCYX
1295
+ axis_order = "TZCYX"
1296
+ axes = "".join(
1297
+ [ax for ax in axis_order if ax in filtered_dims]
1298
+ )
1299
+
1300
+ # Fallback to YX if no significant dimensions found
1301
+ if not axes:
1302
+ axes = "YX"
1303
+
1304
+ metadata.update(
1305
+ {
760
1306
  "axes": axes,
761
- "resolution": (scale_x, scale_y),
762
1307
  "unit": "um",
1308
+ "total_bounding_box": total_bbox,
1309
+ "has_scenes": bool(scenes_bbox),
1310
+ "scene_count": len(scenes_bbox) if scenes_bbox else 1,
763
1311
  }
1312
+ )
764
1313
 
765
- if dims["Z"] != (0, 1):
766
- scale_z = CZILoader.get_scales(metadata_xml, "Z")
767
- metadata["spacing"] = scale_z
768
- except ValueError as e:
769
- print(f"Error getting scale metadata: {e}")
1314
+ # Add scene-specific info if applicable
1315
+ if scene_id is not None:
1316
+ metadata["scene_id"] = scene_id
770
1317
 
771
1318
  return metadata
772
1319
 
773
- except (ValueError, FileNotFoundError, RuntimeError) as e:
774
- print(f"Error getting metadata: {e}")
1320
+ except (OSError, ImportError, AttributeError, RuntimeError) as e:
1321
+ print(f"Warning: Could not extract metadata from {filepath}: {e}")
775
1322
  return {}
776
1323
 
777
1324
  @staticmethod
778
- def get_physical_pixel_size(
779
- filepath: str, series_index: int
780
- ) -> Dict[str, float]:
1325
+ def _extract_scale_from_xml(metadata_xml: str, dimension: str) -> float:
1326
+ """
1327
+ Extract scale information from CZI XML metadata
1328
+
1329
+ This looks for Distance elements with the specified dimension ID
1330
+ """
781
1331
  try:
782
- with czi.open_czi(filepath) as czi_file:
783
- scenes = czi_file.scenes_bounding_rectangle
1332
+ # Pattern to find Distance elements with specific dimension
1333
+ pattern = re.compile(
1334
+ rf'<Distance[^>]*Id="{re.escape(dimension)}"[^>]*>.*?<Value[^>]*>(.*?)</Value>',
1335
+ re.DOTALL | re.IGNORECASE,
1336
+ )
784
1337
 
785
- if series_index < 0 or series_index >= len(scenes):
786
- raise ValueError(
787
- f"Scene index {series_index} out of range"
788
- )
1338
+ match = pattern.search(metadata_xml)
1339
+ if match:
1340
+ value = float(match.group(1))
1341
+ # CZI typically stores in meters, convert to micrometers
1342
+ return value * 1e6
789
1343
 
790
- # scene_keys = list(scenes.keys())
791
- # scene_index = scene_keys[series_index]
1344
+ # Alternative pattern for older CZI format
1345
+ pattern2 = re.compile(
1346
+ rf'<Scaling>.*?<Items>.*?<Distance.*?Id="{re.escape(dimension)}".*?>.*?<Value>(.*?)</Value>',
1347
+ re.DOTALL | re.IGNORECASE,
1348
+ )
792
1349
 
793
- # Get scale information
794
- scale_x = czi_file.scale_x
795
- scale_y = czi_file.scale_y
796
- scale_z = czi_file.scale_z
1350
+ match2 = pattern2.search(metadata_xml)
1351
+ if match2:
1352
+ value = float(match2.group(1))
1353
+ return value * 1e6
797
1354
 
798
- return {"X": scale_x, "Y": scale_y, "Z": scale_z}
799
- except (ValueError, FileNotFoundError) as e:
800
- print(f"Error getting pixel size: {str(e)}")
801
- return {}
1355
+ return 1.0 # Default fallback
1356
+
1357
+ except (ValueError, TypeError, AttributeError):
1358
+ return 1.0
802
1359
 
803
1360
 
804
1361
  class AcquiferLoader(FormatLoader):
805
- """Loader for Acquifer datasets using the acquifer_napari_plugin utility"""
1362
+ """Enhanced loader for Acquifer datasets with better detection"""
806
1363
 
807
- # Cache for loaded datasets to avoid reloading the same directory multiple times
808
- _dataset_cache = {} # {directory_path: xarray_dataset}
1364
+ _dataset_cache = {}
809
1365
 
810
1366
  @staticmethod
811
1367
  def can_load(filepath: str) -> bool:
812
- """
813
- Check if this is a directory that can be loaded as an Acquifer dataset
814
- """
1368
+ """Check if directory contains Acquifer-specific patterns"""
815
1369
  if not os.path.isdir(filepath):
816
1370
  return False
817
1371
 
818
1372
  try:
1373
+ dir_contents = os.listdir(filepath)
1374
+
1375
+ # Check for Acquifer-specific indicators
1376
+ acquifer_indicators = [
1377
+ "PlateLayout" in dir_contents,
1378
+ any(f.startswith("Image") for f in dir_contents),
1379
+ any("--PX" in f for f in dir_contents),
1380
+ any(f.endswith("_metadata.txt") for f in dir_contents),
1381
+ "Well" in str(dir_contents).upper(),
1382
+ ]
819
1383
 
820
- # Check if directory contains files
1384
+ if not any(acquifer_indicators):
1385
+ return False
1386
+
1387
+ # Verify it contains image files
821
1388
  image_files = []
822
- for root, _, files in os.walk(filepath):
1389
+ for _root, _, files in os.walk(filepath):
823
1390
  for file in files:
824
1391
  if file.lower().endswith(
825
1392
  (".tif", ".tiff", ".png", ".jpg", ".jpeg")
826
1393
  ):
827
- image_files.append(os.path.join(root, file))
1394
+ image_files.append(file)
828
1395
 
829
- return bool(image_files)
830
- except (ValueError, FileNotFoundError) as e:
831
- print(f"Error checking Acquifer dataset: {e}")
1396
+ return len(image_files) > 0
1397
+
1398
+ except (OSError, PermissionError):
832
1399
  return False
833
1400
 
834
1401
  @staticmethod
835
1402
  def _load_dataset(directory):
836
- """Load the dataset using array_from_directory and cache it"""
1403
+ """Load and cache Acquifer dataset"""
837
1404
  if directory in AcquiferLoader._dataset_cache:
838
1405
  return AcquiferLoader._dataset_cache[directory]
839
1406
 
840
1407
  try:
841
1408
  from acquifer_napari_plugin.utils import array_from_directory
842
1409
 
843
- # Check if directory contains files before trying to load
1410
+ # Verify image files exist
844
1411
  image_files = []
845
1412
  for root, _, files in os.walk(directory):
846
1413
  for file in files:
@@ -850,145 +1417,108 @@ class AcquiferLoader(FormatLoader):
850
1417
  image_files.append(os.path.join(root, file))
851
1418
 
852
1419
  if not image_files:
853
- raise ValueError(
854
- f"No image files found in directory: {directory}"
1420
+ raise FileFormatError(
1421
+ f"No image files found in Acquifer directory: {directory}"
855
1422
  )
856
1423
 
857
1424
  dataset = array_from_directory(directory)
858
1425
  AcquiferLoader._dataset_cache[directory] = dataset
859
1426
  return dataset
860
- except (ValueError, FileNotFoundError) as e:
861
- print(f"Error loading Acquifer dataset: {e}")
862
- raise ValueError(f"Failed to load Acquifer dataset: {e}") from e
1427
+
1428
+ except ImportError as e:
1429
+ raise FileFormatError(
1430
+ f"Acquifer plugin not available: {str(e)}"
1431
+ ) from e
1432
+ except (OSError, ValueError, AttributeError) as e:
1433
+ raise FileFormatError(
1434
+ f"Failed to load Acquifer dataset: {str(e)}"
1435
+ ) from e
863
1436
 
864
1437
  @staticmethod
865
1438
  def get_series_count(filepath: str) -> int:
866
- """
867
- Return the number of wells as series count
868
- """
869
1439
  try:
870
1440
  dataset = AcquiferLoader._load_dataset(filepath)
871
-
872
- # Check for Well dimension
873
- if "Well" in dataset.dims:
874
- return len(dataset.coords["Well"])
875
- else:
876
- # Single series for the whole dataset
877
- return 1
878
- except (ValueError, FileNotFoundError) as e:
879
- print(f"Error getting series count: {e}")
1441
+ return len(dataset.coords.get("Well", [1]))
1442
+ except (FileFormatError, AttributeError, KeyError):
880
1443
  return 0
881
1444
 
882
1445
  @staticmethod
883
1446
  def load_series(filepath: str, series_index: int) -> np.ndarray:
884
- """
885
- Load a specific well as a series
886
- """
887
1447
  try:
888
1448
  dataset = AcquiferLoader._load_dataset(filepath)
889
1449
 
890
- # If the dataset has a Well dimension, select the specific well
891
1450
  if "Well" in dataset.dims:
892
- if series_index < 0 or series_index >= len(
893
- dataset.coords["Well"]
894
- ):
895
- raise ValueError(
1451
+ if series_index >= len(dataset.coords["Well"]):
1452
+ raise SeriesIndexError(
896
1453
  f"Series index {series_index} out of range"
897
1454
  )
898
1455
 
899
- # Get the well value at this index
900
1456
  well_value = dataset.coords["Well"].values[series_index]
901
-
902
- # Select the data for this well
903
- well_data = dataset.sel(Well=well_value)
904
- # squeeze out singleton dimensions
905
- well_data = well_data.squeeze()
906
- # Convert to numpy array and return
1457
+ well_data = dataset.sel(Well=well_value).squeeze()
907
1458
  return well_data.values
908
1459
  else:
909
- # No Well dimension, return the entire dataset
1460
+ if series_index != 0:
1461
+ raise SeriesIndexError(
1462
+ "Single well dataset only supports series index 0"
1463
+ )
910
1464
  return dataset.values
911
1465
 
912
- except (ValueError, FileNotFoundError) as e:
913
- print(f"Error loading series: {e}")
914
- import traceback
915
-
916
- traceback.print_exc()
917
- raise ValueError(f"Failed to load series: {e}") from e
1466
+ except (AttributeError, KeyError, IndexError) as e:
1467
+ raise FileFormatError(
1468
+ f"Failed to load Acquifer series {series_index}: {str(e)}"
1469
+ ) from e
918
1470
 
919
1471
  @staticmethod
920
1472
  def get_metadata(filepath: str, series_index: int) -> Dict:
921
- """
922
- Extract metadata for a specific well
923
- """
924
1473
  try:
925
1474
  dataset = AcquiferLoader._load_dataset(filepath)
926
1475
 
927
- # Initialize with default values
928
- axes = ""
929
- resolution = (1.0, 1.0) # Default resolution
930
-
931
1476
  if "Well" in dataset.dims:
932
1477
  well_value = dataset.coords["Well"].values[series_index]
933
- well_data = dataset.sel(Well=well_value)
934
- well_data = well_data.squeeze() # remove singleton dimensions
935
-
936
- # Get dimensions
1478
+ well_data = dataset.sel(Well=well_value).squeeze()
937
1479
  dims = list(well_data.dims)
938
- dims = [
939
- item.replace("Channel", "C").replace("Time", "T")
940
- for item in dims
941
- ]
942
- axes = "".join(dims)
943
-
944
- # Try to get the first image file in the directory for metadata
945
- image_files = []
946
- for root, _, files in os.walk(filepath):
947
- for file in files:
948
- if file.lower().endswith((".tif", ".tiff")):
949
- image_files.append(os.path.join(root, file))
950
-
951
- if image_files:
952
- sample_file = image_files[0]
953
- try:
954
- # acquifer_metadata.getPixelSize_um(sample_file) is deprecated, get values after --PX in filename
955
- pattern = re.compile(r"--PX(\d+)")
956
- match = pattern.search(sample_file)
957
- if match:
958
- pixel_size = float(match.group(1)) * 10**-4
959
-
960
- resolution = (pixel_size, pixel_size)
961
- except (ValueError, FileNotFoundError) as e:
962
- print(f"Warning: Could not get pixel size: {e}")
963
1480
  else:
964
- # If no Well dimension, use dimensions from the dataset
965
1481
  dims = list(dataset.dims)
966
- dims = [
967
- item.replace("Channel", "C").replace("Time", "T")
968
- for item in dims
969
- ]
970
- axes = "".join(dims)
971
1482
 
972
- metadata = {
1483
+ # Normalize dimension names
1484
+ dims = [
1485
+ dim.replace("Channel", "C").replace("Time", "T")
1486
+ for dim in dims
1487
+ ]
1488
+ axes = "".join(dims)
1489
+
1490
+ # Try to extract pixel size from filenames
1491
+ resolution = (1.0, 1.0)
1492
+ try:
1493
+ for _root, _, files in os.walk(filepath):
1494
+ for file in files:
1495
+ if file.lower().endswith((".tif", ".tiff")):
1496
+ match = re.search(r"--PX(\d+)", file)
1497
+ if match:
1498
+ pixel_size = float(match.group(1)) * 1e-4
1499
+ resolution = (pixel_size, pixel_size)
1500
+ break
1501
+ if resolution != (1.0, 1.0):
1502
+ break
1503
+ except (OSError, ValueError, TypeError):
1504
+ pass
1505
+
1506
+ return {
973
1507
  "axes": axes,
974
1508
  "resolution": resolution,
975
1509
  "unit": "um",
976
1510
  "filepath": filepath,
977
1511
  }
978
- print(f"Extracted metadata: {metadata}")
979
- return metadata
980
-
981
- except (ValueError, FileNotFoundError) as e:
982
- print(f"Error getting metadata: {e}")
1512
+ except (FileFormatError, AttributeError, KeyError):
983
1513
  return {}
984
1514
 
985
1515
 
986
1516
  class ScanFolderWorker(QThread):
987
1517
  """Worker thread for scanning folders"""
988
1518
 
989
- progress = Signal(int, int) # current, total
990
- finished = Signal(list) # list of found files
991
- error = Signal(str) # error message
1519
+ progress = Signal(int, int)
1520
+ finished = Signal(list)
1521
+ error = Signal(str)
992
1522
 
993
1523
  def __init__(self, folder: str, filters: List[str]):
994
1524
  super().__init__()
@@ -1000,13 +1530,11 @@ class ScanFolderWorker(QThread):
1000
1530
  found_files = []
1001
1531
  all_items = []
1002
1532
 
1003
- # Get both files and potential Acquifer directories
1004
- include_directories = "acquifer" in [
1005
- f.lower() for f in self.filters
1006
- ]
1533
+ include_acquifer = "acquifer" in [f.lower() for f in self.filters]
1007
1534
 
1008
- # Count items to scan
1535
+ # Collect files and directories
1009
1536
  for root, dirs, files in os.walk(self.folder):
1537
+ # Add matching files
1010
1538
  for file in files:
1011
1539
  if any(
1012
1540
  file.lower().endswith(f)
@@ -1015,32 +1543,32 @@ class ScanFolderWorker(QThread):
1015
1543
  ):
1016
1544
  all_items.append(os.path.join(root, file))
1017
1545
 
1018
- # Add potential Acquifer directories
1019
- if include_directories:
1546
+ # Add Acquifer directories
1547
+ if include_acquifer:
1020
1548
  for dir_name in dirs:
1021
1549
  dir_path = os.path.join(root, dir_name)
1022
1550
  if AcquiferLoader.can_load(dir_path):
1023
1551
  all_items.append(dir_path)
1024
1552
 
1025
- # Scan all items
1553
+ # Process items
1026
1554
  total_items = len(all_items)
1027
1555
  for i, item_path in enumerate(all_items):
1028
1556
  if i % 10 == 0:
1029
1557
  self.progress.emit(i, total_items)
1030
-
1031
1558
  found_files.append(item_path)
1032
1559
 
1033
1560
  self.finished.emit(found_files)
1034
- except (ValueError, FileNotFoundError) as e:
1035
- self.error.emit(str(e))
1561
+
1562
+ except (OSError, PermissionError) as e:
1563
+ self.error.emit(f"Scan failed: {str(e)}")
1036
1564
 
1037
1565
 
1038
1566
  class ConversionWorker(QThread):
1039
- """Worker thread for file conversion"""
1567
+ """Enhanced worker thread for file conversion"""
1040
1568
 
1041
- progress = Signal(int, int, str) # current, total, filename
1042
- file_done = Signal(str, bool, str) # filepath, success, error message
1043
- finished = Signal(int) # number of successfully converted files
1569
+ progress = Signal(int, int, str)
1570
+ file_done = Signal(str, bool, str)
1571
+ finished = Signal(int)
1044
1572
 
1045
1573
  def __init__(
1046
1574
  self,
@@ -1058,111 +1586,35 @@ class ConversionWorker(QThread):
1058
1586
 
1059
1587
  def run(self):
1060
1588
  success_count = 0
1589
+
1061
1590
  for i, (filepath, series_index) in enumerate(self.files_to_convert):
1062
1591
  if not self.running:
1063
1592
  break
1064
1593
 
1065
- # Update progress
1066
- self.progress.emit(
1067
- i + 1, len(self.files_to_convert), Path(filepath).name
1068
- )
1594
+ filename = Path(filepath).name
1595
+ self.progress.emit(i + 1, len(self.files_to_convert), filename)
1069
1596
 
1070
1597
  try:
1071
- # Get loader
1072
- loader = self.get_file_loader(filepath)
1073
- if not loader:
1074
- self.file_done.emit(
1075
- filepath, False, "Unsupported file format"
1076
- )
1077
- continue
1078
-
1079
- # Load series - this is the critical part that must succeed
1080
- try:
1081
- image_data = loader.load_series(filepath, series_index)
1082
- except (ValueError, FileNotFoundError) as e:
1598
+ # Load and convert file
1599
+ success = self._convert_single_file(filepath, series_index)
1600
+ if success:
1601
+ success_count += 1
1083
1602
  self.file_done.emit(
1084
- filepath, False, f"Failed to load image: {str(e)}"
1085
- )
1086
- continue
1087
-
1088
- # Try to extract metadata - but don't fail if this doesn't work
1089
- metadata = None
1090
- try:
1091
- metadata = (
1092
- loader.get_metadata(filepath, series_index) or {}
1093
- )
1094
- print(f"Extracted metadata keys: {list(metadata.keys())}")
1095
- except (ValueError, FileNotFoundError) as e:
1096
- print(f"Warning: Failed to extract metadata: {str(e)}")
1097
- metadata = {}
1098
-
1099
- # Generate output filename
1100
- base_name = Path(filepath).stem
1101
-
1102
- # Determine format based on size and settings
1103
- estimated_size_bytes = (
1104
- np.prod(image_data.shape) * image_data.itemsize
1105
- )
1106
- file_size_GB = estimated_size_bytes / (1024**3)
1107
-
1108
- # Determine format
1109
- use_zarr = self.use_zarr
1110
- # If file is very large (>4GB) and user didn't explicitly choose TIF,
1111
- # auto-switch to ZARR format
1112
- if file_size_GB > 4 and not self.use_zarr:
1113
- # Recommend ZARR format but respect user's choice by still allowing TIF
1114
- print(
1115
- f"File size ({file_size_GB:.2f}GB) exceeds 4GB, ZARR format is recommended but using TIF with BigTIFF format"
1116
- )
1117
- self.file_done.emit(
1118
- filepath,
1119
- True,
1120
- f"File size ({file_size_GB:.2f}GB) exceeds 4GB, using TIF with BigTIFF format",
1121
- )
1122
-
1123
- # Set up the output path
1124
- if use_zarr:
1125
- output_path = os.path.join(
1126
- self.output_folder,
1127
- f"{base_name}_series{series_index}.zarr",
1603
+ filepath, True, "Conversion successful"
1128
1604
  )
1129
1605
  else:
1130
- output_path = os.path.join(
1131
- self.output_folder,
1132
- f"{base_name}_series{series_index}.tif",
1133
- )
1134
-
1135
- # The crucial part - save the file with separate try/except for each save method
1136
- save_success = False
1137
- error_message = ""
1138
-
1139
- try:
1140
- if use_zarr:
1141
- save_success = self._save_zarr(
1142
- image_data, output_path, metadata
1143
- )
1144
- else:
1145
- self._save_tif(image_data, output_path, metadata)
1146
- save_success = os.path.exists(output_path)
1147
-
1148
- if save_success:
1149
- success_count += 1
1150
- self.file_done.emit(
1151
- filepath, True, f"Saved to {output_path}"
1152
- )
1153
- else:
1154
- error_message = "Failed to save file - unknown error"
1155
- self.file_done.emit(filepath, False, error_message)
1156
-
1157
- except (ValueError, FileNotFoundError) as e:
1158
- error_message = f"Failed to save file: {str(e)}"
1159
- print(f"Error in save operation: {error_message}")
1160
- self.file_done.emit(filepath, False, error_message)
1161
-
1162
- except (ValueError, FileNotFoundError) as e:
1163
- print(f"Unexpected error during conversion: {str(e)}")
1606
+ self.file_done.emit(filepath, False, "Conversion failed")
1607
+
1608
+ except (
1609
+ FileFormatError,
1610
+ SeriesIndexError,
1611
+ ConversionError,
1612
+ MemoryError,
1613
+ ) as e:
1614
+ self.file_done.emit(filepath, False, str(e))
1615
+ except (OSError, PermissionError) as e:
1164
1616
  self.file_done.emit(
1165
- filepath, False, f"Unexpected error: {str(e)}"
1617
+ filepath, False, f"File access error: {str(e)}"
1166
1618
  )
1167
1619
 
1168
1620
  self.finished.emit(success_count)
@@ -1170,283 +1622,305 @@ class ConversionWorker(QThread):
1170
1622
  def stop(self):
1171
1623
  self.running = False
1172
1624
 
1173
- def _save_tif(
1174
- self, image_data: np.ndarray, output_path: str, metadata: dict = None
1175
- ):
1176
- """Enhanced TIF saving with proper dimension handling and BigTIFF support"""
1177
- import tifffile
1625
+ def _convert_single_file(self, filepath: str, series_index: int) -> bool:
1626
+ """Convert a single file to the target format"""
1627
+ image_data = None
1628
+ try:
1629
+ # Get loader and load data
1630
+ loader = self.get_file_loader(filepath)
1631
+ if not loader:
1632
+ raise FileFormatError("Unsupported file format")
1633
+
1634
+ image_data = loader.load_series(filepath, series_index)
1635
+ metadata = loader.get_metadata(filepath, series_index) or {}
1636
+
1637
+ # Generate output path
1638
+ base_name = Path(filepath).stem
1639
+ if self.use_zarr:
1640
+ output_path = os.path.join(
1641
+ self.output_folder,
1642
+ f"{base_name}_series{series_index}.zarr",
1643
+ )
1644
+ result = self._save_zarr(
1645
+ image_data, output_path, metadata, base_name, series_index
1646
+ )
1647
+ else:
1648
+ output_path = os.path.join(
1649
+ self.output_folder, f"{base_name}_series{series_index}.tif"
1650
+ )
1651
+ result = self._save_tif(image_data, output_path, metadata)
1652
+
1653
+ return result
1654
+
1655
+ except (FileFormatError, SeriesIndexError, MemoryError) as e:
1656
+ raise ConversionError(f"Conversion failed: {str(e)}") from e
1657
+ finally:
1658
+ # Free up memory after conversion
1659
+ if image_data is not None:
1660
+ del image_data
1661
+ import gc
1178
1662
 
1179
- print(f"Saving TIF file: {output_path}")
1180
- print(f"Image data shape: {image_data.shape}")
1663
+ gc.collect()
1181
1664
 
1182
- # Check if this is a large file that needs BigTIFF
1183
- estimated_size_bytes = np.prod(image_data.shape) * image_data.itemsize
1184
- file_size_GB = estimated_size_bytes / (1024**3)
1185
- use_bigtiff = file_size_GB > 4
1665
+ def _save_tif(
1666
+ self,
1667
+ image_data: Union[np.ndarray, da.Array],
1668
+ output_path: str,
1669
+ metadata: dict,
1670
+ ) -> bool:
1671
+ """Save image data as TIF with memory-efficient handling"""
1672
+ try:
1673
+ # Estimate file size
1674
+ if hasattr(image_data, "nbytes"):
1675
+ size_gb = image_data.nbytes / (1024**3)
1676
+ else:
1677
+ size_gb = (
1678
+ np.prod(image_data.shape)
1679
+ * getattr(image_data, "itemsize", 8)
1680
+ ) / (1024**3)
1186
1681
 
1187
- if use_bigtiff:
1188
1682
  print(
1189
- f"File size ({file_size_GB:.2f}GB) exceeds 4GB, using BigTIFF format"
1683
+ f"Saving TIF: {output_path}, estimated size: {size_gb:.2f}GB"
1190
1684
  )
1191
1685
 
1192
- if metadata:
1193
- print(f"Metadata keys: {list(metadata.keys())}")
1194
- if "axes" in metadata:
1195
- print(f"Original axes: {metadata['axes']}")
1686
+ # For very large files, reject TIF format
1687
+ if size_gb > 8:
1688
+ raise MemoryError(
1689
+ "File too large for TIF format. Use ZARR instead."
1690
+ )
1196
1691
 
1197
- # Handle Dask arrays
1198
- if hasattr(image_data, "compute"):
1199
- print("Computing Dask array before saving")
1200
- # For large arrays, compute block by block
1201
- try:
1202
- # Convert to numpy array in memory
1203
- image_data = image_data.compute()
1204
- except (MemoryError, ValueError) as e:
1205
- print(f"Error computing dask array: {e}")
1206
- # Alternative: write block by block
1207
- # This would require custom implementation
1208
- raise
1209
-
1210
- # Basic save if no metadata
1211
- if metadata is None:
1212
- print("No metadata provided, using basic save")
1213
- tifffile.imwrite(
1214
- output_path,
1215
- image_data,
1216
- compression="zlib",
1217
- bigtiff=use_bigtiff,
1218
- )
1219
- return
1692
+ use_bigtiff = size_gb > 4
1220
1693
 
1221
- # Get image dimensions and axis order
1222
- ndim = len(image_data.shape)
1223
- axes = metadata.get("axes", "")
1694
+ # Handle Dask arrays efficiently
1695
+ if hasattr(image_data, "dask"):
1696
+ if size_gb > 6: # Conservative threshold for Dask->TIF
1697
+ raise MemoryError(
1698
+ "Dask array too large for TIF. Use ZARR instead."
1699
+ )
1700
+
1701
+ # For large Dask arrays, use chunked writing
1702
+ if len(image_data.shape) > 3:
1703
+ return self._save_tif_chunked_dask(
1704
+ image_data, output_path, use_bigtiff
1705
+ )
1706
+ else:
1707
+ # Compute smaller arrays
1708
+ image_data = image_data.compute()
1224
1709
 
1225
- print(f"Number of dimensions: {ndim}")
1226
- if axes:
1227
- print(f"Axes from metadata: {axes}")
1710
+ # Standard TIF saving
1711
+ save_kwargs = {"bigtiff": use_bigtiff, "compression": "zlib"}
1228
1712
 
1229
- # Handle ImageJ compatibility for dimensions
1230
- if ndim > 2:
1231
- # Get target order for ImageJ
1232
- imagej_order = "TZCYX"
1713
+ if len(image_data.shape) > 2:
1714
+ save_kwargs["imagej"] = True
1233
1715
 
1234
- # If axes information is incomplete, try to infer from shape
1235
- if len(axes) != ndim:
1236
- print(
1237
- f"Warning: Axes length ({len(axes)}) doesn't match dimensions ({ndim})"
1238
- )
1239
- # For your specific case with shape (45, 101, 4, 1024, 1024)
1240
- # Infer TZCYX if shape matches
1241
- if (
1242
- ndim == 5
1243
- and image_data.shape[2] <= 10
1244
- and image_data.shape[3] > 100
1245
- and image_data.shape[4] > 100
1246
- ):
1247
- print("Inferring TZCYX from shape")
1248
- axes = "TZCYX"
1249
-
1250
- if axes and axes != imagej_order:
1251
- print(f"Reordering: {axes} -> {imagej_order}")
1252
-
1253
- # Map dimensions from original to target order
1254
- dim_map = {}
1255
- for i, ax in enumerate(axes):
1256
- if ax in imagej_order:
1257
- dim_map[ax] = i
1258
-
1259
- # Handle missing dimensions
1260
- for ax in imagej_order:
1261
- if ax not in dim_map:
1262
- print(f"Adding missing dimension: {ax}")
1263
- image_data = np.expand_dims(image_data, axis=0)
1264
- dim_map[ax] = image_data.shape[0] - 1
1265
-
1266
- # Create reordering indices
1267
- source_idx = [dim_map[ax] for ax in imagej_order]
1268
- target_idx = list(range(len(imagej_order)))
1269
-
1270
- print(f"Reordering dimensions: {source_idx} -> {target_idx}")
1271
-
1272
- # Reorder dimensions
1716
+ if metadata.get("resolution"):
1273
1717
  try:
1274
- image_data = np.moveaxis(
1275
- image_data, source_idx, target_idx
1276
- )
1277
- except (ValueError, IndexError) as e:
1278
- print(f"Error reordering dimensions: {e}")
1279
- # Fall back to simple save without reordering
1280
- tifffile.imwrite(
1281
- output_path,
1282
- image_data,
1283
- compression="zlib",
1284
- bigtiff=use_bigtiff,
1285
- )
1286
- return
1718
+ res_x, res_y = metadata["resolution"]
1719
+ save_kwargs["resolution"] = (float(res_x), float(res_y))
1720
+ except (ValueError, TypeError):
1721
+ pass
1287
1722
 
1288
- # Update axes information
1289
- metadata["axes"] = imagej_order
1723
+ tifffile.imwrite(output_path, image_data, **save_kwargs)
1724
+ return os.path.exists(output_path)
1290
1725
 
1291
- # Extract resolution information for ImageJ
1292
- resolution = None
1293
- if "resolution" in metadata:
1294
- try:
1295
- res_x, res_y = metadata["resolution"]
1296
- resolution = (float(res_x), float(res_y))
1297
- print(f"Using resolution: {resolution}")
1298
- except (ValueError, TypeError) as e:
1299
- print(f"Error processing resolution: {e}")
1726
+ except (OSError, PermissionError) as e:
1727
+ raise ConversionError(f"TIF save failed: {str(e)}") from e
1300
1728
 
1301
- # Handle saving with metadata
1729
+ def _save_tif_chunked_dask(
1730
+ self, dask_array: da.Array, output_path: str, use_bigtiff: bool
1731
+ ) -> bool:
1732
+ """Save large Dask array to TIF using chunked writing"""
1302
1733
  try:
1303
- if ndim <= 2:
1304
- # 2D case - simpler saving
1305
- print("Saving as 2D image")
1306
- tifffile.imwrite(
1307
- output_path,
1308
- image_data,
1309
- resolution=resolution,
1310
- compression="zlib",
1311
- bigtiff=use_bigtiff,
1312
- )
1313
- else:
1314
- # Hyperstack case
1315
- print("Saving as hyperstack with ImageJ metadata")
1316
-
1317
- # Create clean metadata dict with only needed keys
1318
- imagej_metadata = {}
1319
- if "unit" in metadata:
1320
- imagej_metadata["unit"] = metadata["unit"]
1321
- if "spacing" in metadata:
1322
- imagej_metadata["spacing"] = float(metadata["spacing"])
1734
+ print(
1735
+ f"Using chunked Dask TIF writing for shape {dask_array.shape}"
1736
+ )
1323
1737
 
1738
+ # Write timepoints/slices individually for multi-dimensional data
1739
+ if len(dask_array.shape) >= 4:
1740
+ with tifffile.TiffWriter(
1741
+ output_path, bigtiff=use_bigtiff
1742
+ ) as writer:
1743
+ for i in range(dask_array.shape[0]):
1744
+ slice_data = dask_array[i].compute()
1745
+ writer.write(slice_data, compression="zlib")
1746
+ else:
1747
+ # For 3D or smaller, compute and save normally
1748
+ computed_data = dask_array.compute()
1324
1749
  tifffile.imwrite(
1325
1750
  output_path,
1326
- image_data,
1327
- imagej=True,
1328
- resolution=resolution,
1329
- metadata=imagej_metadata,
1330
- compression="zlib",
1751
+ computed_data,
1331
1752
  bigtiff=use_bigtiff,
1753
+ compression="zlib",
1332
1754
  )
1333
1755
 
1334
- print(f"Successfully saved TIF file: {output_path}")
1335
- except (ValueError, FileNotFoundError) as e:
1336
- print(f"Error saving TIF file: {e}")
1337
- # Try simple save as fallback
1338
- tifffile.imwrite(output_path, image_data, bigtiff=use_bigtiff)
1756
+ return True
1757
+
1758
+ except (OSError, PermissionError, MemoryError) as e:
1759
+ raise ConversionError(
1760
+ f"Chunked TIF writing failed: {str(e)}"
1761
+ ) from e
1762
+ finally:
1763
+ # Clean up temporary data
1764
+ if "slice_data" in locals():
1765
+ del slice_data
1766
+ if "computed_data" in locals():
1767
+ del computed_data
1339
1768
 
1340
1769
  def _save_zarr(
1341
- self, image_data: np.ndarray, output_path: str, metadata: dict = None
1342
- ):
1343
- """Enhanced ZARR saving with proper metadata storage and specific exceptions"""
1344
- print(f"Saving ZARR file: {output_path}")
1345
- print(f"Image data shape: {image_data.shape}")
1770
+ self,
1771
+ image_data: Union[np.ndarray, da.Array],
1772
+ output_path: str,
1773
+ metadata: dict,
1774
+ base_name: str,
1775
+ series_index: int,
1776
+ ) -> bool:
1777
+ """Save image data as ZARR with proper OME-ZARR structure conforming to spec"""
1778
+ try:
1779
+ print(f"Saving ZARR: {output_path}")
1346
1780
 
1347
- metadata = metadata or {}
1348
- print(
1349
- f"Metadata keys: {list(metadata.keys()) if metadata else 'No metadata'}"
1350
- )
1781
+ if os.path.exists(output_path):
1782
+ shutil.rmtree(output_path)
1351
1783
 
1352
- # Handle overwriting by deleting the directory if it exists
1353
- if os.path.exists(output_path):
1354
- print(f"Deleting existing Zarr directory: {output_path}")
1355
- shutil.rmtree(output_path)
1784
+ store = parse_url(output_path, mode="w").store
1356
1785
 
1357
- # Explicitly create a DirectoryStore
1358
- store = parse_url(output_path, mode="w").store
1786
+ # Convert to Dask array with appropriate chunks
1787
+ # OME-Zarr best practice: keep X,Y intact, chunk along T/Z
1788
+ # Codec limit: chunks must be <2GB
1789
+ if not hasattr(image_data, "dask"):
1790
+ image_data = da.from_array(image_data, chunks="auto")
1359
1791
 
1360
- ndim = len(image_data.shape)
1792
+ # Check if chunks exceed compression codec limit (2GB)
1793
+ max_chunk_bytes = 1_500_000_000 # 1.5GB safe limit
1794
+ chunk_bytes = (
1795
+ np.prod(image_data.chunksize) * image_data.dtype.itemsize
1796
+ )
1361
1797
 
1362
- axes = metadata.get("axes").lower() if metadata else None
1798
+ if chunk_bytes > max_chunk_bytes:
1799
+ print(
1800
+ f"Rechunking: current chunks ({chunk_bytes / 1e9:.2f} GB) exceed codec limit"
1801
+ )
1802
+ # Keep spatial dims (Y, X) and channel intact, rechunk T and Z
1803
+ new_chunks = list(image_data.chunksize)
1804
+ # Reduce T and Z proportionally to get under limit
1805
+ scale = (max_chunk_bytes / chunk_bytes) ** 0.5
1806
+ for i in range(min(2, len(new_chunks))):
1807
+ new_chunks[i] = max(1, int(new_chunks[i] * scale))
1808
+ image_data = image_data.rechunk(tuple(new_chunks))
1809
+ print(
1810
+ f"Rechunked to {image_data.chunksize} ({np.prod(image_data.chunksize) * image_data.dtype.itemsize / 1e9:.2f} GB/chunk)"
1811
+ )
1363
1812
 
1364
- # Standardize axes order to 'ctzyx' if possible, regardless of Z presence
1365
- target_axes = "tczyx"
1366
- if axes != target_axes[:ndim]:
1367
- print(f"Reordering axes from {axes} to {target_axes[:ndim]}")
1368
- try:
1369
- # Create a mapping from original axes to target axes
1370
- axes_map = {ax: i for i, ax in enumerate(axes)}
1371
- reorder_list = []
1372
- for _i, target_ax in enumerate(target_axes[:ndim]):
1373
- if target_ax in axes_map:
1374
- reorder_list.append(axes_map[target_ax])
1375
- else:
1376
- print(f"Axis {target_ax} not found in original axes")
1377
- reorder_list.append(None)
1813
+ # Handle axes reordering for proper OME-ZARR structure
1814
+ axes = metadata.get("axes", "").lower()
1815
+ if axes:
1816
+ ndim = len(image_data.shape)
1817
+ has_time = "t" in axes
1818
+ target_axes = "tczyx" if has_time else "czyx"
1819
+ target_axes = target_axes[:ndim]
1378
1820
 
1379
- # Filter out None values (missing axes)
1380
- reorder_list = [i for i in reorder_list if i is not None]
1821
+ if axes != target_axes and len(axes) == ndim:
1822
+ try:
1823
+ reorder_indices = [
1824
+ axes.index(ax) for ax in target_axes if ax in axes
1825
+ ]
1826
+ if len(reorder_indices) == len(axes):
1827
+ image_data = image_data.transpose(reorder_indices)
1828
+ axes = target_axes
1829
+ except (ValueError, IndexError):
1830
+ pass
1831
+
1832
+ # Create proper layer name for napari
1833
+ layer_name = (
1834
+ f"{base_name}_series_{series_index}"
1835
+ if series_index > 0
1836
+ else base_name
1837
+ )
1381
1838
 
1382
- if len(reorder_list) != len(axes):
1383
- raise ValueError(
1384
- "Reordering failed: Mismatch between original and reordered dimensions."
1385
- )
1386
- image_data = np.moveaxis(
1387
- image_data, range(len(axes)), reorder_list
1388
- )
1389
- axes = "".join(
1390
- [axes[i] for i in reorder_list]
1391
- ) # Update axes to reflect new order
1392
- print(f"New axes order after reordering: {axes}")
1393
- except (ValueError, IndexError) as e:
1394
- print(f"Error during reordering: {e}")
1395
- raise
1396
-
1397
- # Convert to Dask array
1398
- if not hasattr(image_data, "dask"):
1399
- print("Converting to dask array with auto chunks...")
1400
- image_data = da.from_array(image_data, chunks="auto")
1401
- else:
1402
- print("Using existing dask array")
1839
+ # Build proper OME-Zarr coordinate transformations from metadata
1840
+ scale_transform = self._build_scale_transform(
1841
+ metadata, axes, image_data.shape
1842
+ )
1403
1843
 
1404
- # Write the image data as OME-Zarr
1405
- try:
1406
- print("Writing image data using ome_zarr.writer.write_image...")
1844
+ # Save with OME-ZARR including physical metadata
1407
1845
  with ProgressBar():
1408
1846
  root = zarr.group(store=store)
1847
+
1848
+ # Set layer name for napari compatibility
1849
+ root.attrs["name"] = layer_name
1850
+
1851
+ # Write the image with proper OME-ZARR structure and physical metadata
1852
+ # coordinate_transformations expects a list of lists (one per resolution level)
1409
1853
  write_image(
1410
1854
  image_data,
1411
1855
  group=root,
1412
- axes=axes,
1856
+ axes=axes or "zyx",
1857
+ coordinate_transformations=(
1858
+ [[scale_transform]] if scale_transform else None
1859
+ ),
1413
1860
  scaler=None,
1414
1861
  storage_options={"compression": "zstd"},
1415
1862
  )
1416
1863
 
1417
- # Add basic OME-Zarr metadata
1418
- root = zarr.open(store)
1419
- root.attrs["multiscales"] = [
1420
- {
1421
- "version": "0.4",
1422
- "datasets": [{"path": "0"}],
1423
- "axes": [
1424
- {
1425
- "name": ax,
1426
- "type": (
1427
- "space"
1428
- if ax in "xyz"
1429
- else "time" if ax == "t" else "channel"
1430
- ),
1431
- }
1432
- for ax in axes
1433
- ],
1434
- }
1435
- ]
1436
-
1437
- print("OME-Zarr file saved successfully.")
1864
+ print(
1865
+ f"Successfully saved ZARR with metadata: axes={axes}, scale={scale_transform}"
1866
+ )
1438
1867
  return True
1439
1868
 
1440
- except (ValueError, FileNotFoundError) as e:
1441
- print(f"Error during Zarr writing: {e}")
1442
- import traceback
1869
+ except (OSError, PermissionError, ImportError) as e:
1870
+ raise ConversionError(f"ZARR save failed: {str(e)}") from e
1871
+ finally:
1872
+ # Force cleanup of any large intermediate arrays
1873
+ import gc
1443
1874
 
1444
- traceback.print_exc()
1445
- return False
1875
+ gc.collect()
1876
+
1877
+ def _build_scale_transform(
1878
+ self, metadata: dict, axes: str, shape: tuple
1879
+ ) -> dict:
1880
+ """
1881
+ Build OME-Zarr coordinate transformation from microscopy metadata.
1882
+
1883
+ Converts extracted resolution, spacing, and unit information into proper
1884
+ OME-Zarr scale transformation conforming to the specification.
1885
+ """
1886
+ if not axes:
1887
+ return None
1888
+
1889
+ # Initialize scale array with defaults (1.0 for all dimensions)
1890
+ ndim = len(shape)
1891
+ scales = [1.0] * ndim
1892
+
1893
+ # Get physical metadata
1894
+ resolution = metadata.get(
1895
+ "resolution"
1896
+ ) # (x_res, y_res) in pixels/unit
1897
+ spacing = metadata.get("spacing") # z spacing in physical units
1898
+ unit = metadata.get("unit", "micrometer") # Physical unit
1899
+
1900
+ # Map axes to scale values based on extracted metadata
1901
+ for i, axis in enumerate(axes):
1902
+ if axis == "x" and resolution and len(resolution) >= 1:
1903
+ # X resolution: convert from pixels/unit to unit/pixel
1904
+ if resolution[0] > 0:
1905
+ scales[i] = 1.0 / resolution[0]
1906
+ elif axis == "y" and resolution and len(resolution) >= 2:
1907
+ # Y resolution: convert from pixels/unit to unit/pixel
1908
+ if resolution[1] > 0:
1909
+ scales[i] = 1.0 / resolution[1]
1910
+ elif axis == "z" and spacing and spacing > 0:
1911
+ # Z spacing is already in physical units per slice
1912
+ scales[i] = spacing
1913
+ # Time and channel axes remain at 1.0 (no physical scaling)
1914
+
1915
+ # Build the scale transformation
1916
+ scale_transform = {"type": "scale", "scale": scales}
1917
+
1918
+ print(f"Built scale transformation: {scales} (unit: {unit})")
1919
+ return scale_transform
1446
1920
 
1447
1921
 
1448
1922
  class MicroscopyImageConverterWidget(QWidget):
1449
- """Main widget for microscopy image conversion to TIF/ZARR"""
1923
+ """Enhanced main widget for microscopy image conversion"""
1450
1924
 
1451
1925
  def __init__(self, viewer: napari.Viewer):
1452
1926
  super().__init__()
@@ -1461,195 +1935,168 @@ class MicroscopyImageConverterWidget(QWidget):
1461
1935
  AcquiferLoader,
1462
1936
  ]
1463
1937
 
1464
- # Selected series for conversion
1465
- self.selected_series = {} # {filepath: series_index}
1466
-
1467
- # Track files that should export all series
1468
- self.export_all_series = {} # {filepath: boolean}
1469
-
1470
- # Working threads
1938
+ # Conversion state
1939
+ self.selected_series = {}
1940
+ self.export_all_series = {}
1471
1941
  self.scan_worker = None
1472
1942
  self.conversion_worker = None
1473
-
1474
- # Flag to prevent recursive radio button updates
1475
1943
  self.updating_format_buttons = False
1476
1944
 
1477
- # Create layout
1945
+ self._setup_ui()
1946
+
1947
+ def _setup_ui(self):
1948
+ """Set up the user interface"""
1478
1949
  main_layout = QVBoxLayout()
1479
1950
  self.setLayout(main_layout)
1480
1951
 
1481
- # File selection widgets
1952
+ # Input folder selection
1482
1953
  folder_layout = QHBoxLayout()
1483
- folder_label = QLabel("Input Folder:")
1954
+ folder_layout.addWidget(QLabel("Input Folder:"))
1484
1955
  self.folder_edit = QLineEdit()
1485
1956
  browse_button = QPushButton("Browse...")
1486
1957
  browse_button.clicked.connect(self.browse_folder)
1487
1958
 
1488
- folder_layout.addWidget(folder_label)
1489
1959
  folder_layout.addWidget(self.folder_edit)
1490
1960
  folder_layout.addWidget(browse_button)
1491
1961
  main_layout.addLayout(folder_layout)
1492
1962
 
1493
- # File filter widgets
1963
+ # File filters
1494
1964
  filter_layout = QHBoxLayout()
1495
- filter_label = QLabel("File Filter:")
1965
+ filter_layout.addWidget(QLabel("File Filter:"))
1496
1966
  self.filter_edit = QLineEdit()
1497
1967
  self.filter_edit.setPlaceholderText(
1498
- ".lif, .nd2, .ndpi, .czi, acquifer (comma separated)"
1968
+ ".lif, .nd2, .ndpi, .czi, acquifer"
1499
1969
  )
1500
- self.filter_edit.setText(".lif,.nd2,.ndpi,.czi, acquifer")
1970
+ self.filter_edit.setText(".lif,.nd2,.ndpi,.czi,acquifer")
1501
1971
  scan_button = QPushButton("Scan Folder")
1502
1972
  scan_button.clicked.connect(self.scan_folder)
1503
1973
 
1504
- filter_layout.addWidget(filter_label)
1505
1974
  filter_layout.addWidget(self.filter_edit)
1506
1975
  filter_layout.addWidget(scan_button)
1507
1976
  main_layout.addLayout(filter_layout)
1508
1977
 
1509
- # Progress bar for scanning
1978
+ # Progress bars
1510
1979
  self.scan_progress = QProgressBar()
1511
1980
  self.scan_progress.setVisible(False)
1512
1981
  main_layout.addWidget(self.scan_progress)
1513
1982
 
1514
- # Files and series tables
1983
+ # Tables layout
1515
1984
  tables_layout = QHBoxLayout()
1516
-
1517
- # Files table
1518
- self.files_table = SeriesTableWidget(viewer)
1985
+ self.files_table = SeriesTableWidget(self.viewer)
1986
+ self.series_widget = SeriesDetailWidget(self, self.viewer)
1519
1987
  tables_layout.addWidget(self.files_table)
1520
-
1521
- # Series details widget
1522
- self.series_widget = SeriesDetailWidget(self, viewer)
1523
1988
  tables_layout.addWidget(self.series_widget)
1524
-
1525
1989
  main_layout.addLayout(tables_layout)
1526
1990
 
1527
- # Conversion options
1528
- options_layout = QVBoxLayout()
1529
-
1530
- # Output format selection
1991
+ # Format selection
1531
1992
  format_layout = QHBoxLayout()
1532
- format_label = QLabel("Output Format:")
1533
- self.tif_radio = QCheckBox("TIF (< 4GB)")
1993
+ format_layout.addWidget(QLabel("Output Format:"))
1994
+ self.tif_radio = QCheckBox("TIF")
1534
1995
  self.tif_radio.setChecked(True)
1535
- self.zarr_radio = QCheckBox("ZARR (> 4GB)")
1996
+ self.zarr_radio = QCheckBox("ZARR (Recommended for >4GB)")
1536
1997
 
1537
- # Make checkboxes mutually exclusive like radio buttons
1538
1998
  self.tif_radio.toggled.connect(self.handle_format_toggle)
1539
1999
  self.zarr_radio.toggled.connect(self.handle_format_toggle)
1540
2000
 
1541
- format_layout.addWidget(format_label)
1542
2001
  format_layout.addWidget(self.tif_radio)
1543
2002
  format_layout.addWidget(self.zarr_radio)
1544
- options_layout.addLayout(format_layout)
2003
+ main_layout.addLayout(format_layout)
1545
2004
 
1546
- # Output folder selection
2005
+ # Output folder
1547
2006
  output_layout = QHBoxLayout()
1548
- output_label = QLabel("Output Folder:")
2007
+ output_layout.addWidget(QLabel("Output Folder:"))
1549
2008
  self.output_edit = QLineEdit()
1550
2009
  output_browse = QPushButton("Browse...")
1551
2010
  output_browse.clicked.connect(self.browse_output)
1552
2011
 
1553
- output_layout.addWidget(output_label)
1554
2012
  output_layout.addWidget(self.output_edit)
1555
2013
  output_layout.addWidget(output_browse)
1556
- options_layout.addLayout(output_layout)
1557
-
1558
- main_layout.addLayout(options_layout)
2014
+ main_layout.addLayout(output_layout)
1559
2015
 
1560
- # Conversion progress bar
2016
+ # Conversion progress
1561
2017
  self.conversion_progress = QProgressBar()
1562
2018
  self.conversion_progress.setVisible(False)
1563
2019
  main_layout.addWidget(self.conversion_progress)
1564
2020
 
1565
- # Conversion and cancel buttons
2021
+ # Control buttons
1566
2022
  button_layout = QHBoxLayout()
1567
2023
  convert_button = QPushButton("Convert Selected Files")
1568
2024
  convert_button.clicked.connect(self.convert_files)
2025
+ convert_all_button = QPushButton("Convert All Files")
2026
+ convert_all_button.clicked.connect(self.convert_all_files)
1569
2027
  self.cancel_button = QPushButton("Cancel")
1570
2028
  self.cancel_button.clicked.connect(self.cancel_operation)
1571
2029
  self.cancel_button.setVisible(False)
1572
2030
 
1573
2031
  button_layout.addWidget(convert_button)
2032
+ button_layout.addWidget(convert_all_button)
1574
2033
  button_layout.addWidget(self.cancel_button)
1575
2034
  main_layout.addLayout(button_layout)
1576
2035
 
1577
- # Status label
2036
+ # Status
1578
2037
  self.status_label = QLabel("")
1579
2038
  main_layout.addWidget(self.status_label)
1580
2039
 
1581
- def cancel_operation(self):
1582
- """Cancel current operation"""
1583
- if self.scan_worker and self.scan_worker.isRunning():
1584
- self.scan_worker.terminate()
1585
- self.scan_worker = None
1586
- self.status_label.setText("Scanning cancelled")
1587
-
1588
- if self.conversion_worker and self.conversion_worker.isRunning():
1589
- self.conversion_worker.stop()
1590
- self.status_label.setText("Conversion cancelled")
1591
-
1592
- self.scan_progress.setVisible(False)
1593
- self.conversion_progress.setVisible(False)
1594
- self.cancel_button.setVisible(False)
1595
-
1596
2040
  def browse_folder(self):
1597
- """Open a folder browser dialog"""
2041
+ """Browse for input folder"""
1598
2042
  folder = QFileDialog.getExistingDirectory(self, "Select Input Folder")
1599
2043
  if folder:
1600
2044
  self.folder_edit.setText(folder)
1601
2045
 
1602
2046
  def browse_output(self):
1603
- """Open a folder browser dialog for output folder"""
2047
+ """Browse for output folder"""
1604
2048
  folder = QFileDialog.getExistingDirectory(self, "Select Output Folder")
1605
2049
  if folder:
1606
2050
  self.output_edit.setText(folder)
1607
2051
 
1608
2052
  def scan_folder(self):
1609
- """Scan the selected folder for image files"""
2053
+ """Scan folder for image files"""
1610
2054
  folder = self.folder_edit.text()
1611
2055
  if not folder or not os.path.isdir(folder):
1612
2056
  self.status_label.setText("Please select a valid folder")
1613
2057
  return
1614
2058
 
1615
- # Get file filters
1616
2059
  filters = [
1617
2060
  f.strip() for f in self.filter_edit.text().split(",") if f.strip()
1618
2061
  ]
1619
2062
  if not filters:
1620
2063
  filters = [".lif", ".nd2", ".ndpi", ".czi"]
1621
2064
 
1622
- # Clear existing files
2065
+ # Clear existing data and force garbage collection
1623
2066
  self.files_table.setRowCount(0)
1624
2067
  self.files_table.file_data.clear()
1625
2068
 
1626
- # Set up and start the worker thread
2069
+ # Clear any cached datasets
2070
+ AcquiferLoader._dataset_cache.clear()
2071
+
2072
+ # Force memory cleanup before starting scan
2073
+ import gc
2074
+
2075
+ gc.collect()
2076
+
2077
+ # Start scan worker
1627
2078
  self.scan_worker = ScanFolderWorker(folder, filters)
1628
2079
  self.scan_worker.progress.connect(self.update_scan_progress)
1629
2080
  self.scan_worker.finished.connect(self.process_found_files)
1630
2081
  self.scan_worker.error.connect(self.show_error)
1631
2082
 
1632
- # Show progress bar and start worker
1633
2083
  self.scan_progress.setVisible(True)
1634
2084
  self.scan_progress.setValue(0)
1635
2085
  self.cancel_button.setVisible(True)
1636
2086
  self.status_label.setText("Scanning folder...")
1637
2087
  self.scan_worker.start()
1638
2088
 
1639
- def update_scan_progress(self, current, total):
1640
- """Update the scan progress bar"""
2089
+ def update_scan_progress(self, current: int, total: int):
2090
+ """Update scan progress"""
1641
2091
  if total > 0:
1642
2092
  self.scan_progress.setValue(int(current * 100 / total))
1643
2093
 
1644
- def process_found_files(self, found_files):
1645
- """Process the list of found files after scanning is complete"""
1646
- # Hide progress bar
2094
+ def process_found_files(self, found_files: List[str]):
2095
+ """Process found files and add to table"""
1647
2096
  self.scan_progress.setVisible(False)
1648
2097
  self.cancel_button.setVisible(False)
1649
2098
 
1650
- # Process files
1651
2099
  with concurrent.futures.ThreadPoolExecutor() as executor:
1652
- # Process files in parallel to get series counts
1653
2100
  futures = {}
1654
2101
  for filepath in found_files:
1655
2102
  file_type = self.get_file_type(filepath)
@@ -1661,47 +2108,62 @@ class MicroscopyImageConverterWidget(QWidget):
1661
2108
  )
1662
2109
  futures[future] = (filepath, file_type)
1663
2110
 
1664
- # Process results as they complete
1665
- file_count = len(found_files)
1666
- processed = 0
1667
-
1668
2111
  for i, future in enumerate(
1669
2112
  concurrent.futures.as_completed(futures)
1670
2113
  ):
1671
- processed = i + 1
1672
2114
  filepath, file_type = futures[future]
1673
-
1674
2115
  try:
1675
2116
  series_count = future.result()
1676
- # Add file to table
1677
2117
  self.files_table.add_file(
1678
2118
  filepath, file_type, series_count
1679
2119
  )
1680
- except (ValueError, FileNotFoundError) as e:
1681
- print(f"Error processing {filepath}: {str(e)}")
1682
- # Add file with error indication
1683
- self.files_table.add_file(filepath, file_type, -1)
2120
+ except (OSError, FileFormatError, ValueError) as e:
2121
+ print(f"Error processing {filepath}: {e}")
2122
+ self.files_table.add_file(filepath, file_type, 0)
1684
2123
 
1685
2124
  # Update status periodically
1686
- if processed % 5 == 0 or processed == file_count:
2125
+ if i % 5 == 0:
1687
2126
  self.status_label.setText(
1688
- f"Processed {processed}/{file_count} files..."
2127
+ f"Processed {i+1}/{len(futures)} files..."
1689
2128
  )
1690
2129
  QApplication.processEvents()
1691
2130
 
1692
2131
  self.status_label.setText(f"Found {len(found_files)} files")
1693
2132
 
1694
- def show_error(self, error_message):
2133
+ def show_error(self, error_message: str):
1695
2134
  """Show error message"""
1696
2135
  self.status_label.setText(f"Error: {error_message}")
1697
2136
  self.scan_progress.setVisible(False)
1698
2137
  self.cancel_button.setVisible(False)
1699
2138
  QMessageBox.critical(self, "Error", error_message)
1700
2139
 
2140
+ def cancel_operation(self):
2141
+ """Cancel current operation"""
2142
+ if self.scan_worker and self.scan_worker.isRunning():
2143
+ self.scan_worker.terminate()
2144
+ self.scan_worker.deleteLater()
2145
+ self.scan_worker = None
2146
+
2147
+ if self.conversion_worker and self.conversion_worker.isRunning():
2148
+ self.conversion_worker.stop()
2149
+ self.conversion_worker.deleteLater()
2150
+ self.conversion_worker = None
2151
+
2152
+ # Force memory cleanup after cancellation
2153
+ import gc
2154
+
2155
+ gc.collect()
2156
+
2157
+ self.scan_progress.setVisible(False)
2158
+ self.conversion_progress.setVisible(False)
2159
+ self.cancel_button.setVisible(False)
2160
+ self.status_label.setText("Operation cancelled")
2161
+
1701
2162
  def get_file_type(self, filepath: str) -> str:
1702
- """Determine the file type based on extension or directory type"""
2163
+ """Determine file type"""
1703
2164
  if os.path.isdir(filepath) and AcquiferLoader.can_load(filepath):
1704
2165
  return "Acquifer"
2166
+
1705
2167
  ext = filepath.lower()
1706
2168
  if ext.endswith(".lif"):
1707
2169
  return "LIF"
@@ -1714,124 +2176,142 @@ class MicroscopyImageConverterWidget(QWidget):
1714
2176
  return "Unknown"
1715
2177
 
1716
2178
  def get_file_loader(self, filepath: str) -> Optional[FormatLoader]:
1717
- """Get the appropriate loader for the file type"""
2179
+ """Get appropriate loader for file"""
1718
2180
  for loader in self.loaders:
1719
2181
  if loader.can_load(filepath):
1720
2182
  return loader
1721
2183
  return None
1722
2184
 
1723
2185
  def show_series_details(self, filepath: str):
1724
- """Show details for the series in the selected file"""
2186
+ """Show series details"""
1725
2187
  self.series_widget.set_file(filepath)
1726
2188
 
1727
2189
  def set_selected_series(self, filepath: str, series_index: int):
1728
- """Set the selected series for a file"""
2190
+ """Set selected series for file"""
1729
2191
  self.selected_series[filepath] = series_index
1730
2192
 
1731
2193
  def set_export_all_series(self, filepath: str, export_all: bool):
1732
- """Set whether to export all series for a file"""
2194
+ """Set export all series flag"""
1733
2195
  self.export_all_series[filepath] = export_all
1734
-
1735
- # If exporting all, we still need a default series in selected_series
1736
- # for files that are marked for export all
1737
2196
  if export_all and filepath not in self.selected_series:
1738
2197
  self.selected_series[filepath] = 0
1739
2198
 
1740
2199
  def load_image(self, filepath: str):
1741
- """Load an image file into the viewer"""
1742
- loader = self.get_file_loader(filepath)
1743
- if not loader:
1744
- self.viewer.status = f"Unsupported file format: {filepath}"
1745
- return
1746
-
2200
+ """Load image into viewer"""
1747
2201
  try:
1748
- # For non-series files, just load the first series
1749
- series_index = 0
1750
- image_data = loader.load_series(filepath, series_index)
2202
+ loader = self.get_file_loader(filepath)
2203
+ if not loader:
2204
+ raise FileFormatError("Unsupported file format")
1751
2205
 
1752
- # Clear existing layers and display the image
2206
+ image_data = loader.load_series(filepath, 0)
1753
2207
  self.viewer.layers.clear()
1754
- self.viewer.add_image(image_data, name=f"{Path(filepath).stem}")
1755
-
1756
- # Update status
2208
+ layer_name = f"{Path(filepath).stem}"
2209
+ self.viewer.add_image(image_data, name=layer_name)
1757
2210
  self.viewer.status = f"Loaded {Path(filepath).name}"
1758
- except (ValueError, FileNotFoundError) as e:
1759
- self.viewer.status = f"Error loading image: {str(e)}"
1760
- QMessageBox.warning(
1761
- self, "Error", f"Could not load image: {str(e)}"
1762
- )
1763
2211
 
1764
- def is_output_folder_valid(self, folder):
1765
- """Check if the output folder is valid and writable"""
1766
- if not folder:
1767
- self.status_label.setText("Please specify an output folder")
1768
- return False
2212
+ except (OSError, FileFormatError, MemoryError) as e:
2213
+ error_msg = f"Error loading image: {str(e)}"
2214
+ self.viewer.status = error_msg
2215
+ QMessageBox.warning(self, "Load Error", error_msg)
1769
2216
 
1770
- # Check if folder exists, if not try to create it
1771
- if not os.path.exists(folder):
1772
- try:
1773
- os.makedirs(folder)
1774
- except (FileNotFoundError, PermissionError) as e:
2217
+ def update_format_buttons(self, use_zarr: bool = False):
2218
+ """Update format buttons based on file size"""
2219
+ if self.updating_format_buttons:
2220
+ return
2221
+
2222
+ self.updating_format_buttons = True
2223
+ try:
2224
+ if use_zarr:
2225
+ self.zarr_radio.setChecked(True)
2226
+ self.tif_radio.setChecked(False)
1775
2227
  self.status_label.setText(
1776
- f"Cannot create output folder: {str(e)}"
2228
+ "Auto-selected ZARR format for large file (>4GB)"
1777
2229
  )
1778
- return False
2230
+ else:
2231
+ self.tif_radio.setChecked(True)
2232
+ self.zarr_radio.setChecked(False)
2233
+ finally:
2234
+ self.updating_format_buttons = False
1779
2235
 
1780
- # Check if folder is writable
1781
- if not os.access(folder, os.W_OK):
1782
- self.status_label.setText("Output folder is not writable")
1783
- return False
2236
+ def handle_format_toggle(self, checked: bool):
2237
+ """Handle format toggle"""
2238
+ if self.updating_format_buttons:
2239
+ return
1784
2240
 
1785
- return True
2241
+ self.updating_format_buttons = True
2242
+ try:
2243
+ sender = self.sender()
2244
+ if sender == self.tif_radio and checked:
2245
+ self.zarr_radio.setChecked(False)
2246
+ elif sender == self.zarr_radio and checked:
2247
+ self.tif_radio.setChecked(False)
2248
+ finally:
2249
+ self.updating_format_buttons = False
1786
2250
 
1787
2251
  def convert_files(self):
1788
- """Convert selected files to TIF or ZARR"""
1789
- # Check if any files are selected
1790
- if not self.selected_series:
1791
- self.status_label.setText("No files selected for conversion")
1792
- return
2252
+ """Convert selected files - only converts the currently displayed file"""
2253
+ try:
2254
+ # Get the currently displayed file from series_widget
2255
+ current_file = self.series_widget.current_file
1793
2256
 
1794
- # Check output folder
1795
- output_folder = self.output_edit.text()
1796
- if not output_folder:
1797
- output_folder = os.path.join(self.folder_edit.text(), "converted")
2257
+ if not current_file:
2258
+ self.status_label.setText(
2259
+ "Please select a file from the table first"
2260
+ )
2261
+ return
1798
2262
 
1799
- # Validate output folder
1800
- if not self.is_output_folder_valid(output_folder):
1801
- return
2263
+ # Ensure the current file is in selected_series
2264
+ if current_file not in self.selected_series:
2265
+ self.selected_series[current_file] = 0
2266
+
2267
+ # Validate output folder
2268
+ output_folder = self.output_edit.text()
2269
+ if not output_folder:
2270
+ output_folder = os.path.join(
2271
+ self.folder_edit.text(), "converted"
2272
+ )
1802
2273
 
1803
- # Create files to convert list
1804
- files_to_convert = []
2274
+ if not self._validate_output_folder(output_folder):
2275
+ return
2276
+
2277
+ # Build conversion list - only for the current file
2278
+ files_to_convert = []
2279
+ filepath = current_file
2280
+ series_index = self.selected_series.get(filepath, 0)
1805
2281
 
1806
- for filepath, series_index in self.selected_series.items():
1807
- # Check if we should export all series for this file
1808
2282
  if self.export_all_series.get(filepath, False):
1809
- # Get the number of series for this file
1810
2283
  loader = self.get_file_loader(filepath)
1811
2284
  if loader:
1812
2285
  try:
1813
2286
  series_count = loader.get_series_count(filepath)
1814
- # Add all series for this file
1815
2287
  for i in range(series_count):
1816
2288
  files_to_convert.append((filepath, i))
1817
- except (ValueError, FileNotFoundError) as e:
2289
+ except (OSError, FileFormatError, ValueError) as e:
1818
2290
  self.status_label.setText(
1819
2291
  f"Error getting series count: {str(e)}"
1820
2292
  )
1821
- QMessageBox.warning(
1822
- self,
1823
- "Error",
1824
- f"Could not get series count for {Path(filepath).name}: {str(e)}",
1825
- )
2293
+ return
1826
2294
  else:
1827
- # Just add the selected series
1828
2295
  files_to_convert.append((filepath, series_index))
1829
2296
 
1830
- if not files_to_convert:
1831
- self.status_label.setText("No valid files to convert")
1832
- return
2297
+ if not files_to_convert:
2298
+ self.status_label.setText("No valid files to convert")
2299
+ return
2300
+
2301
+ # Start conversion
2302
+ self._start_conversion_worker(files_to_convert, output_folder)
2303
+
2304
+ except (OSError, PermissionError, ValueError) as e:
2305
+ QMessageBox.critical(
2306
+ self,
2307
+ "Conversion Error",
2308
+ f"Failed to start conversion: {str(e)}",
2309
+ )
1833
2310
 
1834
- # Set up and start the conversion worker thread
2311
+ def _start_conversion_worker(
2312
+ self, files_to_convert: List[Tuple[str, int]], output_folder: str
2313
+ ):
2314
+ """Start the conversion worker thread"""
1835
2315
  self.conversion_worker = ConversionWorker(
1836
2316
  files_to_convert=files_to_convert,
1837
2317
  output_folder=output_folder,
@@ -1839,39 +2319,112 @@ class MicroscopyImageConverterWidget(QWidget):
1839
2319
  file_loader_func=self.get_file_loader,
1840
2320
  )
1841
2321
 
1842
- # Connect signals
1843
2322
  self.conversion_worker.progress.connect(
1844
2323
  self.update_conversion_progress
1845
2324
  )
1846
- self.conversion_worker.file_done.connect(
1847
- self.handle_file_conversion_result
1848
- )
2325
+ self.conversion_worker.file_done.connect(self.handle_conversion_result)
1849
2326
  self.conversion_worker.finished.connect(self.conversion_completed)
1850
2327
 
1851
- # Show progress bar and start worker
1852
2328
  self.conversion_progress.setVisible(True)
1853
2329
  self.conversion_progress.setValue(0)
1854
2330
  self.cancel_button.setVisible(True)
1855
2331
  self.status_label.setText(
1856
- f"Starting conversion of {len(files_to_convert)} files/series..."
2332
+ f"Converting {len(files_to_convert)} files/series..."
1857
2333
  )
1858
2334
 
1859
- # Start conversion
1860
2335
  self.conversion_worker.start()
1861
2336
 
1862
- def update_conversion_progress(self, current, total, filename):
1863
- """Update conversion progress bar and status"""
2337
+ def convert_all_files(self):
2338
+ """Convert all files with default settings"""
2339
+ try:
2340
+ all_files = list(self.files_table.file_data.keys())
2341
+ if not all_files:
2342
+ self.status_label.setText("No files available for conversion")
2343
+ return
2344
+
2345
+ # Validate output folder
2346
+ output_folder = self.output_edit.text()
2347
+ if not output_folder:
2348
+ output_folder = os.path.join(
2349
+ self.folder_edit.text(), "converted"
2350
+ )
2351
+
2352
+ if not self._validate_output_folder(output_folder):
2353
+ return
2354
+
2355
+ # Build conversion list for all files
2356
+ files_to_convert = []
2357
+ for filepath in all_files:
2358
+ file_info = self.files_table.file_data.get(filepath)
2359
+ if file_info and file_info.get("series_count", 0) > 1:
2360
+ # For files with multiple series, export all
2361
+ loader = self.get_file_loader(filepath)
2362
+ if loader:
2363
+ try:
2364
+ series_count = loader.get_series_count(filepath)
2365
+ for i in range(series_count):
2366
+ files_to_convert.append((filepath, i))
2367
+ except (OSError, FileFormatError, ValueError) as e:
2368
+ self.status_label.setText(
2369
+ f"Error getting series count: {str(e)}"
2370
+ )
2371
+ return
2372
+ else:
2373
+ # For single image files
2374
+ files_to_convert.append((filepath, 0))
2375
+
2376
+ if not files_to_convert:
2377
+ self.status_label.setText("No valid files to convert")
2378
+ return
2379
+
2380
+ # Start conversion
2381
+ self._start_conversion_worker(files_to_convert, output_folder)
2382
+
2383
+ except (OSError, PermissionError, ValueError) as e:
2384
+ QMessageBox.critical(
2385
+ self,
2386
+ "Conversion Error",
2387
+ f"Failed to start conversion: {str(e)}",
2388
+ )
2389
+
2390
+ def _validate_output_folder(self, folder: str) -> bool:
2391
+ """Validate output folder"""
2392
+ if not folder:
2393
+ self.status_label.setText("Please specify an output folder")
2394
+ return False
2395
+
2396
+ if not os.path.exists(folder):
2397
+ try:
2398
+ os.makedirs(folder)
2399
+ except (OSError, PermissionError) as e:
2400
+ self.status_label.setText(
2401
+ f"Cannot create output folder: {str(e)}"
2402
+ )
2403
+ return False
2404
+
2405
+ if not os.access(folder, os.W_OK):
2406
+ self.status_label.setText("Output folder is not writable")
2407
+ return False
2408
+
2409
+ return True
2410
+
2411
+ def update_conversion_progress(
2412
+ self, current: int, total: int, filename: str
2413
+ ):
2414
+ """Update conversion progress"""
1864
2415
  if total > 0:
1865
2416
  self.conversion_progress.setValue(int(current * 100 / total))
1866
2417
  self.status_label.setText(
1867
2418
  f"Converting {filename} ({current}/{total})..."
1868
2419
  )
1869
2420
 
1870
- def handle_file_conversion_result(self, filepath, success, message):
1871
- """Handle result of a single file conversion"""
2421
+ def handle_conversion_result(
2422
+ self, filepath: str, success: bool, message: str
2423
+ ):
2424
+ """Handle single file conversion result"""
1872
2425
  filename = Path(filepath).name
1873
2426
  if success:
1874
- print(f"Successfully converted: {filename} - {message}")
2427
+ print(f"Successfully converted: {filename}")
1875
2428
  else:
1876
2429
  print(f"Failed to convert: {filename} - {message}")
1877
2430
  QMessageBox.warning(
@@ -1880,14 +2433,25 @@ class MicroscopyImageConverterWidget(QWidget):
1880
2433
  f"Error converting {filename}: {message}",
1881
2434
  )
1882
2435
 
1883
- def conversion_completed(self, success_count):
1884
- """Handle completion of all conversions"""
2436
+ def conversion_completed(self, success_count: int):
2437
+ """Handle conversion completion"""
1885
2438
  self.conversion_progress.setVisible(False)
1886
2439
  self.cancel_button.setVisible(False)
1887
2440
 
2441
+ # Clean up conversion worker
2442
+ if self.conversion_worker:
2443
+ self.conversion_worker.deleteLater()
2444
+ self.conversion_worker = None
2445
+
2446
+ # Force memory cleanup
2447
+ import gc
2448
+
2449
+ gc.collect()
2450
+
1888
2451
  output_folder = self.output_edit.text()
1889
2452
  if not output_folder:
1890
2453
  output_folder = os.path.join(self.folder_edit.text(), "converted")
2454
+
1891
2455
  if success_count > 0:
1892
2456
  self.status_label.setText(
1893
2457
  f"Successfully converted {success_count} files to {output_folder}"
@@ -1895,66 +2459,17 @@ class MicroscopyImageConverterWidget(QWidget):
1895
2459
  else:
1896
2460
  self.status_label.setText("No files were converted")
1897
2461
 
1898
- def update_format_buttons(self, use_zarr=False):
1899
- """Update format radio buttons based on file size
1900
-
1901
- Args:
1902
- use_zarr: True if file size > 4GB, otherwise False
1903
- """
1904
- if self.updating_format_buttons:
1905
- return
1906
-
1907
- self.updating_format_buttons = True
1908
- try:
1909
- if use_zarr:
1910
- self.zarr_radio.setChecked(True)
1911
- self.tif_radio.setChecked(False)
1912
- print("Auto-selected ZARR format for large file (>4GB)")
1913
- else:
1914
- self.tif_radio.setChecked(True)
1915
- self.zarr_radio.setChecked(False)
1916
- print("Auto-selected TIF format for smaller file (<4GB)")
1917
- finally:
1918
- self.updating_format_buttons = False
1919
-
1920
- def handle_format_toggle(self, checked):
1921
- """Handle format radio button toggle"""
1922
- if self.updating_format_buttons:
1923
- return
1924
-
1925
- self.updating_format_buttons = True
1926
- try:
1927
- # Make checkboxes mutually exclusive like radio buttons
1928
- sender = self.sender()
1929
- if sender == self.tif_radio and checked:
1930
- self.zarr_radio.setChecked(False)
1931
- elif sender == self.zarr_radio and checked:
1932
- self.tif_radio.setChecked(False)
1933
- finally:
1934
- self.updating_format_buttons = False
1935
-
1936
2462
 
1937
- # Create a MagicGUI widget that creates and returns the converter widget
1938
- @magicgui(
1939
- call_button="Start Microscopy Image Converter",
1940
- layout="vertical",
1941
- )
2463
+ @magicgui(call_button="Start Microscopy Image Converter", layout="vertical")
1942
2464
  def microscopy_converter(viewer: napari.Viewer):
1943
- """
1944
- Start the microscopy image converter tool
1945
- """
1946
- # Create the converter widget
2465
+ """Start the enhanced microscopy image converter tool"""
1947
2466
  converter_widget = MicroscopyImageConverterWidget(viewer)
1948
-
1949
- # Add to viewer
1950
2467
  viewer.window.add_dock_widget(
1951
2468
  converter_widget, name="Microscopy Image Converter", area="right"
1952
2469
  )
1953
-
1954
2470
  return converter_widget
1955
2471
 
1956
2472
 
1957
- # This is what napari calls to get the widget
1958
2473
  def napari_experimental_provide_dock_widget():
1959
2474
  """Provide the converter widget to Napari"""
1960
2475
  return microscopy_converter