napari-tmidas 0.2.2__py3-none-any.whl → 0.2.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- napari_tmidas/__init__.py +35 -5
- napari_tmidas/_crop_anything.py +1520 -609
- napari_tmidas/_env_manager.py +76 -0
- napari_tmidas/_file_conversion.py +1646 -1131
- napari_tmidas/_file_selector.py +1455 -216
- napari_tmidas/_label_inspection.py +83 -8
- napari_tmidas/_processing_worker.py +309 -0
- napari_tmidas/_reader.py +6 -10
- napari_tmidas/_registry.py +2 -2
- napari_tmidas/_roi_colocalization.py +1221 -84
- napari_tmidas/_tests/test_crop_anything.py +123 -0
- napari_tmidas/_tests/test_env_manager.py +89 -0
- napari_tmidas/_tests/test_grid_view_overlay.py +193 -0
- napari_tmidas/_tests/test_init.py +98 -0
- napari_tmidas/_tests/test_intensity_label_filter.py +222 -0
- napari_tmidas/_tests/test_label_inspection.py +86 -0
- napari_tmidas/_tests/test_processing_basic.py +500 -0
- napari_tmidas/_tests/test_processing_worker.py +142 -0
- napari_tmidas/_tests/test_regionprops_analysis.py +547 -0
- napari_tmidas/_tests/test_registry.py +70 -2
- napari_tmidas/_tests/test_scipy_filters.py +168 -0
- napari_tmidas/_tests/test_skimage_filters.py +259 -0
- napari_tmidas/_tests/test_split_channels.py +217 -0
- napari_tmidas/_tests/test_spotiflow.py +87 -0
- napari_tmidas/_tests/test_tyx_display_fix.py +142 -0
- napari_tmidas/_tests/test_ui_utils.py +68 -0
- napari_tmidas/_tests/test_widget.py +30 -0
- napari_tmidas/_tests/test_windows_basic.py +66 -0
- napari_tmidas/_ui_utils.py +57 -0
- napari_tmidas/_version.py +16 -3
- napari_tmidas/_widget.py +41 -4
- napari_tmidas/processing_functions/basic.py +557 -20
- napari_tmidas/processing_functions/careamics_env_manager.py +72 -99
- napari_tmidas/processing_functions/cellpose_env_manager.py +415 -112
- napari_tmidas/processing_functions/cellpose_segmentation.py +132 -191
- napari_tmidas/processing_functions/colocalization.py +513 -56
- napari_tmidas/processing_functions/grid_view_overlay.py +703 -0
- napari_tmidas/processing_functions/intensity_label_filter.py +422 -0
- napari_tmidas/processing_functions/regionprops_analysis.py +1280 -0
- napari_tmidas/processing_functions/sam2_env_manager.py +53 -69
- napari_tmidas/processing_functions/sam2_mp4.py +274 -195
- napari_tmidas/processing_functions/scipy_filters.py +403 -8
- napari_tmidas/processing_functions/skimage_filters.py +424 -212
- napari_tmidas/processing_functions/spotiflow_detection.py +949 -0
- napari_tmidas/processing_functions/spotiflow_env_manager.py +591 -0
- napari_tmidas/processing_functions/timepoint_merger.py +334 -86
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/METADATA +71 -30
- napari_tmidas-0.2.5.dist-info/RECORD +63 -0
- napari_tmidas/_tests/__init__.py +0 -0
- napari_tmidas-0.2.2.dist-info/RECORD +0 -40
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/WHEEL +0 -0
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/entry_points.txt +0 -0
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.5.dist-info}/top_level.txt +0 -0
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Batch Microscopy Image File Conversion
|
|
3
|
-
|
|
4
|
-
This module provides
|
|
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
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
#
|
|
185
|
-
self.export_all_checkbox.
|
|
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
|
-
#
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
)
|
|
182
|
+
# Re-enable signals
|
|
183
|
+
self.export_all_checkbox.blockSignals(False)
|
|
218
184
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
242
|
-
f"
|
|
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
|
|
276
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
self.current_file, series_index
|
|
293
|
-
)
|
|
251
|
+
series_index = self.series_selector.itemData(
|
|
252
|
+
self.series_selector.currentIndex()
|
|
253
|
+
)
|
|
294
254
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
)
|
|
255
|
+
if series_index >= self.max_series:
|
|
256
|
+
self.info_label.setText("Error: Series index out of range")
|
|
257
|
+
return
|
|
299
258
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
reordered = image_data.transpose(reorder_indices)
|
|
312
|
+
return image_data.transpose(reorder_indices)
|
|
375
313
|
else:
|
|
376
|
-
|
|
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"
|
|
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(
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
#
|
|
418
|
-
|
|
419
|
-
|
|
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(
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
501
|
-
|
|
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(
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
542
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
969
|
+
return metadata
|
|
589
970
|
|
|
590
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
621
|
-
|
|
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 (
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
|
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
|
|
1029
|
+
"axes": slide.properties.get(
|
|
1030
|
+
"tiffslide.series-axes", "YX"
|
|
1031
|
+
),
|
|
654
1032
|
"resolution": (
|
|
655
|
-
slide.properties
|
|
656
|
-
slide.properties
|
|
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,
|
|
661
|
-
|
|
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
|
-
"""
|
|
676
|
-
|
|
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
|
-
|
|
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
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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(
|
|
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
|
-
|
|
696
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
704
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
735
|
-
|
|
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
|
-
|
|
738
|
-
|
|
1207
|
+
# Get basic metadata
|
|
1208
|
+
metadata = {}
|
|
739
1209
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
747
|
-
|
|
1230
|
+
except (AttributeError, RuntimeError):
|
|
1231
|
+
print(
|
|
1232
|
+
"Warning: Could not extract scale information from metadata"
|
|
1233
|
+
)
|
|
748
1234
|
|
|
749
|
-
#
|
|
1235
|
+
# Get actual data to determine final dimensions after squeezing
|
|
750
1236
|
try:
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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 (
|
|
774
|
-
print(f"
|
|
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
|
|
779
|
-
|
|
780
|
-
|
|
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
|
|
783
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
791
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1350
|
+
match2 = pattern2.search(metadata_xml)
|
|
1351
|
+
if match2:
|
|
1352
|
+
value = float(match2.group(1))
|
|
1353
|
+
return value * 1e6
|
|
797
1354
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
"""
|
|
1362
|
+
"""Enhanced loader for Acquifer datasets with better detection"""
|
|
806
1363
|
|
|
807
|
-
|
|
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
|
-
|
|
1384
|
+
if not any(acquifer_indicators):
|
|
1385
|
+
return False
|
|
1386
|
+
|
|
1387
|
+
# Verify it contains image files
|
|
821
1388
|
image_files = []
|
|
822
|
-
for
|
|
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(
|
|
1394
|
+
image_files.append(file)
|
|
828
1395
|
|
|
829
|
-
return
|
|
830
|
-
|
|
831
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
raise
|
|
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
|
-
|
|
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
|
|
893
|
-
|
|
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
|
-
|
|
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 (
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
990
|
-
finished = Signal(list)
|
|
991
|
-
error = Signal(str)
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
1019
|
-
if
|
|
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
|
-
#
|
|
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
|
-
|
|
1035
|
-
|
|
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
|
-
"""
|
|
1567
|
+
"""Enhanced worker thread for file conversion"""
|
|
1040
1568
|
|
|
1041
|
-
progress = Signal(int, int, str)
|
|
1042
|
-
file_done = Signal(str, bool, str)
|
|
1043
|
-
finished = Signal(int)
|
|
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
|
-
|
|
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
|
-
#
|
|
1072
|
-
|
|
1073
|
-
if
|
|
1074
|
-
|
|
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,
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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"
|
|
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
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
1180
|
-
print(f"Image data shape: {image_data.shape}")
|
|
1663
|
+
gc.collect()
|
|
1181
1664
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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"
|
|
1683
|
+
f"Saving TIF: {output_path}, estimated size: {size_gb:.2f}GB"
|
|
1190
1684
|
)
|
|
1191
1685
|
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
-
|
|
1226
|
-
|
|
1227
|
-
print(f"Axes from metadata: {axes}")
|
|
1710
|
+
# Standard TIF saving
|
|
1711
|
+
save_kwargs = {"bigtiff": use_bigtiff, "compression": "zlib"}
|
|
1228
1712
|
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
1723
|
+
tifffile.imwrite(output_path, image_data, **save_kwargs)
|
|
1724
|
+
return os.path.exists(output_path)
|
|
1290
1725
|
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
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,
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
1348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
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
|
-
|
|
1380
|
-
|
|
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
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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 (
|
|
1441
|
-
|
|
1442
|
-
|
|
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
|
-
|
|
1445
|
-
|
|
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
|
-
"""
|
|
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
|
-
#
|
|
1465
|
-
self.selected_series = {}
|
|
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
|
-
|
|
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
|
-
#
|
|
1952
|
+
# Input folder selection
|
|
1482
1953
|
folder_layout = QHBoxLayout()
|
|
1483
|
-
|
|
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
|
|
1963
|
+
# File filters
|
|
1494
1964
|
filter_layout = QHBoxLayout()
|
|
1495
|
-
|
|
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
|
|
1968
|
+
".lif, .nd2, .ndpi, .czi, acquifer"
|
|
1499
1969
|
)
|
|
1500
|
-
self.filter_edit.setText(".lif,.nd2,.ndpi,.czi,
|
|
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
|
|
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
|
-
#
|
|
1983
|
+
# Tables layout
|
|
1515
1984
|
tables_layout = QHBoxLayout()
|
|
1516
|
-
|
|
1517
|
-
|
|
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
|
-
#
|
|
1528
|
-
options_layout = QVBoxLayout()
|
|
1529
|
-
|
|
1530
|
-
# Output format selection
|
|
1991
|
+
# Format selection
|
|
1531
1992
|
format_layout = QHBoxLayout()
|
|
1532
|
-
|
|
1533
|
-
self.tif_radio = QCheckBox("TIF
|
|
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 (>
|
|
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
|
-
|
|
2003
|
+
main_layout.addLayout(format_layout)
|
|
1545
2004
|
|
|
1546
|
-
# Output folder
|
|
2005
|
+
# Output folder
|
|
1547
2006
|
output_layout = QHBoxLayout()
|
|
1548
|
-
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
main_layout.addLayout(options_layout)
|
|
2014
|
+
main_layout.addLayout(output_layout)
|
|
1559
2015
|
|
|
1560
|
-
# Conversion progress
|
|
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
|
-
#
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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 (
|
|
1681
|
-
print(f"Error processing {filepath}: {
|
|
1682
|
-
|
|
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
|
|
2125
|
+
if i % 5 == 0:
|
|
1687
2126
|
self.status_label.setText(
|
|
1688
|
-
f"Processed {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
2202
|
+
loader = self.get_file_loader(filepath)
|
|
2203
|
+
if not loader:
|
|
2204
|
+
raise FileFormatError("Unsupported file format")
|
|
1751
2205
|
|
|
1752
|
-
|
|
2206
|
+
image_data = loader.load_series(filepath, 0)
|
|
1753
2207
|
self.viewer.layers.clear()
|
|
1754
|
-
|
|
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
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
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
|
-
|
|
2228
|
+
"Auto-selected ZARR format for large file (>4GB)"
|
|
1777
2229
|
)
|
|
1778
|
-
|
|
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
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
return
|
|
2236
|
+
def handle_format_toggle(self, checked: bool):
|
|
2237
|
+
"""Handle format toggle"""
|
|
2238
|
+
if self.updating_format_buttons:
|
|
2239
|
+
return
|
|
1784
2240
|
|
|
1785
|
-
|
|
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
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
self.
|
|
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
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
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
|
-
|
|
1804
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
1863
|
-
"""
|
|
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
|
|
1871
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|