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.
@@ -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