napari-tmidas 0.1.5__py3-none-any.whl → 0.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,16 @@
1
+ """
2
+ Batch Image Processing with Napari
3
+ ----------------------------------
4
+ This module provides a collection of functions for batch processing of image files.
5
+ It includes a Napari widget for selecting files and processing functions, and a
6
+ custom widget for displaying and processing the selected files.
7
+
8
+ New functions can be added to the processing registry by decorating them with
9
+ `@register_batch_processing_function`. Each function should accept an image array
10
+ as the first argument, and any additional keyword arguments for parameters.
11
+ """
12
+
1
13
  import concurrent.futures
2
- import contextlib
3
14
  import os
4
15
  import sys
5
16
  from typing import Any, Dict, List
@@ -58,6 +69,12 @@ class ProcessedFilesTableWidget(QTableWidget):
58
69
  self.current_original_image = None
59
70
  self.current_processed_image = None
60
71
 
72
+ # For tracking multi-output files
73
+ self.multi_output_files = {}
74
+
75
+ # Connect the cellDoubleClicked signal
76
+ self.cellDoubleClicked.connect(self._handle_cell_double_click)
77
+
61
78
  def add_initial_files(self, file_list: List[str]):
62
79
  """
63
80
  Add initial files to the table
@@ -65,6 +82,7 @@ class ProcessedFilesTableWidget(QTableWidget):
65
82
  # Clear existing rows
66
83
  self.setRowCount(0)
67
84
  self.file_pairs.clear()
85
+ self.multi_output_files.clear()
68
86
 
69
87
  # Add files
70
88
  for filepath in file_list:
@@ -87,65 +105,154 @@ class ProcessedFilesTableWidget(QTableWidget):
87
105
  "row": row,
88
106
  }
89
107
 
90
- def update_processed_files(self, processing_info: dict):
108
+ def update_processed_files(self, processing_info: List[Dict]):
91
109
  """
92
110
  Update table with processed files
93
111
 
94
- processing_info: {
95
- 'original_file': original filepath,
96
- 'processed_file': processed filepath
97
- }
112
+ Args:
113
+ processing_info: List of dictionaries containing:
114
+ {
115
+ 'original_file': original filepath,
116
+ 'processed_file': processed filepath (single output)
117
+ - OR -
118
+ 'processed_files': list of processed filepaths (multi-output)
119
+ }
98
120
  """
99
121
  for item in processing_info:
100
122
  original_file = item["original_file"]
101
- processed_file = item["processed_file"]
102
-
103
- # Find the corresponding row
104
- if original_file in self.file_pairs:
105
- row = self.file_pairs[original_file]["row"]
106
-
107
- # Update processed file column
108
- processed_item = QTableWidgetItem(
109
- os.path.basename(processed_file)
110
- )
111
- processed_item.setData(Qt.UserRole, processed_file)
112
- self.setItem(row, 1, processed_item)
113
123
 
114
- # Update file pairs
115
- self.file_pairs[original_file]["processed"] = processed_file
124
+ # Handle single processed file case
125
+ if "processed_file" in item:
126
+ processed_file = item["processed_file"]
127
+
128
+ # Find the corresponding row
129
+ if original_file in self.file_pairs:
130
+ row = self.file_pairs[original_file]["row"]
131
+
132
+ # Create a single item with the processed file
133
+ file_name = os.path.basename(processed_file)
134
+ processed_item = QTableWidgetItem(file_name)
135
+ processed_item.setData(Qt.UserRole, processed_file)
136
+ processed_item.setToolTip("Double-click to view")
137
+ self.setItem(row, 1, processed_item)
138
+
139
+ # Update file pairs
140
+ self.file_pairs[original_file][
141
+ "processed"
142
+ ] = processed_file
143
+
144
+ # Handle multi-file output case
145
+ elif "processed_files" in item and item["processed_files"]:
146
+ processed_files = item["processed_files"]
147
+
148
+ # Store all processed files for this original file
149
+ self.multi_output_files[original_file] = processed_files
150
+
151
+ # Find the corresponding row
152
+ if original_file in self.file_pairs:
153
+ row = self.file_pairs[original_file]["row"]
154
+
155
+ # Create a ComboBox for selecting outputs
156
+ combo = QComboBox()
157
+ for i, file_path in enumerate(processed_files):
158
+ file_name = os.path.basename(file_path)
159
+ combo.addItem(f"Channel {i}: {file_name}", file_path)
160
+
161
+ # Connect the combo box to load the selected processed file
162
+ combo.currentIndexChanged.connect(
163
+ lambda idx, files=processed_files: self._load_processed_image(
164
+ files[idx]
165
+ )
166
+ )
167
+
168
+ # Add the ComboBox directly to the table cell
169
+ self.setCellWidget(row, 1, combo)
170
+
171
+ # Update file pairs with first file as default
172
+ self.file_pairs[original_file]["processed"] = (
173
+ processed_files[0]
174
+ )
116
175
 
117
176
  def mousePressEvent(self, event):
118
177
  """
119
- Load image when clicked
178
+ Handle mouse click events on the table to load appropriate images
120
179
  """
121
180
  if event.button() == Qt.LeftButton:
181
+ # Get the item at the click position
122
182
  item = self.itemAt(event.pos())
123
- if item:
183
+ column = self.columnAt(event.pos().x())
184
+ row = self.rowAt(event.pos().y())
185
+
186
+ # Load original image when clicking on first column
187
+ if column == 0 and item:
124
188
  filepath = item.data(Qt.UserRole)
125
189
  if filepath:
126
- # Determine which column was clicked
127
- column = self.columnAt(event.pos().x())
128
- if column == 0:
129
- # Original image clicked
130
- self._load_original_image(filepath)
131
- elif column == 1 and filepath:
132
- # Processed image clicked
190
+ self._load_original_image(filepath)
191
+
192
+ # Load processed image when clicking on second column (for single output files)
193
+ elif column == 1:
194
+ # Check if this cell has a non-combo-box item (single output)
195
+ cell_item = self.item(row, column)
196
+ if cell_item and cell_item.data(Qt.UserRole):
197
+ filepath = cell_item.data(Qt.UserRole)
198
+ if filepath:
133
199
  self._load_processed_image(filepath)
200
+ # Combo boxes are handled by their own event handlers
134
201
 
135
202
  super().mousePressEvent(event)
136
203
 
204
+ def _handle_cell_double_click(self, row, column):
205
+ """
206
+ Handle double-click events on cells, particularly for single processed files
207
+ """
208
+ if column == 1:
209
+ item = self.item(row, column)
210
+ if (
211
+ item
212
+ ): # This means it's a single processed file, not a combo box
213
+ filepath = item.data(Qt.UserRole)
214
+ if filepath:
215
+ self._load_processed_image(filepath)
216
+
137
217
  def _load_original_image(self, filepath: str):
138
218
  """
139
219
  Load original image into viewer
140
220
  """
221
+ # Ensure filepath is valid
222
+ if not filepath or not os.path.exists(filepath):
223
+ print(f"Error: File does not exist: {filepath}")
224
+ self.viewer.status = f"Error: File not found: {filepath}"
225
+ return
226
+
141
227
  # Remove existing original layer if it exists
142
228
  if self.current_original_image is not None:
143
- with contextlib.suppress(KeyError):
144
- self.viewer.layers.remove(self.current_original_image)
229
+ try:
230
+ # Check if the layer is still in the viewer
231
+ if self.current_original_image in self.viewer.layers:
232
+ self.viewer.layers.remove(self.current_original_image)
233
+ else:
234
+ # If not found by reference, try by name
235
+ layer_names = [layer.name for layer in self.viewer.layers]
236
+ if self.current_original_image.name in layer_names:
237
+ self.viewer.layers.remove(
238
+ self.current_original_image.name
239
+ )
240
+ except (KeyError, ValueError) as e:
241
+ print(
242
+ f"Warning: Could not remove previous original layer: {e}"
243
+ )
244
+
245
+ # Reset the current original image reference
246
+ self.current_original_image = None
145
247
 
146
248
  # Load new image
147
249
  try:
250
+ # Display status while loading
251
+ self.viewer.status = f"Loading {os.path.basename(filepath)}..."
252
+
148
253
  image = imread(filepath)
254
+ # remove singletons
255
+ image = np.squeeze(image)
149
256
  # check if label image by checking file name
150
257
  is_label = "labels" in os.path.basename(
151
258
  filepath
@@ -159,6 +266,10 @@ class ProcessedFilesTableWidget(QTableWidget):
159
266
  self.current_original_image = self.viewer.add_image(
160
267
  image, name=f"Original: {os.path.basename(filepath)}"
161
268
  )
269
+
270
+ # Update status with success message
271
+ self.viewer.status = f"Loaded {os.path.basename(filepath)}"
272
+
162
273
  except (ValueError, TypeError, OSError, tifffile.TiffFileError) as e:
163
274
  print(f"Error loading original image {filepath}: {e}")
164
275
  self.viewer.status = f"Error processing {filepath}: {e}"
@@ -166,15 +277,43 @@ class ProcessedFilesTableWidget(QTableWidget):
166
277
  def _load_processed_image(self, filepath: str):
167
278
  """
168
279
  Load processed image into viewer, distinguishing labels by filename pattern
280
+ and ensure it's always shown on top
169
281
  """
282
+ # Ensure filepath is valid
283
+ if not filepath or not os.path.exists(filepath):
284
+ print(f"Error: File does not exist: {filepath}")
285
+ self.viewer.status = f"Error: File not found: {filepath}"
286
+ return
287
+
170
288
  # Remove existing processed layer if it exists
171
289
  if self.current_processed_image is not None:
172
- with contextlib.suppress(KeyError):
173
- self.viewer.layers.remove(self.current_processed_image)
290
+ try:
291
+ # Check if the layer is still in the viewer
292
+ if self.current_processed_image in self.viewer.layers:
293
+ self.viewer.layers.remove(self.current_processed_image)
294
+ else:
295
+ # If not found by reference, try by name
296
+ layer_names = [layer.name for layer in self.viewer.layers]
297
+ if self.current_processed_image.name in layer_names:
298
+ self.viewer.layers.remove(
299
+ self.current_processed_image.name
300
+ )
301
+ except (KeyError, ValueError) as e:
302
+ print(
303
+ f"Warning: Could not remove previous processed layer: {e}"
304
+ )
305
+
306
+ # Reset the current processed image reference
307
+ self.current_processed_image = None
174
308
 
175
309
  # Load new image
176
310
  try:
311
+ # Display status while loading
312
+ self.viewer.status = f"Loading {os.path.basename(filepath)}..."
313
+
177
314
  image = imread(filepath)
315
+ # remove singletons
316
+ image = np.squeeze(image)
178
317
  filename = os.path.basename(filepath)
179
318
 
180
319
  # Check if filename contains label indicators
@@ -194,7 +333,21 @@ class ProcessedFilesTableWidget(QTableWidget):
194
333
  image, name=f"Processed: {filename}"
195
334
  )
196
335
 
197
- except (ValueError, TypeError) as e:
336
+ # Move the processed layer to the top of the stack
337
+ # Get the index of the current processed layer
338
+ layer_index = self.viewer.layers.index(
339
+ self.current_processed_image
340
+ )
341
+ # Move it to the top (last position in the list)
342
+ if layer_index < len(self.viewer.layers) - 1:
343
+ self.viewer.layers.move(
344
+ layer_index, len(self.viewer.layers) - 1
345
+ )
346
+
347
+ # Update status with success message
348
+ self.viewer.status = f"Loaded {filename} (moved to top layer)"
349
+
350
+ except (ValueError, TypeError, OSError, tifffile.TiffFileError) as e:
198
351
  print(f"Error loading processed image {filepath}: {e}")
199
352
  self.viewer.status = f"Error processing {filepath}: {e}"
200
353
 
@@ -351,7 +504,6 @@ def _add_browse_button_to_selector(file_selector_widget):
351
504
  h_layout.addWidget(browse_button)
352
505
 
353
506
  # Replace the input field with our container
354
- # parent = input_folder_widget.parentWidget()
355
507
  layout_index = parent_layout.indexOf(input_folder_widget)
356
508
  parent_layout.removeWidget(input_folder_widget)
357
509
  parent_layout.insertWidget(layout_index, container_widget)
@@ -404,6 +556,7 @@ class ProcessingWorker(QThread):
404
556
  self.input_suffix = input_suffix
405
557
  self.output_suffix = output_suffix
406
558
  self.stop_requested = False
559
+ self.thread_count = max(1, (os.cpu_count() or 4) - 1) # Default value
407
560
 
408
561
  def run(self):
409
562
  """Process files in a separate thread"""
@@ -411,8 +564,10 @@ class ProcessingWorker(QThread):
411
564
  processed_files_info = []
412
565
  total_files = len(self.file_list)
413
566
 
414
- # Create a thread pool for concurrent processing
415
- with concurrent.futures.ThreadPoolExecutor() as executor:
567
+ # Create a thread pool for concurrent processing with specified thread count
568
+ with concurrent.futures.ThreadPoolExecutor(
569
+ max_workers=self.thread_count
570
+ ) as executor:
416
571
  # Submit tasks
417
572
  future_to_file = {
418
573
  executor.submit(self.process_file, filepath): filepath
@@ -457,35 +612,85 @@ class ProcessingWorker(QThread):
457
612
  # Apply processing with parameters
458
613
  processed_image = self.processing_func(image, **self.param_values)
459
614
 
460
- # Generate new filename
615
+ # Generate new filename base
461
616
  filename = os.path.basename(filepath)
462
617
  name, ext = os.path.splitext(filename)
463
- new_filename = (
464
- name.replace(self.input_suffix, "") + self.output_suffix + ext
618
+ new_filename_base = (
619
+ name.replace(self.input_suffix, "") + self.output_suffix
465
620
  )
466
- new_filepath = os.path.join(self.output_folder, new_filename)
467
-
468
- # Save the processed image
469
- if "labels" in new_filename or "semantic" in new_filename:
470
- # processed_image = ndi.label(processed_image)[0]
471
- tifffile.imwrite(
472
- new_filepath,
473
- processed_image.astype(np.uint32),
474
- compression="zlib",
475
- )
621
+
622
+ # Check if the processed image is a stacked array
623
+ if processed_image.ndim > image.ndim:
624
+ # Save each channel as a separate image
625
+ processed_files = []
626
+ for i in range(processed_image.shape[0]):
627
+ channel_filename = f"{new_filename_base}_channel_{i}{ext}"
628
+ channel_filepath = os.path.join(
629
+ self.output_folder, channel_filename
630
+ )
631
+
632
+ if (
633
+ "labels" in channel_filename
634
+ or "semantic" in channel_filename
635
+ ):
636
+ tifffile.imwrite(
637
+ channel_filepath,
638
+ processed_image[i].astype(np.uint32),
639
+ compression="zlib",
640
+ )
641
+ else:
642
+ # First remove singletons
643
+ channel_image = np.squeeze(processed_image[i])
644
+ tifffile.imwrite(
645
+ channel_filepath,
646
+ channel_image.astype(image_dtype),
647
+ compression="zlib",
648
+ )
649
+ processed_files.append(channel_filepath)
650
+
651
+ # Return processing info
652
+ return {
653
+ "original_file": filepath,
654
+ "processed_files": processed_files,
655
+ }
476
656
  else:
477
- tifffile.imwrite(
478
- new_filepath,
479
- processed_image.astype(image_dtype),
480
- compression="zlib",
657
+ # Save as a single image (original behavior)
658
+ new_filepath = os.path.join(
659
+ self.output_folder, new_filename_base + ext
481
660
  )
482
661
 
483
- # Return processing info
484
- return {"original_file": filepath, "processed_file": new_filepath}
485
-
486
- except Exception:
487
- # Re-raise to be caught by the executor
662
+ if (
663
+ "labels" in new_filename_base
664
+ or "semantic" in new_filename_base
665
+ ):
666
+ tifffile.imwrite(
667
+ new_filepath,
668
+ processed_image.astype(np.uint32),
669
+ compression="zlib",
670
+ )
671
+ else:
672
+ tifffile.imwrite(
673
+ new_filepath,
674
+ processed_image.astype(image_dtype),
675
+ compression="zlib",
676
+ )
677
+
678
+ # Return processing info
679
+ return {
680
+ "original_file": filepath,
681
+ "processed_file": new_filepath,
682
+ }
683
+
684
+ except Exception as e:
685
+ # Log the error and re-raise to be caught by the executor
686
+ print(f"Error processing {filepath}: {e}")
488
687
  raise
688
+ finally:
689
+ # Explicit cleanup to help with memory management
690
+ if "image" in locals():
691
+ del image
692
+ if "processed_image" in locals():
693
+ del processed_image
489
694
 
490
695
  def stop(self):
491
696
  """Request worker to stop processing"""
@@ -517,14 +722,6 @@ class FileResultsWidget(QWidget):
517
722
  layout = QVBoxLayout()
518
723
  self.setLayout(layout)
519
724
 
520
- # Input folder widgets
521
- self.input_folder_widget = QLineEdit(self.input_folder)
522
- layout.addWidget(self.input_folder_widget)
523
-
524
- browse_button = QPushButton("Browse...")
525
- browse_button.clicked.connect(self.browse_folder)
526
- layout.addWidget(browse_button)
527
-
528
725
  # Create table of files
529
726
  self.table = ProcessedFilesTableWidget(viewer)
530
727
  self.table.add_initial_files(file_list)
@@ -623,40 +820,6 @@ class FileResultsWidget(QWidget):
623
820
  # Container for tracking processed files during batch operation
624
821
  self.processed_files_info = []
625
822
 
626
- def browse_folder(self):
627
- """
628
- Open a file dialog to select a folder
629
- """
630
- folder = QFileDialog.getExistingDirectory(
631
- self, "Select Folder", self.input_folder
632
- )
633
- if folder:
634
- self.input_folder = folder
635
- self.input_folder_widget.setText(folder)
636
- self.validate_selected_folder(folder)
637
-
638
- def validate_selected_folder(self, folder: str):
639
- """
640
- Validate the selected folder and update the file list
641
- """
642
- if not os.path.isdir(folder):
643
- self.viewer.status = f"Invalid input folder: {folder}"
644
- return
645
-
646
- # Find matching files
647
- matching_files = [
648
- os.path.join(folder, f)
649
- for f in os.listdir(folder)
650
- if f.endswith(self.input_suffix)
651
- ]
652
-
653
- # Update table with new files
654
- self.file_list = matching_files
655
- self.table.add_initial_files(matching_files)
656
-
657
- # Update viewer status
658
- self.viewer.status = f"Found {len(matching_files)} files"
659
-
660
823
  def update_function_info(self, function_name: str):
661
824
  """
662
825
  Update the function description and parameters when a new function is selected
@@ -754,6 +917,9 @@ class FileResultsWidget(QWidget):
754
917
  output_suffix,
755
918
  )
756
919
 
920
+ # Set the thread count from the UI
921
+ self.worker.thread_count = self.thread_count.value()
922
+
757
923
  # Connect signals
758
924
  self.worker.progress_updated.connect(self.update_progress)
759
925
  self.worker.file_processed.connect(self.file_processed)
@@ -1,3 +1,13 @@
1
+ """
2
+ Batch Label Inspection for Napari
3
+ ---------------------------------
4
+ This module provides a widget for Napari that allows users to inspect image-label pairs in a folder.
5
+ The widget loads image-label pairs from a folder and displays them in the Napari viewer.
6
+ Users can make and save changes to the labels, and proceed to the next pair.
7
+
8
+
9
+ """
10
+
1
11
  import os
2
12
  import sys
3
13