napari-tmidas 0.1.3__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- napari_tmidas/_file_conversion.py +1477 -0
- napari_tmidas/_file_selector.py +357 -60
- napari_tmidas/_label_inspection.py +87 -26
- napari_tmidas/_version.py +2 -2
- napari_tmidas/napari.yaml +5 -0
- napari_tmidas/processing_functions/basic.py +24 -42
- napari_tmidas/processing_functions/skimage_filters.py +60 -43
- {napari_tmidas-0.1.3.dist-info → napari_tmidas-0.1.4.dist-info}/METADATA +29 -10
- {napari_tmidas-0.1.3.dist-info → napari_tmidas-0.1.4.dist-info}/RECORD +13 -12
- {napari_tmidas-0.1.3.dist-info → napari_tmidas-0.1.4.dist-info}/LICENSE +0 -0
- {napari_tmidas-0.1.3.dist-info → napari_tmidas-0.1.4.dist-info}/WHEEL +0 -0
- {napari_tmidas-0.1.3.dist-info → napari_tmidas-0.1.4.dist-info}/entry_points.txt +0 -0
- {napari_tmidas-0.1.3.dist-info → napari_tmidas-0.1.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1477 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import napari
|
|
8
|
+
import nd2 # https://github.com/tlambert03/nd2
|
|
9
|
+
import numpy as np
|
|
10
|
+
import tifffile
|
|
11
|
+
import zarr
|
|
12
|
+
from magicgui import magicgui
|
|
13
|
+
from ome_zarr.writer import write_image # https://github.com/ome/ome-zarr-py
|
|
14
|
+
from pylibCZIrw import czi # https://github.com/ZEISS/pylibczirw
|
|
15
|
+
from qtpy.QtCore import Qt, QThread, Signal
|
|
16
|
+
from qtpy.QtWidgets import (
|
|
17
|
+
QApplication,
|
|
18
|
+
QCheckBox,
|
|
19
|
+
QComboBox,
|
|
20
|
+
QFileDialog,
|
|
21
|
+
QHBoxLayout,
|
|
22
|
+
QHeaderView,
|
|
23
|
+
QLabel,
|
|
24
|
+
QLineEdit,
|
|
25
|
+
QMessageBox,
|
|
26
|
+
QProgressBar,
|
|
27
|
+
QPushButton,
|
|
28
|
+
QTableWidget,
|
|
29
|
+
QTableWidgetItem,
|
|
30
|
+
QVBoxLayout,
|
|
31
|
+
QWidget,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Format-specific readers
|
|
35
|
+
from readlif.reader import (
|
|
36
|
+
LifFile, # https://github.com/Arcadia-Science/readlif
|
|
37
|
+
)
|
|
38
|
+
from tiffslide import TiffSlide # https://github.com/Bayer-Group/tiffslide
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SeriesTableWidget(QTableWidget):
|
|
42
|
+
"""
|
|
43
|
+
Custom table widget to display original files and their series
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, viewer: napari.Viewer):
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.viewer = viewer
|
|
49
|
+
|
|
50
|
+
# Configure table
|
|
51
|
+
self.setColumnCount(2)
|
|
52
|
+
self.setHorizontalHeaderLabels(["Original Files", "Series"])
|
|
53
|
+
self.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
|
54
|
+
|
|
55
|
+
# Track file mappings
|
|
56
|
+
self.file_data = (
|
|
57
|
+
{}
|
|
58
|
+
) # {filepath: {type: file_type, series: [list_of_series]}}
|
|
59
|
+
|
|
60
|
+
# Currently loaded images
|
|
61
|
+
self.current_file = None
|
|
62
|
+
self.current_series = None
|
|
63
|
+
|
|
64
|
+
# Connect selection signals
|
|
65
|
+
self.cellClicked.connect(self.handle_cell_click)
|
|
66
|
+
|
|
67
|
+
def add_file(self, filepath: str, file_type: str, series_count: int):
|
|
68
|
+
"""Add a file to the table with series information"""
|
|
69
|
+
row = self.rowCount()
|
|
70
|
+
self.insertRow(row)
|
|
71
|
+
|
|
72
|
+
# Original file item
|
|
73
|
+
original_item = QTableWidgetItem(os.path.basename(filepath))
|
|
74
|
+
original_item.setData(Qt.UserRole, filepath)
|
|
75
|
+
self.setItem(row, 0, original_item)
|
|
76
|
+
|
|
77
|
+
# Series info
|
|
78
|
+
series_info = (
|
|
79
|
+
f"{series_count} series"
|
|
80
|
+
if series_count >= 0
|
|
81
|
+
else "Not a series file"
|
|
82
|
+
)
|
|
83
|
+
series_item = QTableWidgetItem(series_info)
|
|
84
|
+
self.setItem(row, 1, series_item)
|
|
85
|
+
|
|
86
|
+
# Store file info
|
|
87
|
+
self.file_data[filepath] = {
|
|
88
|
+
"type": file_type,
|
|
89
|
+
"series_count": series_count,
|
|
90
|
+
"row": row,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def handle_cell_click(self, row: int, column: int):
|
|
94
|
+
"""Handle cell click to show series details or load image"""
|
|
95
|
+
if column == 0:
|
|
96
|
+
# Get filepath from the clicked cell
|
|
97
|
+
item = self.item(row, 0)
|
|
98
|
+
if item:
|
|
99
|
+
filepath = item.data(Qt.UserRole)
|
|
100
|
+
file_info = self.file_data.get(filepath)
|
|
101
|
+
|
|
102
|
+
if file_info and file_info["series_count"] > 0:
|
|
103
|
+
# Update the current file
|
|
104
|
+
self.current_file = filepath
|
|
105
|
+
|
|
106
|
+
# Signal to show series details
|
|
107
|
+
self.parent().show_series_details(filepath)
|
|
108
|
+
else:
|
|
109
|
+
# Not a series file, just load the image
|
|
110
|
+
self.parent().load_image(filepath)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SeriesDetailWidget(QWidget):
|
|
114
|
+
"""Widget to display and select series from a file"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, parent, viewer: napari.Viewer):
|
|
117
|
+
super().__init__()
|
|
118
|
+
self.parent = parent
|
|
119
|
+
self.viewer = viewer
|
|
120
|
+
self.current_file = None
|
|
121
|
+
self.max_series = 0
|
|
122
|
+
|
|
123
|
+
# Create layout
|
|
124
|
+
layout = QVBoxLayout()
|
|
125
|
+
self.setLayout(layout)
|
|
126
|
+
|
|
127
|
+
# Series selection widgets
|
|
128
|
+
self.series_label = QLabel("Select Series:")
|
|
129
|
+
layout.addWidget(self.series_label)
|
|
130
|
+
|
|
131
|
+
self.series_selector = QComboBox()
|
|
132
|
+
layout.addWidget(self.series_selector)
|
|
133
|
+
|
|
134
|
+
# Connect series selector
|
|
135
|
+
self.series_selector.currentIndexChanged.connect(self.series_selected)
|
|
136
|
+
|
|
137
|
+
# Add preview button
|
|
138
|
+
preview_button = QPushButton("Preview Selected Series")
|
|
139
|
+
preview_button.clicked.connect(self.preview_series)
|
|
140
|
+
layout.addWidget(preview_button)
|
|
141
|
+
|
|
142
|
+
# Add info label
|
|
143
|
+
self.info_label = QLabel("")
|
|
144
|
+
layout.addWidget(self.info_label)
|
|
145
|
+
|
|
146
|
+
def set_file(self, filepath: str):
|
|
147
|
+
"""Set the current file and update series list"""
|
|
148
|
+
self.current_file = filepath
|
|
149
|
+
self.series_selector.clear()
|
|
150
|
+
|
|
151
|
+
# Try to get series information
|
|
152
|
+
file_loader = self.parent.get_file_loader(filepath)
|
|
153
|
+
if file_loader:
|
|
154
|
+
try:
|
|
155
|
+
series_count = file_loader.get_series_count(filepath)
|
|
156
|
+
self.max_series = series_count
|
|
157
|
+
for i in range(series_count):
|
|
158
|
+
self.series_selector.addItem(f"Series {i}", i)
|
|
159
|
+
|
|
160
|
+
# Set info text
|
|
161
|
+
if series_count > 0:
|
|
162
|
+
metadata = file_loader.get_metadata(filepath, 0)
|
|
163
|
+
if metadata:
|
|
164
|
+
self.info_label.setText(
|
|
165
|
+
f"File contains {series_count} series."
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
self.info_label.setText(
|
|
169
|
+
f"File contains {series_count} series. No additional metadata available."
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
self.info_label.setText("No series found in this file.")
|
|
173
|
+
except FileNotFoundError:
|
|
174
|
+
self.info_label.setText("File not found.")
|
|
175
|
+
except PermissionError:
|
|
176
|
+
self.info_label.setText(
|
|
177
|
+
"Permission denied when accessing the file."
|
|
178
|
+
)
|
|
179
|
+
except ValueError as e:
|
|
180
|
+
self.info_label.setText(f"Invalid data in file: {str(e)}")
|
|
181
|
+
except OSError as e:
|
|
182
|
+
self.info_label.setText(f"I/O error occurred: {str(e)}")
|
|
183
|
+
|
|
184
|
+
def series_selected(self, index: int):
|
|
185
|
+
"""Handle series selection"""
|
|
186
|
+
if index >= 0 and self.current_file:
|
|
187
|
+
series_index = self.series_selector.itemData(index)
|
|
188
|
+
|
|
189
|
+
# Validate series index
|
|
190
|
+
if series_index >= self.max_series:
|
|
191
|
+
self.info_label.setText(
|
|
192
|
+
f"Error: Series index {series_index} out of range (max: {self.max_series-1})"
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
# Update parent with selected series
|
|
197
|
+
self.parent.set_selected_series(self.current_file, series_index)
|
|
198
|
+
|
|
199
|
+
def preview_series(self):
|
|
200
|
+
"""Preview the selected series in Napari"""
|
|
201
|
+
if self.current_file and self.series_selector.currentIndex() >= 0:
|
|
202
|
+
series_index = self.series_selector.itemData(
|
|
203
|
+
self.series_selector.currentIndex()
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Validate series index
|
|
207
|
+
if series_index >= self.max_series:
|
|
208
|
+
self.info_label.setText(
|
|
209
|
+
f"Error: Series index {series_index} out of range (max: {self.max_series-1})"
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
file_loader = self.parent.get_file_loader(self.current_file)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
# Load the series
|
|
217
|
+
image_data = file_loader.load_series(
|
|
218
|
+
self.current_file, series_index
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Clear existing layers and display the image
|
|
222
|
+
self.viewer.layers.clear()
|
|
223
|
+
self.viewer.add_image(
|
|
224
|
+
image_data,
|
|
225
|
+
name=f"{Path(self.current_file).stem} - Series {series_index}",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Update status
|
|
229
|
+
self.viewer.status = f"Previewing {Path(self.current_file).name} - Series {series_index}"
|
|
230
|
+
except (ValueError, FileNotFoundError) as e:
|
|
231
|
+
self.viewer.status = f"Error loading series: {str(e)}"
|
|
232
|
+
QMessageBox.warning(
|
|
233
|
+
self, "Error", f"Could not load series: {str(e)}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class FormatLoader:
|
|
238
|
+
"""Base class for format loaders"""
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def can_load(filepath: str) -> bool:
|
|
242
|
+
raise NotImplementedError()
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def get_series_count(filepath: str) -> int:
|
|
246
|
+
raise NotImplementedError()
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def load_series(filepath: str, series_index: int) -> np.ndarray:
|
|
250
|
+
raise NotImplementedError()
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def get_metadata(filepath: str, series_index: int) -> Dict:
|
|
254
|
+
return {}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class LIFLoader(FormatLoader):
|
|
258
|
+
"""Loader for Leica LIF files"""
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def can_load(filepath: str) -> bool:
|
|
262
|
+
return filepath.lower().endswith(".lif")
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def get_series_count(filepath: str) -> int:
|
|
266
|
+
try:
|
|
267
|
+
lif_file = LifFile(filepath)
|
|
268
|
+
# Directly use the iterator, no need to load all images into a list
|
|
269
|
+
return sum(1 for _ in lif_file.get_iter_image())
|
|
270
|
+
except (ValueError, FileNotFoundError):
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def load_series(filepath: str, series_index: int) -> np.ndarray:
|
|
275
|
+
lif_file = LifFile(filepath)
|
|
276
|
+
image = lif_file.get_image(series_index)
|
|
277
|
+
|
|
278
|
+
# Extract dimensions
|
|
279
|
+
channels = image.channels
|
|
280
|
+
z_stacks = image.nz
|
|
281
|
+
timepoints = image.nt
|
|
282
|
+
x_dim, y_dim = image.dims[0], image.dims[1]
|
|
283
|
+
|
|
284
|
+
# Create an array to hold the entire series
|
|
285
|
+
series_shape = (
|
|
286
|
+
timepoints,
|
|
287
|
+
z_stacks,
|
|
288
|
+
channels,
|
|
289
|
+
y_dim,
|
|
290
|
+
x_dim,
|
|
291
|
+
) # Corrected shape
|
|
292
|
+
series_data = np.zeros(series_shape, dtype=np.uint16)
|
|
293
|
+
|
|
294
|
+
# Populate the array
|
|
295
|
+
missing_frames = 0
|
|
296
|
+
for t in range(timepoints):
|
|
297
|
+
for z in range(z_stacks):
|
|
298
|
+
for c in range(channels):
|
|
299
|
+
# Get the frame and convert to numpy array
|
|
300
|
+
frame = image.get_frame(z=z, t=t, c=c)
|
|
301
|
+
if frame:
|
|
302
|
+
series_data[t, z, c, :, :] = np.array(frame)
|
|
303
|
+
else:
|
|
304
|
+
missing_frames += 1
|
|
305
|
+
series_data[t, z, c, :, :] = np.zeros(
|
|
306
|
+
(y_dim, x_dim), dtype=np.uint16
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if missing_frames > 0:
|
|
310
|
+
print(
|
|
311
|
+
f"Warning: {missing_frames} frames were missing and filled with zeros."
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Squeeze out singleton dimensions but preserve the order of remaining dimensions
|
|
315
|
+
# This can change dimension ordering if dimensions of size 1 are eliminated
|
|
316
|
+
# For example, if timepoints=1, the resulting array will have dimensions (z_stacks, channels, y_dim, x_dim)
|
|
317
|
+
series_data = np.squeeze(series_data)
|
|
318
|
+
|
|
319
|
+
return series_data
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def get_metadata(filepath: str, series_index: int) -> Dict:
|
|
323
|
+
try:
|
|
324
|
+
lif_file = LifFile(filepath)
|
|
325
|
+
image = lif_file.get_image(series_index)
|
|
326
|
+
|
|
327
|
+
metadata = {
|
|
328
|
+
"channels": image.channels,
|
|
329
|
+
"z_stacks": image.nz,
|
|
330
|
+
"timepoints": image.nt,
|
|
331
|
+
"dimensions": image.dims,
|
|
332
|
+
"name": image.name, # Access image name directly
|
|
333
|
+
"scale": image.scale, # Add scale
|
|
334
|
+
}
|
|
335
|
+
return metadata
|
|
336
|
+
except (ValueError, FileNotFoundError):
|
|
337
|
+
return {}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class ND2Loader(FormatLoader):
|
|
341
|
+
"""Loader for Nikon ND2 files"""
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def can_load(filepath: str) -> bool:
|
|
345
|
+
return filepath.lower().endswith(".nd2")
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def get_series_count(filepath: str) -> int:
|
|
349
|
+
|
|
350
|
+
# ND2 files typically have a single series with multiple channels/dimensions
|
|
351
|
+
return 1
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def load_series(filepath: str, series_index: int) -> np.ndarray:
|
|
355
|
+
if series_index != 0:
|
|
356
|
+
raise ValueError("ND2 files only support series index 0")
|
|
357
|
+
|
|
358
|
+
with nd2.ND2File(filepath) as nd2_file:
|
|
359
|
+
# Convert to numpy array
|
|
360
|
+
return nd2_file.asarray()
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def get_metadata(filepath: str, series_index: int) -> Dict:
|
|
364
|
+
if series_index != 0:
|
|
365
|
+
return {}
|
|
366
|
+
|
|
367
|
+
with nd2.ND2File(filepath) as nd2_file:
|
|
368
|
+
return {
|
|
369
|
+
# .sizes # {'T': 10, 'C': 2, 'Y': 256, 'X': 256}
|
|
370
|
+
"channels": nd2_file.sizes.get("C", 1),
|
|
371
|
+
"shape": nd2_file.shape,
|
|
372
|
+
"pixel_size": nd2_file.voxel_size,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class TIFFSlideLoader(FormatLoader):
|
|
377
|
+
"""Loader for whole slide TIFF images (NDPI, SVS, etc.)"""
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def can_load(filepath: str) -> bool:
|
|
381
|
+
ext = filepath.lower()
|
|
382
|
+
return ext.endswith((".ndpi", ".svs", ".tiff", ".tif"))
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def get_series_count(filepath: str) -> int:
|
|
386
|
+
try:
|
|
387
|
+
with TiffSlide(filepath) as slide:
|
|
388
|
+
# NDPI typically has a main image and several levels (pyramid)
|
|
389
|
+
return len(slide.level_dimensions)
|
|
390
|
+
except (ValueError, FileNotFoundError):
|
|
391
|
+
# Try standard tifffile if TiffSlide fails
|
|
392
|
+
try:
|
|
393
|
+
with tifffile.TiffFile(filepath) as tif:
|
|
394
|
+
return len(tif.series)
|
|
395
|
+
except (ValueError, FileNotFoundError):
|
|
396
|
+
return 0
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def load_series(filepath: str, series_index: int) -> np.ndarray:
|
|
400
|
+
try:
|
|
401
|
+
# First try TiffSlide for whole slide images
|
|
402
|
+
with TiffSlide(filepath) as slide:
|
|
403
|
+
if series_index < 0 or series_index >= len(
|
|
404
|
+
slide.level_dimensions
|
|
405
|
+
):
|
|
406
|
+
raise ValueError(
|
|
407
|
+
f"Series index {series_index} out of range"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Get dimensions for the level
|
|
411
|
+
width, height = slide.level_dimensions[series_index]
|
|
412
|
+
# Read the entire level
|
|
413
|
+
return np.array(
|
|
414
|
+
slide.read_region((0, 0), series_index, (width, height))
|
|
415
|
+
)
|
|
416
|
+
except (ValueError, FileNotFoundError):
|
|
417
|
+
# Fall back to tifffile
|
|
418
|
+
with tifffile.TiffFile(filepath) as tif:
|
|
419
|
+
if series_index < 0 or series_index >= len(tif.series):
|
|
420
|
+
raise ValueError(
|
|
421
|
+
f"Series index {series_index} out of range"
|
|
422
|
+
) from None
|
|
423
|
+
|
|
424
|
+
return tif.series[series_index].asarray()
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def get_metadata(filepath: str, series_index: int) -> Dict:
|
|
428
|
+
try:
|
|
429
|
+
with TiffSlide(filepath) as slide:
|
|
430
|
+
if series_index < 0 or series_index >= len(
|
|
431
|
+
slide.level_dimensions
|
|
432
|
+
):
|
|
433
|
+
return {}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
"dimensions": slide.level_dimensions[series_index],
|
|
437
|
+
"downsample": slide.level_downsamples[series_index],
|
|
438
|
+
"properties": dict(slide.properties),
|
|
439
|
+
}
|
|
440
|
+
except (ValueError, FileNotFoundError):
|
|
441
|
+
# Fall back to tifffile
|
|
442
|
+
with tifffile.TiffFile(filepath) as tif:
|
|
443
|
+
if series_index < 0 or series_index >= len(tif.series):
|
|
444
|
+
return {}
|
|
445
|
+
|
|
446
|
+
series = tif.series[series_index]
|
|
447
|
+
return {
|
|
448
|
+
"shape": series.shape,
|
|
449
|
+
"dtype": str(series.dtype),
|
|
450
|
+
"axes": series.axes,
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class CZILoader(FormatLoader):
|
|
455
|
+
"""Loader for Zeiss CZI files
|
|
456
|
+
https://github.com/ZEISS/pylibczirw
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
@staticmethod
|
|
460
|
+
def can_load(filepath: str) -> bool:
|
|
461
|
+
return filepath.lower().endswith(".czi")
|
|
462
|
+
|
|
463
|
+
@staticmethod
|
|
464
|
+
def get_series_count(filepath: str) -> int:
|
|
465
|
+
try:
|
|
466
|
+
with czi.open_czi(filepath) as czi_file:
|
|
467
|
+
scenes = czi_file.scenes_bounding_rectangle
|
|
468
|
+
return len(scenes)
|
|
469
|
+
except (ValueError, FileNotFoundError):
|
|
470
|
+
return 0
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def load_series(filepath: str, series_index: int) -> np.ndarray:
|
|
474
|
+
try:
|
|
475
|
+
with czi.open_czi(filepath) as czi_file:
|
|
476
|
+
scenes = czi_file.scenes_bounding_rectangle
|
|
477
|
+
|
|
478
|
+
if series_index < 0 or series_index >= len(scenes):
|
|
479
|
+
raise ValueError(
|
|
480
|
+
f"Scene index {series_index} out of range"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
scene_keys = list(scenes.keys())
|
|
484
|
+
scene_index = scene_keys[series_index]
|
|
485
|
+
|
|
486
|
+
# You might need to specify pixel_type if automatic detection fails
|
|
487
|
+
image = czi_file.read(scene=scene_index)
|
|
488
|
+
return image
|
|
489
|
+
except (ValueError, FileNotFoundError) as e:
|
|
490
|
+
print(f"Error loading series: {e}")
|
|
491
|
+
raise # Re-raise the exception after logging
|
|
492
|
+
|
|
493
|
+
@staticmethod
|
|
494
|
+
def get_scales(metadata_xml, dim):
|
|
495
|
+
pattern = re.compile(
|
|
496
|
+
r'<Distance[^>]*Id="'
|
|
497
|
+
+ re.escape(dim)
|
|
498
|
+
+ r'"[^>]*>.*?<Value[^>]*>(.*?)</Value>',
|
|
499
|
+
re.DOTALL,
|
|
500
|
+
)
|
|
501
|
+
match = pattern.search(metadata_xml)
|
|
502
|
+
|
|
503
|
+
if match:
|
|
504
|
+
scale = float(match.group(1))
|
|
505
|
+
# convert to microns
|
|
506
|
+
scale = scale * 1e6
|
|
507
|
+
return scale
|
|
508
|
+
else:
|
|
509
|
+
return None # Fixed: return a single None value instead of (None, None, None)
|
|
510
|
+
|
|
511
|
+
@staticmethod
|
|
512
|
+
def get_metadata(filepath: str, series_index: int) -> Dict:
|
|
513
|
+
try:
|
|
514
|
+
with czi.open_czi(filepath) as czi_file:
|
|
515
|
+
scenes = czi_file.scenes_bounding_rectangle
|
|
516
|
+
|
|
517
|
+
if series_index < 0 or series_index >= len(scenes):
|
|
518
|
+
return {}
|
|
519
|
+
|
|
520
|
+
scene_keys = list(scenes.keys())
|
|
521
|
+
scene_index = scene_keys[series_index]
|
|
522
|
+
scene = scenes[scene_index]
|
|
523
|
+
|
|
524
|
+
dims = czi_file.total_bounding_box
|
|
525
|
+
|
|
526
|
+
# Extract the raw metadata as an XML string
|
|
527
|
+
metadata_xml = czi_file.raw_metadata
|
|
528
|
+
|
|
529
|
+
metadata = {
|
|
530
|
+
"scene_index": scene_index,
|
|
531
|
+
"scene_rect": (scene[0], scene[1], scene[2], scene[3]),
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
# Add scale information
|
|
535
|
+
try:
|
|
536
|
+
scale_x = CZILoader.get_scales(metadata_xml, "X")
|
|
537
|
+
scale_y = CZILoader.get_scales(metadata_xml, "Y")
|
|
538
|
+
|
|
539
|
+
metadata.update(
|
|
540
|
+
{
|
|
541
|
+
"scale_x": scale_x,
|
|
542
|
+
"scale_y": scale_y,
|
|
543
|
+
"scale_unit": "microns",
|
|
544
|
+
}
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
if dims["Z"] != (0, 1):
|
|
548
|
+
scale_z = CZILoader.get_scales(metadata_xml, "Z")
|
|
549
|
+
metadata["scale_z"] = scale_z
|
|
550
|
+
except ValueError as e:
|
|
551
|
+
print(f"Error getting scale metadata: {e}")
|
|
552
|
+
|
|
553
|
+
# metadata = {
|
|
554
|
+
# "scene_index": scene_index,
|
|
555
|
+
# "scene_rect": (scene[0], scene[1], scene[2], scene[3]),
|
|
556
|
+
# }
|
|
557
|
+
|
|
558
|
+
# try:
|
|
559
|
+
|
|
560
|
+
# metadata.update(
|
|
561
|
+
# {
|
|
562
|
+
# "dimensions": czi_file.total_bounding_box,
|
|
563
|
+
# # "axes": czi_file.axes,
|
|
564
|
+
# # "shape": czi_file.shape,
|
|
565
|
+
# #"size": czi_file.size,
|
|
566
|
+
# "pixel_types": czi_file.pixel_types,
|
|
567
|
+
# }
|
|
568
|
+
# )
|
|
569
|
+
# except (ValueError, FileNotFoundError) as e:
|
|
570
|
+
# print(f"Error getting full metadata: {e}")
|
|
571
|
+
|
|
572
|
+
# try:
|
|
573
|
+
# metadata["channel_count"] = czi_file.get_dims_channels()[0]
|
|
574
|
+
# metadata["channel_names"] = czi_file.channel_names
|
|
575
|
+
# except (ValueError, FileNotFoundError) as e:
|
|
576
|
+
# print(f"Error getting channel metadata: {e}")
|
|
577
|
+
# try:
|
|
578
|
+
# metadata.update(
|
|
579
|
+
# {
|
|
580
|
+
# "scale_x": czi_file.scale_x,
|
|
581
|
+
# "scale_y": czi_file.scale_y,
|
|
582
|
+
# "scale_z": czi_file.scale_z,
|
|
583
|
+
# "scale_unit": czi_file.scale_unit,
|
|
584
|
+
# }
|
|
585
|
+
# )
|
|
586
|
+
# except (ValueError, FileNotFoundError) as e:
|
|
587
|
+
# print(f"Error getting scale metadata: {e}")
|
|
588
|
+
# try:
|
|
589
|
+
# xml_metadata = czi_file.meta
|
|
590
|
+
# if xml_metadata:
|
|
591
|
+
# metadata["xml_metadata"] = xml_metadata
|
|
592
|
+
# except (ValueError, FileNotFoundError) as e:
|
|
593
|
+
# print(f"Error getting XML metadata: {e}")
|
|
594
|
+
|
|
595
|
+
return metadata
|
|
596
|
+
|
|
597
|
+
except (ValueError, FileNotFoundError, RuntimeError) as e:
|
|
598
|
+
print(f"Error getting metadata: {e}")
|
|
599
|
+
return {}
|
|
600
|
+
|
|
601
|
+
@staticmethod
|
|
602
|
+
def get_physical_pixel_size(
|
|
603
|
+
filepath: str, series_index: int
|
|
604
|
+
) -> Dict[str, float]:
|
|
605
|
+
try:
|
|
606
|
+
with czi.open_czi(filepath) as czi_file:
|
|
607
|
+
scenes = czi_file.scenes_bounding_rectangle
|
|
608
|
+
|
|
609
|
+
if series_index < 0 or series_index >= len(scenes):
|
|
610
|
+
raise ValueError(
|
|
611
|
+
f"Scene index {series_index} out of range"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
# scene_keys = list(scenes.keys())
|
|
615
|
+
# scene_index = scene_keys[series_index]
|
|
616
|
+
|
|
617
|
+
# Get scale information
|
|
618
|
+
scale_x = czi_file.scale_x
|
|
619
|
+
scale_y = czi_file.scale_y
|
|
620
|
+
scale_z = czi_file.scale_z
|
|
621
|
+
|
|
622
|
+
return {"X": scale_x, "Y": scale_y, "Z": scale_z}
|
|
623
|
+
except (ValueError, FileNotFoundError) as e:
|
|
624
|
+
print(f"Error getting pixel size: {str(e)}")
|
|
625
|
+
return {}
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
class ScanFolderWorker(QThread):
|
|
629
|
+
"""Worker thread for scanning folders"""
|
|
630
|
+
|
|
631
|
+
progress = Signal(int, int) # current, total
|
|
632
|
+
finished = Signal(list) # list of found files
|
|
633
|
+
error = Signal(str) # error message
|
|
634
|
+
|
|
635
|
+
def __init__(self, folder: str, filters: List[str]):
|
|
636
|
+
super().__init__()
|
|
637
|
+
self.folder = folder
|
|
638
|
+
self.filters = filters
|
|
639
|
+
|
|
640
|
+
def run(self):
|
|
641
|
+
try:
|
|
642
|
+
found_files = []
|
|
643
|
+
file_count = 0
|
|
644
|
+
|
|
645
|
+
# First count total files to scan for progress reporting
|
|
646
|
+
for _root, _, files in os.walk(self.folder):
|
|
647
|
+
file_count += len(files)
|
|
648
|
+
|
|
649
|
+
# Now scan for matching files
|
|
650
|
+
scanned = 0
|
|
651
|
+
for root, _, files in os.walk(self.folder):
|
|
652
|
+
for file in files:
|
|
653
|
+
scanned += 1
|
|
654
|
+
if scanned % 10 == 0: # Update progress every 10 files
|
|
655
|
+
self.progress.emit(scanned, file_count)
|
|
656
|
+
|
|
657
|
+
if any(file.lower().endswith(f) for f in self.filters):
|
|
658
|
+
found_files.append(os.path.join(root, file))
|
|
659
|
+
|
|
660
|
+
self.finished.emit(found_files)
|
|
661
|
+
except (ValueError, FileNotFoundError) as e:
|
|
662
|
+
self.error.emit(str(e))
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class ConversionWorker(QThread):
|
|
666
|
+
"""Worker thread for file conversion"""
|
|
667
|
+
|
|
668
|
+
progress = Signal(int, int, str) # current, total, filename
|
|
669
|
+
file_done = Signal(str, bool, str) # filepath, success, error message
|
|
670
|
+
finished = Signal(int) # number of successfully converted files
|
|
671
|
+
|
|
672
|
+
def __init__(
|
|
673
|
+
self,
|
|
674
|
+
files_to_convert: List[Tuple[str, int]],
|
|
675
|
+
output_folder: str,
|
|
676
|
+
use_zarr: bool,
|
|
677
|
+
file_loader_func,
|
|
678
|
+
):
|
|
679
|
+
super().__init__()
|
|
680
|
+
self.files_to_convert = files_to_convert
|
|
681
|
+
self.output_folder = output_folder
|
|
682
|
+
self.use_zarr = use_zarr
|
|
683
|
+
self.get_file_loader = file_loader_func
|
|
684
|
+
self.running = True
|
|
685
|
+
|
|
686
|
+
def run(self):
|
|
687
|
+
success_count = 0
|
|
688
|
+
for i, (filepath, series_index) in enumerate(self.files_to_convert):
|
|
689
|
+
if not self.running:
|
|
690
|
+
break
|
|
691
|
+
|
|
692
|
+
# Update progress
|
|
693
|
+
self.progress.emit(
|
|
694
|
+
i + 1, len(self.files_to_convert), Path(filepath).name
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
try:
|
|
698
|
+
# Get loader
|
|
699
|
+
loader = self.get_file_loader(filepath)
|
|
700
|
+
if not loader:
|
|
701
|
+
self.file_done.emit(
|
|
702
|
+
filepath, False, "Unsupported file format"
|
|
703
|
+
)
|
|
704
|
+
continue
|
|
705
|
+
|
|
706
|
+
# Load series - this is the critical part that must succeed
|
|
707
|
+
try:
|
|
708
|
+
image_data = loader.load_series(filepath, series_index)
|
|
709
|
+
except (ValueError, FileNotFoundError) as e:
|
|
710
|
+
self.file_done.emit(
|
|
711
|
+
filepath, False, f"Failed to load image: {str(e)}"
|
|
712
|
+
)
|
|
713
|
+
continue
|
|
714
|
+
|
|
715
|
+
# Try to extract metadata - but don't fail if this doesn't work
|
|
716
|
+
metadata = None
|
|
717
|
+
try:
|
|
718
|
+
metadata = (
|
|
719
|
+
loader.get_metadata(filepath, series_index) or {}
|
|
720
|
+
)
|
|
721
|
+
print(f"Extracted metadata keys: {list(metadata.keys())}")
|
|
722
|
+
except (ValueError, FileNotFoundError) as e:
|
|
723
|
+
print(f"Warning: Failed to extract metadata: {str(e)}")
|
|
724
|
+
metadata = {}
|
|
725
|
+
|
|
726
|
+
# Generate output filename
|
|
727
|
+
base_name = Path(filepath).stem
|
|
728
|
+
|
|
729
|
+
# Determine format based on size and settings
|
|
730
|
+
estimated_size_bytes = (
|
|
731
|
+
np.prod(image_data.shape) * image_data.itemsize
|
|
732
|
+
)
|
|
733
|
+
use_zarr = self.use_zarr or (
|
|
734
|
+
estimated_size_bytes > 4 * 1024 * 1024 * 1024
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
# Set up the output path
|
|
738
|
+
if use_zarr:
|
|
739
|
+
output_path = os.path.join(
|
|
740
|
+
self.output_folder,
|
|
741
|
+
f"{base_name}_series{series_index}.zarr",
|
|
742
|
+
)
|
|
743
|
+
else:
|
|
744
|
+
output_path = os.path.join(
|
|
745
|
+
self.output_folder,
|
|
746
|
+
f"{base_name}_series{series_index}.tif",
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
# The crucial part - save the file with separate try/except for each save method
|
|
750
|
+
save_success = False
|
|
751
|
+
error_message = ""
|
|
752
|
+
|
|
753
|
+
try:
|
|
754
|
+
if use_zarr:
|
|
755
|
+
# First try with metadata
|
|
756
|
+
try:
|
|
757
|
+
if metadata:
|
|
758
|
+
self._save_zarr(
|
|
759
|
+
image_data, output_path, metadata
|
|
760
|
+
)
|
|
761
|
+
else:
|
|
762
|
+
self._save_zarr(image_data, output_path)
|
|
763
|
+
except (ValueError, FileNotFoundError) as e:
|
|
764
|
+
print(
|
|
765
|
+
f"Warning: Failed to save with metadata, trying without: {str(e)}"
|
|
766
|
+
)
|
|
767
|
+
# If that fails, try without metadata
|
|
768
|
+
self._save_zarr(image_data, output_path, None)
|
|
769
|
+
else:
|
|
770
|
+
# First try with metadata
|
|
771
|
+
try:
|
|
772
|
+
if metadata:
|
|
773
|
+
self._save_tif(
|
|
774
|
+
image_data, output_path, metadata
|
|
775
|
+
)
|
|
776
|
+
else:
|
|
777
|
+
self._save_tif(image_data, output_path)
|
|
778
|
+
except (ValueError, FileNotFoundError) as e:
|
|
779
|
+
print(
|
|
780
|
+
f"Warning: Failed to save with metadata, trying without: {str(e)}"
|
|
781
|
+
)
|
|
782
|
+
# If that fails, try without metadata
|
|
783
|
+
self._save_tif(image_data, output_path, None)
|
|
784
|
+
|
|
785
|
+
save_success = True
|
|
786
|
+
except (ValueError, FileNotFoundError) as e:
|
|
787
|
+
error_message = f"Failed to save file: {str(e)}"
|
|
788
|
+
print(f"Error in save operation: {error_message}")
|
|
789
|
+
save_success = False
|
|
790
|
+
|
|
791
|
+
if save_success:
|
|
792
|
+
success_count += 1
|
|
793
|
+
self.file_done.emit(
|
|
794
|
+
filepath, True, f"Saved to {output_path}"
|
|
795
|
+
)
|
|
796
|
+
else:
|
|
797
|
+
self.file_done.emit(filepath, False, error_message)
|
|
798
|
+
|
|
799
|
+
except (ValueError, FileNotFoundError) as e:
|
|
800
|
+
print(f"Unexpected error during conversion: {str(e)}")
|
|
801
|
+
self.file_done.emit(
|
|
802
|
+
filepath, False, f"Unexpected error: {str(e)}"
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
self.finished.emit(success_count)
|
|
806
|
+
|
|
807
|
+
def stop(self):
|
|
808
|
+
self.running = False
|
|
809
|
+
|
|
810
|
+
def _save_tif(
|
|
811
|
+
self, image_data: np.ndarray, output_path: str, metadata: dict = None
|
|
812
|
+
):
|
|
813
|
+
"""Save image data as TIFF with optional metadata"""
|
|
814
|
+
try:
|
|
815
|
+
# For basic save without metadata
|
|
816
|
+
if metadata is None:
|
|
817
|
+
tifffile.imwrite(output_path, image_data, compression="zstd")
|
|
818
|
+
print(f"Saved TIFF file without metadata: {output_path}")
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
# Convert metadata to TIFF-compatible format
|
|
822
|
+
tiff_metadata = {}
|
|
823
|
+
resolution = None
|
|
824
|
+
resolution_unit = None
|
|
825
|
+
|
|
826
|
+
try:
|
|
827
|
+
# Extract resolution information for TIFF tags
|
|
828
|
+
scale_x = metadata.get("scale_x")
|
|
829
|
+
scale_y = metadata.get("scale_y")
|
|
830
|
+
# scale_unit = metadata.get("scale_unit")
|
|
831
|
+
|
|
832
|
+
if all([scale_x, scale_y, scale_x > 0, scale_y > 0]):
|
|
833
|
+
# For TIFF, resolution is specified as pixels per resolution unit
|
|
834
|
+
# So we need to invert the scale (which is microns/pixel)
|
|
835
|
+
# Convert from microns/pixel to pixels/cm
|
|
836
|
+
x_res = 10000 / scale_x # 10000 microns = 1 cm
|
|
837
|
+
y_res = 10000 / scale_y
|
|
838
|
+
resolution = (x_res, y_res)
|
|
839
|
+
resolution_unit = "CENTIMETER"
|
|
840
|
+
|
|
841
|
+
# Include all other metadata
|
|
842
|
+
for key, value in metadata.items():
|
|
843
|
+
if (
|
|
844
|
+
isinstance(value, (str, int, float, bool))
|
|
845
|
+
or isinstance(value, (list, tuple))
|
|
846
|
+
and all(
|
|
847
|
+
isinstance(x, (str, int, float, bool))
|
|
848
|
+
for x in value
|
|
849
|
+
)
|
|
850
|
+
):
|
|
851
|
+
tiff_metadata[key] = value
|
|
852
|
+
elif isinstance(value, dict):
|
|
853
|
+
# For dictionaries, convert to a simple JSON string
|
|
854
|
+
try:
|
|
855
|
+
import json
|
|
856
|
+
|
|
857
|
+
tiff_metadata[key] = json.dumps(value)
|
|
858
|
+
except (ValueError, TypeError):
|
|
859
|
+
pass
|
|
860
|
+
except (ValueError, FileNotFoundError) as e:
|
|
861
|
+
print(f"Warning: Error processing metadata for TIFF: {str(e)}")
|
|
862
|
+
|
|
863
|
+
# Save with metadata, resolution, and compression
|
|
864
|
+
save_args = {"compression": "zstd", "metadata": tiff_metadata}
|
|
865
|
+
|
|
866
|
+
# Add resolution parameters if available
|
|
867
|
+
if resolution is not None:
|
|
868
|
+
save_args["resolution"] = resolution
|
|
869
|
+
|
|
870
|
+
if resolution_unit is not None:
|
|
871
|
+
save_args["resolutionunit"] = resolution_unit
|
|
872
|
+
|
|
873
|
+
# Add ImageJ-specific metadata for better compatibility
|
|
874
|
+
if scale_x is not None and scale_y is not None:
|
|
875
|
+
imagej_metadata = {}
|
|
876
|
+
save_args["imagej"] = True
|
|
877
|
+
|
|
878
|
+
# Add pixel spacing for ImageJ
|
|
879
|
+
if "scale_z" in metadata and metadata["scale_z"] is not None:
|
|
880
|
+
imagej_metadata["spacing"] = metadata["scale_z"]
|
|
881
|
+
|
|
882
|
+
tiff_metadata["unit"] = "um" # Specify microns as the unit
|
|
883
|
+
|
|
884
|
+
tifffile.imwrite(output_path, image_data, **save_args)
|
|
885
|
+
print(f"Saved TIFF file with metadata: {output_path}")
|
|
886
|
+
except (ValueError, FileNotFoundError) as e:
|
|
887
|
+
print(f"Error in _save_tif: {str(e)}")
|
|
888
|
+
# Try a last resort, basic save without any options
|
|
889
|
+
tifffile.imwrite(output_path, image_data)
|
|
890
|
+
print(f"Saved TIFF file with fallback method: {output_path}")
|
|
891
|
+
|
|
892
|
+
def _save_zarr(
|
|
893
|
+
self, image_data: np.ndarray, output_path: str, metadata: dict = None
|
|
894
|
+
):
|
|
895
|
+
"""Save image data as OME-Zarr format with optional metadata."""
|
|
896
|
+
try:
|
|
897
|
+
# Determine optimal chunk size
|
|
898
|
+
target_chunk_size = 1024 * 1024 # 1MB
|
|
899
|
+
item_size = image_data.itemsize
|
|
900
|
+
chunks = self._calculate_chunks(
|
|
901
|
+
image_data.shape, target_chunk_size, item_size
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
# Determine appropriate axes based on dimensions
|
|
905
|
+
ndim = len(image_data.shape)
|
|
906
|
+
default_axes = "TCZYX"
|
|
907
|
+
axes = (
|
|
908
|
+
default_axes[-ndim:]
|
|
909
|
+
if ndim <= 5
|
|
910
|
+
else "".join([f"D{i}" for i in range(ndim)])
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
# Create the zarr store
|
|
914
|
+
store = zarr.DirectoryStore(output_path)
|
|
915
|
+
|
|
916
|
+
# Set up transformations if possible
|
|
917
|
+
coordinate_transformations = None
|
|
918
|
+
if metadata:
|
|
919
|
+
try:
|
|
920
|
+
# Extract scale information if present
|
|
921
|
+
scales = []
|
|
922
|
+
for _i, ax in enumerate(axes):
|
|
923
|
+
scale = 1.0 # Default scale
|
|
924
|
+
|
|
925
|
+
# Try to find scale for this axis
|
|
926
|
+
scale_key = f"scale_{ax.lower()}"
|
|
927
|
+
if scale_key in metadata:
|
|
928
|
+
try:
|
|
929
|
+
scale_value = float(metadata[scale_key])
|
|
930
|
+
if scale_value > 0: # Only use valid values
|
|
931
|
+
scale = scale_value
|
|
932
|
+
except (ValueError, TypeError):
|
|
933
|
+
pass
|
|
934
|
+
|
|
935
|
+
scales.append(scale)
|
|
936
|
+
|
|
937
|
+
# Only create transformations if we have non-default scales
|
|
938
|
+
if any(s != 1.0 for s in scales):
|
|
939
|
+
coordinate_transformations = [
|
|
940
|
+
{"type": "scale", "scale": scales}
|
|
941
|
+
]
|
|
942
|
+
except (ValueError, FileNotFoundError) as e:
|
|
943
|
+
print(
|
|
944
|
+
f"Warning: Could not process coordinate transformations: {str(e)}"
|
|
945
|
+
)
|
|
946
|
+
coordinate_transformations = None
|
|
947
|
+
|
|
948
|
+
# Write the image data using the OME-Zarr writer
|
|
949
|
+
write_options = {
|
|
950
|
+
"image": image_data,
|
|
951
|
+
"group": store,
|
|
952
|
+
"axes": axes,
|
|
953
|
+
"chunks": chunks,
|
|
954
|
+
"compression": "zstd",
|
|
955
|
+
"compression_opts": {"level": 3},
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
# Add transformations if available
|
|
959
|
+
if coordinate_transformations:
|
|
960
|
+
write_options["coordinate_transformations"] = (
|
|
961
|
+
coordinate_transformations
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
write_image(**write_options)
|
|
965
|
+
print(f"Saved OME-Zarr image data: {output_path}")
|
|
966
|
+
|
|
967
|
+
# Try to add metadata
|
|
968
|
+
if metadata:
|
|
969
|
+
try:
|
|
970
|
+
# Access the root group
|
|
971
|
+
root = zarr.group(store)
|
|
972
|
+
|
|
973
|
+
# Add OMERO metadata
|
|
974
|
+
if "omero" not in root:
|
|
975
|
+
root.create_group("omero")
|
|
976
|
+
omero_metadata = root["omero"]
|
|
977
|
+
omero_metadata.attrs["version"] = "0.4"
|
|
978
|
+
|
|
979
|
+
# Add original metadata in a separate group
|
|
980
|
+
if "original_metadata" not in root:
|
|
981
|
+
metadata_group = root.create_group("original_metadata")
|
|
982
|
+
else:
|
|
983
|
+
metadata_group = root["original_metadata"]
|
|
984
|
+
|
|
985
|
+
# Add metadata as attributes, safely converting types
|
|
986
|
+
for key, value in metadata.items():
|
|
987
|
+
try:
|
|
988
|
+
# Try to store directly if it's a simple type
|
|
989
|
+
if isinstance(
|
|
990
|
+
value, (str, int, float, bool, type(None))
|
|
991
|
+
):
|
|
992
|
+
metadata_group.attrs[key] = value
|
|
993
|
+
else:
|
|
994
|
+
# Otherwise convert to string
|
|
995
|
+
metadata_group.attrs[key] = str(value)
|
|
996
|
+
except (ValueError, TypeError) as e:
|
|
997
|
+
print(
|
|
998
|
+
f"Warning: Could not store metadata key '{key}': {str(e)}"
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
print(f"Added metadata to OME-Zarr file: {output_path}")
|
|
1002
|
+
except (ValueError, FileNotFoundError) as e:
|
|
1003
|
+
print(f"Warning: Could not add metadata to Zarr: {str(e)}")
|
|
1004
|
+
|
|
1005
|
+
return output_path
|
|
1006
|
+
except Exception as e:
|
|
1007
|
+
print(f"Error in _save_zarr: {str(e)}")
|
|
1008
|
+
# For zarr, we don't have a simpler fallback method, so re-raise
|
|
1009
|
+
raise
|
|
1010
|
+
|
|
1011
|
+
def _calculate_chunks(self, shape, target_size, item_size):
|
|
1012
|
+
"""Calculate appropriate chunk sizes for zarr storage"""
|
|
1013
|
+
try:
|
|
1014
|
+
total_elements = np.prod(shape)
|
|
1015
|
+
elements_per_chunk = target_size // item_size
|
|
1016
|
+
|
|
1017
|
+
chunk_shape = list(shape)
|
|
1018
|
+
for i in reversed(range(len(chunk_shape))):
|
|
1019
|
+
# Guard against division by zero
|
|
1020
|
+
if total_elements > 0 and chunk_shape[i] > 0:
|
|
1021
|
+
chunk_shape[i] = max(
|
|
1022
|
+
1,
|
|
1023
|
+
int(
|
|
1024
|
+
elements_per_chunk
|
|
1025
|
+
/ (total_elements / chunk_shape[i])
|
|
1026
|
+
),
|
|
1027
|
+
)
|
|
1028
|
+
break
|
|
1029
|
+
|
|
1030
|
+
# Ensure chunks aren't larger than dimensions
|
|
1031
|
+
for i in range(len(chunk_shape)):
|
|
1032
|
+
chunk_shape[i] = min(chunk_shape[i], shape[i])
|
|
1033
|
+
|
|
1034
|
+
return tuple(chunk_shape)
|
|
1035
|
+
except (ValueError, FileNotFoundError) as e:
|
|
1036
|
+
print(f"Warning: Error calculating chunks: {str(e)}")
|
|
1037
|
+
# Return a default chunk size that's safe
|
|
1038
|
+
return tuple(min(512, d) for d in shape)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
class MicroscopyImageConverterWidget(QWidget):
|
|
1042
|
+
"""Main widget for microscopy image conversion to TIF/ZARR"""
|
|
1043
|
+
|
|
1044
|
+
def __init__(self, viewer: napari.Viewer):
|
|
1045
|
+
super().__init__()
|
|
1046
|
+
self.viewer = viewer
|
|
1047
|
+
|
|
1048
|
+
# Register format loaders
|
|
1049
|
+
self.loaders = [LIFLoader, ND2Loader, TIFFSlideLoader, CZILoader]
|
|
1050
|
+
|
|
1051
|
+
# Selected series for conversion
|
|
1052
|
+
self.selected_series = {} # {filepath: series_index}
|
|
1053
|
+
|
|
1054
|
+
# Working threads
|
|
1055
|
+
self.scan_worker = None
|
|
1056
|
+
self.conversion_worker = None
|
|
1057
|
+
|
|
1058
|
+
# Create layout
|
|
1059
|
+
main_layout = QVBoxLayout()
|
|
1060
|
+
self.setLayout(main_layout)
|
|
1061
|
+
|
|
1062
|
+
# File selection widgets
|
|
1063
|
+
folder_layout = QHBoxLayout()
|
|
1064
|
+
folder_label = QLabel("Input Folder:")
|
|
1065
|
+
self.folder_edit = QLineEdit()
|
|
1066
|
+
browse_button = QPushButton("Browse...")
|
|
1067
|
+
browse_button.clicked.connect(self.browse_folder)
|
|
1068
|
+
|
|
1069
|
+
folder_layout.addWidget(folder_label)
|
|
1070
|
+
folder_layout.addWidget(self.folder_edit)
|
|
1071
|
+
folder_layout.addWidget(browse_button)
|
|
1072
|
+
main_layout.addLayout(folder_layout)
|
|
1073
|
+
|
|
1074
|
+
# File filter widgets
|
|
1075
|
+
filter_layout = QHBoxLayout()
|
|
1076
|
+
filter_label = QLabel("File Filter:")
|
|
1077
|
+
self.filter_edit = QLineEdit()
|
|
1078
|
+
self.filter_edit.setPlaceholderText(
|
|
1079
|
+
".lif, .nd2, .ndpi, .czi (comma separated)"
|
|
1080
|
+
)
|
|
1081
|
+
self.filter_edit.setText(".lif,.nd2,.ndpi,.czi")
|
|
1082
|
+
scan_button = QPushButton("Scan Folder")
|
|
1083
|
+
scan_button.clicked.connect(self.scan_folder)
|
|
1084
|
+
|
|
1085
|
+
filter_layout.addWidget(filter_label)
|
|
1086
|
+
filter_layout.addWidget(self.filter_edit)
|
|
1087
|
+
filter_layout.addWidget(scan_button)
|
|
1088
|
+
main_layout.addLayout(filter_layout)
|
|
1089
|
+
|
|
1090
|
+
# Progress bar for scanning
|
|
1091
|
+
self.scan_progress = QProgressBar()
|
|
1092
|
+
self.scan_progress.setVisible(False)
|
|
1093
|
+
main_layout.addWidget(self.scan_progress)
|
|
1094
|
+
|
|
1095
|
+
# Files and series tables
|
|
1096
|
+
tables_layout = QHBoxLayout()
|
|
1097
|
+
|
|
1098
|
+
# Files table
|
|
1099
|
+
self.files_table = SeriesTableWidget(viewer)
|
|
1100
|
+
tables_layout.addWidget(self.files_table)
|
|
1101
|
+
|
|
1102
|
+
# Series details widget
|
|
1103
|
+
self.series_widget = SeriesDetailWidget(self, viewer)
|
|
1104
|
+
tables_layout.addWidget(self.series_widget)
|
|
1105
|
+
|
|
1106
|
+
main_layout.addLayout(tables_layout)
|
|
1107
|
+
|
|
1108
|
+
# Conversion options
|
|
1109
|
+
options_layout = QVBoxLayout()
|
|
1110
|
+
|
|
1111
|
+
# Output format selection
|
|
1112
|
+
format_layout = QHBoxLayout()
|
|
1113
|
+
format_label = QLabel("Output Format:")
|
|
1114
|
+
self.tif_radio = QCheckBox("TIF (< 4GB)")
|
|
1115
|
+
self.tif_radio.setChecked(True)
|
|
1116
|
+
self.zarr_radio = QCheckBox("ZARR (> 4GB)")
|
|
1117
|
+
|
|
1118
|
+
# Make checkboxes mutually exclusive like radio buttons
|
|
1119
|
+
self.tif_radio.toggled.connect(
|
|
1120
|
+
lambda checked: (
|
|
1121
|
+
self.zarr_radio.setChecked(not checked) if checked else None
|
|
1122
|
+
)
|
|
1123
|
+
)
|
|
1124
|
+
self.zarr_radio.toggled.connect(
|
|
1125
|
+
lambda checked: (
|
|
1126
|
+
self.tif_radio.setChecked(not checked) if checked else None
|
|
1127
|
+
)
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
format_layout.addWidget(format_label)
|
|
1131
|
+
format_layout.addWidget(self.tif_radio)
|
|
1132
|
+
format_layout.addWidget(self.zarr_radio)
|
|
1133
|
+
options_layout.addLayout(format_layout)
|
|
1134
|
+
|
|
1135
|
+
# Output folder selection
|
|
1136
|
+
output_layout = QHBoxLayout()
|
|
1137
|
+
output_label = QLabel("Output Folder:")
|
|
1138
|
+
self.output_edit = QLineEdit()
|
|
1139
|
+
output_browse = QPushButton("Browse...")
|
|
1140
|
+
output_browse.clicked.connect(self.browse_output)
|
|
1141
|
+
|
|
1142
|
+
output_layout.addWidget(output_label)
|
|
1143
|
+
output_layout.addWidget(self.output_edit)
|
|
1144
|
+
output_layout.addWidget(output_browse)
|
|
1145
|
+
options_layout.addLayout(output_layout)
|
|
1146
|
+
|
|
1147
|
+
main_layout.addLayout(options_layout)
|
|
1148
|
+
|
|
1149
|
+
# Conversion progress bar
|
|
1150
|
+
self.conversion_progress = QProgressBar()
|
|
1151
|
+
self.conversion_progress.setVisible(False)
|
|
1152
|
+
main_layout.addWidget(self.conversion_progress)
|
|
1153
|
+
|
|
1154
|
+
# Conversion and cancel buttons
|
|
1155
|
+
button_layout = QHBoxLayout()
|
|
1156
|
+
convert_button = QPushButton("Convert Selected Files")
|
|
1157
|
+
convert_button.clicked.connect(self.convert_files)
|
|
1158
|
+
self.cancel_button = QPushButton("Cancel")
|
|
1159
|
+
self.cancel_button.clicked.connect(self.cancel_operation)
|
|
1160
|
+
self.cancel_button.setVisible(False)
|
|
1161
|
+
|
|
1162
|
+
button_layout.addWidget(convert_button)
|
|
1163
|
+
button_layout.addWidget(self.cancel_button)
|
|
1164
|
+
main_layout.addLayout(button_layout)
|
|
1165
|
+
|
|
1166
|
+
# Status label
|
|
1167
|
+
self.status_label = QLabel("")
|
|
1168
|
+
main_layout.addWidget(self.status_label)
|
|
1169
|
+
|
|
1170
|
+
def cancel_operation(self):
|
|
1171
|
+
"""Cancel current operation"""
|
|
1172
|
+
if self.scan_worker and self.scan_worker.isRunning():
|
|
1173
|
+
self.scan_worker.terminate()
|
|
1174
|
+
self.scan_worker = None
|
|
1175
|
+
self.status_label.setText("Scanning cancelled")
|
|
1176
|
+
|
|
1177
|
+
if self.conversion_worker and self.conversion_worker.isRunning():
|
|
1178
|
+
self.conversion_worker.stop()
|
|
1179
|
+
self.status_label.setText("Conversion cancelled")
|
|
1180
|
+
|
|
1181
|
+
self.scan_progress.setVisible(False)
|
|
1182
|
+
self.conversion_progress.setVisible(False)
|
|
1183
|
+
self.cancel_button.setVisible(False)
|
|
1184
|
+
|
|
1185
|
+
def browse_folder(self):
|
|
1186
|
+
"""Open a folder browser dialog"""
|
|
1187
|
+
folder = QFileDialog.getExistingDirectory(self, "Select Input Folder")
|
|
1188
|
+
if folder:
|
|
1189
|
+
self.folder_edit.setText(folder)
|
|
1190
|
+
|
|
1191
|
+
def browse_output(self):
|
|
1192
|
+
"""Open a folder browser dialog for output folder"""
|
|
1193
|
+
folder = QFileDialog.getExistingDirectory(self, "Select Output Folder")
|
|
1194
|
+
if folder:
|
|
1195
|
+
self.output_edit.setText(folder)
|
|
1196
|
+
|
|
1197
|
+
def scan_folder(self):
|
|
1198
|
+
"""Scan the selected folder for image files"""
|
|
1199
|
+
folder = self.folder_edit.text()
|
|
1200
|
+
if not folder or not os.path.isdir(folder):
|
|
1201
|
+
self.status_label.setText("Please select a valid folder")
|
|
1202
|
+
return
|
|
1203
|
+
|
|
1204
|
+
# Get file filters
|
|
1205
|
+
filters = [
|
|
1206
|
+
f.strip() for f in self.filter_edit.text().split(",") if f.strip()
|
|
1207
|
+
]
|
|
1208
|
+
if not filters:
|
|
1209
|
+
filters = [".lif", ".nd2", ".ndpi", ".czi"]
|
|
1210
|
+
|
|
1211
|
+
# Clear existing files
|
|
1212
|
+
self.files_table.setRowCount(0)
|
|
1213
|
+
self.files_table.file_data.clear()
|
|
1214
|
+
|
|
1215
|
+
# Set up and start the worker thread
|
|
1216
|
+
self.scan_worker = ScanFolderWorker(folder, filters)
|
|
1217
|
+
self.scan_worker.progress.connect(self.update_scan_progress)
|
|
1218
|
+
self.scan_worker.finished.connect(self.process_found_files)
|
|
1219
|
+
self.scan_worker.error.connect(self.show_error)
|
|
1220
|
+
|
|
1221
|
+
# Show progress bar and start worker
|
|
1222
|
+
self.scan_progress.setVisible(True)
|
|
1223
|
+
self.scan_progress.setValue(0)
|
|
1224
|
+
self.cancel_button.setVisible(True)
|
|
1225
|
+
self.status_label.setText("Scanning folder...")
|
|
1226
|
+
self.scan_worker.start()
|
|
1227
|
+
|
|
1228
|
+
def update_scan_progress(self, current, total):
|
|
1229
|
+
"""Update the scan progress bar"""
|
|
1230
|
+
if total > 0:
|
|
1231
|
+
self.scan_progress.setValue(int(current * 100 / total))
|
|
1232
|
+
|
|
1233
|
+
def process_found_files(self, found_files):
|
|
1234
|
+
"""Process the list of found files after scanning is complete"""
|
|
1235
|
+
# Hide progress bar
|
|
1236
|
+
self.scan_progress.setVisible(False)
|
|
1237
|
+
self.cancel_button.setVisible(False)
|
|
1238
|
+
|
|
1239
|
+
# Process files
|
|
1240
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
1241
|
+
# Process files in parallel to get series counts
|
|
1242
|
+
futures = {}
|
|
1243
|
+
for filepath in found_files:
|
|
1244
|
+
file_type = self.get_file_type(filepath)
|
|
1245
|
+
if file_type:
|
|
1246
|
+
loader = self.get_file_loader(filepath)
|
|
1247
|
+
if loader:
|
|
1248
|
+
future = executor.submit(
|
|
1249
|
+
loader.get_series_count, filepath
|
|
1250
|
+
)
|
|
1251
|
+
futures[future] = (filepath, file_type)
|
|
1252
|
+
|
|
1253
|
+
# Process results as they complete
|
|
1254
|
+
file_count = len(found_files)
|
|
1255
|
+
processed = 0
|
|
1256
|
+
|
|
1257
|
+
for i, future in enumerate(
|
|
1258
|
+
concurrent.futures.as_completed(futures)
|
|
1259
|
+
):
|
|
1260
|
+
processed = i + 1
|
|
1261
|
+
filepath, file_type = futures[future]
|
|
1262
|
+
|
|
1263
|
+
try:
|
|
1264
|
+
series_count = future.result()
|
|
1265
|
+
# Add file to table
|
|
1266
|
+
self.files_table.add_file(
|
|
1267
|
+
filepath, file_type, series_count
|
|
1268
|
+
)
|
|
1269
|
+
except (ValueError, FileNotFoundError) as e:
|
|
1270
|
+
print(f"Error processing {filepath}: {str(e)}")
|
|
1271
|
+
# Add file with error indication
|
|
1272
|
+
self.files_table.add_file(filepath, file_type, -1)
|
|
1273
|
+
|
|
1274
|
+
# Update status periodically
|
|
1275
|
+
if processed % 5 == 0 or processed == file_count:
|
|
1276
|
+
self.status_label.setText(
|
|
1277
|
+
f"Processed {processed}/{file_count} files..."
|
|
1278
|
+
)
|
|
1279
|
+
QApplication.processEvents()
|
|
1280
|
+
|
|
1281
|
+
self.status_label.setText(f"Found {len(found_files)} files")
|
|
1282
|
+
|
|
1283
|
+
def show_error(self, error_message):
|
|
1284
|
+
"""Show error message"""
|
|
1285
|
+
self.status_label.setText(f"Error: {error_message}")
|
|
1286
|
+
self.scan_progress.setVisible(False)
|
|
1287
|
+
self.cancel_button.setVisible(False)
|
|
1288
|
+
QMessageBox.critical(self, "Error", error_message)
|
|
1289
|
+
|
|
1290
|
+
def get_file_type(self, filepath: str) -> str:
|
|
1291
|
+
"""Determine the file type based on extension"""
|
|
1292
|
+
ext = filepath.lower()
|
|
1293
|
+
if ext.endswith(".lif"):
|
|
1294
|
+
return "LIF"
|
|
1295
|
+
elif ext.endswith(".nd2"):
|
|
1296
|
+
return "ND2"
|
|
1297
|
+
elif ext.endswith((".ndpi", ".svs")):
|
|
1298
|
+
return "Slide"
|
|
1299
|
+
elif ext.endswith(".czi"):
|
|
1300
|
+
return "CZI"
|
|
1301
|
+
elif ext.endswith((".tif", ".tiff")):
|
|
1302
|
+
return "TIFF"
|
|
1303
|
+
return "Unknown"
|
|
1304
|
+
|
|
1305
|
+
def get_file_loader(self, filepath: str) -> Optional[FormatLoader]:
|
|
1306
|
+
"""Get the appropriate loader for the file type"""
|
|
1307
|
+
for loader in self.loaders:
|
|
1308
|
+
if loader.can_load(filepath):
|
|
1309
|
+
return loader
|
|
1310
|
+
return None
|
|
1311
|
+
|
|
1312
|
+
def show_series_details(self, filepath: str):
|
|
1313
|
+
"""Show details for the series in the selected file"""
|
|
1314
|
+
self.series_widget.set_file(filepath)
|
|
1315
|
+
|
|
1316
|
+
def set_selected_series(self, filepath: str, series_index: int):
|
|
1317
|
+
"""Set the selected series for a file"""
|
|
1318
|
+
self.selected_series[filepath] = series_index
|
|
1319
|
+
|
|
1320
|
+
def load_image(self, filepath: str):
|
|
1321
|
+
"""Load an image file into the viewer"""
|
|
1322
|
+
loader = self.get_file_loader(filepath)
|
|
1323
|
+
if not loader:
|
|
1324
|
+
self.viewer.status = f"Unsupported file format: {filepath}"
|
|
1325
|
+
return
|
|
1326
|
+
|
|
1327
|
+
try:
|
|
1328
|
+
# For non-series files, just load the first series
|
|
1329
|
+
series_index = 0
|
|
1330
|
+
image_data = loader.load_series(filepath, series_index)
|
|
1331
|
+
|
|
1332
|
+
# Clear existing layers and display the image
|
|
1333
|
+
self.viewer.layers.clear()
|
|
1334
|
+
self.viewer.add_image(image_data, name=f"{Path(filepath).stem}")
|
|
1335
|
+
|
|
1336
|
+
# Update status
|
|
1337
|
+
self.viewer.status = f"Loaded {Path(filepath).name}"
|
|
1338
|
+
except (ValueError, FileNotFoundError) as e:
|
|
1339
|
+
self.viewer.status = f"Error loading image: {str(e)}"
|
|
1340
|
+
QMessageBox.warning(
|
|
1341
|
+
self, "Error", f"Could not load image: {str(e)}"
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
def is_output_folder_valid(self, folder):
|
|
1345
|
+
"""Check if the output folder is valid and writable"""
|
|
1346
|
+
if not folder:
|
|
1347
|
+
self.status_label.setText("Please specify an output folder")
|
|
1348
|
+
return False
|
|
1349
|
+
|
|
1350
|
+
# Check if folder exists, if not try to create it
|
|
1351
|
+
if not os.path.exists(folder):
|
|
1352
|
+
try:
|
|
1353
|
+
os.makedirs(folder)
|
|
1354
|
+
except (FileNotFoundError, PermissionError) as e:
|
|
1355
|
+
self.status_label.setText(
|
|
1356
|
+
f"Cannot create output folder: {str(e)}"
|
|
1357
|
+
)
|
|
1358
|
+
return False
|
|
1359
|
+
|
|
1360
|
+
# Check if folder is writable
|
|
1361
|
+
if not os.access(folder, os.W_OK):
|
|
1362
|
+
self.status_label.setText("Output folder is not writable")
|
|
1363
|
+
return False
|
|
1364
|
+
|
|
1365
|
+
return True
|
|
1366
|
+
|
|
1367
|
+
def convert_files(self):
|
|
1368
|
+
"""Convert selected files to TIF or ZARR"""
|
|
1369
|
+
# Check if any files are selected
|
|
1370
|
+
if not self.selected_series:
|
|
1371
|
+
self.status_label.setText("No files selected for conversion")
|
|
1372
|
+
return
|
|
1373
|
+
|
|
1374
|
+
# Check output folder
|
|
1375
|
+
output_folder = self.output_edit.text()
|
|
1376
|
+
if not output_folder:
|
|
1377
|
+
output_folder = os.path.join(self.folder_edit.text(), "converted")
|
|
1378
|
+
|
|
1379
|
+
# Validate output folder
|
|
1380
|
+
if not self.is_output_folder_valid(output_folder):
|
|
1381
|
+
return
|
|
1382
|
+
|
|
1383
|
+
# Create files to convert list
|
|
1384
|
+
files_to_convert = [
|
|
1385
|
+
(filepath, series_index)
|
|
1386
|
+
for filepath, series_index in self.selected_series.items()
|
|
1387
|
+
]
|
|
1388
|
+
|
|
1389
|
+
# Set up and start the conversion worker thread
|
|
1390
|
+
self.conversion_worker = ConversionWorker(
|
|
1391
|
+
files_to_convert=files_to_convert,
|
|
1392
|
+
output_folder=output_folder,
|
|
1393
|
+
use_zarr=self.zarr_radio.isChecked(),
|
|
1394
|
+
file_loader_func=self.get_file_loader,
|
|
1395
|
+
)
|
|
1396
|
+
|
|
1397
|
+
# Connect signals
|
|
1398
|
+
self.conversion_worker.progress.connect(
|
|
1399
|
+
self.update_conversion_progress
|
|
1400
|
+
)
|
|
1401
|
+
self.conversion_worker.file_done.connect(
|
|
1402
|
+
self.handle_file_conversion_result
|
|
1403
|
+
)
|
|
1404
|
+
self.conversion_worker.finished.connect(self.conversion_completed)
|
|
1405
|
+
|
|
1406
|
+
# Show progress bar and start worker
|
|
1407
|
+
self.conversion_progress.setVisible(True)
|
|
1408
|
+
self.conversion_progress.setValue(0)
|
|
1409
|
+
self.cancel_button.setVisible(True)
|
|
1410
|
+
self.status_label.setText(
|
|
1411
|
+
f"Starting conversion of {len(files_to_convert)} files..."
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
# Start conversion
|
|
1415
|
+
self.conversion_worker.start()
|
|
1416
|
+
|
|
1417
|
+
def update_conversion_progress(self, current, total, filename):
|
|
1418
|
+
"""Update conversion progress bar and status"""
|
|
1419
|
+
if total > 0:
|
|
1420
|
+
self.conversion_progress.setValue(int(current * 100 / total))
|
|
1421
|
+
self.status_label.setText(
|
|
1422
|
+
f"Converting {filename} ({current}/{total})..."
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
def handle_file_conversion_result(self, filepath, success, message):
|
|
1426
|
+
"""Handle result of a single file conversion"""
|
|
1427
|
+
filename = Path(filepath).name
|
|
1428
|
+
if success:
|
|
1429
|
+
print(f"Successfully converted: {filename} - {message}")
|
|
1430
|
+
else:
|
|
1431
|
+
print(f"Failed to convert: {filename} - {message}")
|
|
1432
|
+
QMessageBox.warning(
|
|
1433
|
+
self,
|
|
1434
|
+
"Conversion Warning",
|
|
1435
|
+
f"Error converting {filename}: {message}",
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
def conversion_completed(self, success_count):
|
|
1439
|
+
"""Handle completion of all conversions"""
|
|
1440
|
+
self.conversion_progress.setVisible(False)
|
|
1441
|
+
self.cancel_button.setVisible(False)
|
|
1442
|
+
|
|
1443
|
+
output_folder = self.output_edit.text()
|
|
1444
|
+
if not output_folder:
|
|
1445
|
+
output_folder = os.path.join(self.folder_edit.text(), "converted")
|
|
1446
|
+
if success_count > 0:
|
|
1447
|
+
self.status_label.setText(
|
|
1448
|
+
f"Successfully converted {success_count} files to {output_folder}"
|
|
1449
|
+
)
|
|
1450
|
+
else:
|
|
1451
|
+
self.status_label.setText("No files were converted")
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
# Create a MagicGUI widget that creates and returns the converter widget
|
|
1455
|
+
@magicgui(
|
|
1456
|
+
call_button="Start Microscopy Image Converter",
|
|
1457
|
+
layout="vertical",
|
|
1458
|
+
)
|
|
1459
|
+
def microscopy_converter(viewer: napari.Viewer):
|
|
1460
|
+
"""
|
|
1461
|
+
Start the microscopy image converter tool
|
|
1462
|
+
"""
|
|
1463
|
+
# Create the converter widget
|
|
1464
|
+
converter_widget = MicroscopyImageConverterWidget(viewer)
|
|
1465
|
+
|
|
1466
|
+
# Add to viewer
|
|
1467
|
+
viewer.window.add_dock_widget(
|
|
1468
|
+
converter_widget, name="Microscopy Image Converter", area="right"
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
return converter_widget
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
# This is what napari calls to get the widget
|
|
1475
|
+
def napari_experimental_provide_dock_widget():
|
|
1476
|
+
"""Provide the converter widget to Napari"""
|
|
1477
|
+
return microscopy_converter
|