napari-tmidas 0.2.1__py3-none-any.whl → 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. napari_tmidas/__init__.py +35 -5
  2. napari_tmidas/_crop_anything.py +1458 -499
  3. napari_tmidas/_env_manager.py +76 -0
  4. napari_tmidas/_file_conversion.py +1646 -1131
  5. napari_tmidas/_file_selector.py +1464 -223
  6. napari_tmidas/_label_inspection.py +83 -8
  7. napari_tmidas/_processing_worker.py +309 -0
  8. napari_tmidas/_reader.py +6 -10
  9. napari_tmidas/_registry.py +15 -14
  10. napari_tmidas/_roi_colocalization.py +1221 -84
  11. napari_tmidas/_tests/test_crop_anything.py +123 -0
  12. napari_tmidas/_tests/test_env_manager.py +89 -0
  13. napari_tmidas/_tests/test_file_selector.py +90 -0
  14. napari_tmidas/_tests/test_grid_view_overlay.py +193 -0
  15. napari_tmidas/_tests/test_init.py +98 -0
  16. napari_tmidas/_tests/test_intensity_label_filter.py +222 -0
  17. napari_tmidas/_tests/test_label_inspection.py +86 -0
  18. napari_tmidas/_tests/test_processing_basic.py +500 -0
  19. napari_tmidas/_tests/test_processing_worker.py +142 -0
  20. napari_tmidas/_tests/test_regionprops_analysis.py +547 -0
  21. napari_tmidas/_tests/test_registry.py +135 -0
  22. napari_tmidas/_tests/test_scipy_filters.py +168 -0
  23. napari_tmidas/_tests/test_skimage_filters.py +259 -0
  24. napari_tmidas/_tests/test_split_channels.py +217 -0
  25. napari_tmidas/_tests/test_spotiflow.py +87 -0
  26. napari_tmidas/_tests/test_tyx_display_fix.py +142 -0
  27. napari_tmidas/_tests/test_ui_utils.py +68 -0
  28. napari_tmidas/_tests/test_widget.py +30 -0
  29. napari_tmidas/_tests/test_windows_basic.py +66 -0
  30. napari_tmidas/_ui_utils.py +57 -0
  31. napari_tmidas/_version.py +16 -3
  32. napari_tmidas/_widget.py +41 -4
  33. napari_tmidas/processing_functions/basic.py +557 -20
  34. napari_tmidas/processing_functions/careamics_env_manager.py +72 -99
  35. napari_tmidas/processing_functions/cellpose_env_manager.py +415 -112
  36. napari_tmidas/processing_functions/cellpose_segmentation.py +132 -191
  37. napari_tmidas/processing_functions/colocalization.py +513 -56
  38. napari_tmidas/processing_functions/grid_view_overlay.py +703 -0
  39. napari_tmidas/processing_functions/intensity_label_filter.py +422 -0
  40. napari_tmidas/processing_functions/regionprops_analysis.py +1280 -0
  41. napari_tmidas/processing_functions/sam2_env_manager.py +53 -69
  42. napari_tmidas/processing_functions/sam2_mp4.py +274 -195
  43. napari_tmidas/processing_functions/scipy_filters.py +403 -8
  44. napari_tmidas/processing_functions/skimage_filters.py +424 -212
  45. napari_tmidas/processing_functions/spotiflow_detection.py +949 -0
  46. napari_tmidas/processing_functions/spotiflow_env_manager.py +591 -0
  47. napari_tmidas/processing_functions/timepoint_merger.py +334 -86
  48. napari_tmidas/processing_functions/trackastra_tracking.py +24 -5
  49. {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/METADATA +92 -39
  50. napari_tmidas-0.2.4.dist-info/RECORD +63 -0
  51. napari_tmidas/_tests/__init__.py +0 -0
  52. napari_tmidas-0.2.1.dist-info/RECORD +0 -38
  53. {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/WHEEL +0 -0
  54. {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/entry_points.txt +0 -0
  55. {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/licenses/LICENSE +0 -0
  56. {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/top_level.txt +0 -0
@@ -12,11 +12,49 @@ import os
12
12
  import sys
13
13
 
14
14
  import numpy as np
15
- from magicgui import magicgui
16
- from napari.layers import Labels
17
- from napari.viewer import Viewer
18
- from qtpy.QtWidgets import QFileDialog, QMessageBox, QPushButton
19
- from skimage.io import imread # , imsave
15
+
16
+ # Lazy imports for optional heavy dependencies
17
+ try:
18
+ from magicgui import magicgui
19
+
20
+ _HAS_MAGICGUI = True
21
+ except ImportError:
22
+ # Create stub decorator
23
+ def magicgui(*args, **kwargs):
24
+ def decorator(func):
25
+ return func
26
+
27
+ if len(args) == 1 and callable(args[0]) and not kwargs:
28
+ return args[0]
29
+ return decorator
30
+
31
+ _HAS_MAGICGUI = False
32
+
33
+ try:
34
+ from napari.layers import Labels
35
+ from napari.viewer import Viewer
36
+
37
+ _HAS_NAPARI = True
38
+ except ImportError:
39
+ Labels = None
40
+ Viewer = None
41
+ _HAS_NAPARI = False
42
+
43
+ try:
44
+ from qtpy.QtWidgets import QFileDialog, QMessageBox, QPushButton
45
+
46
+ _HAS_QTPY = True
47
+ except ImportError:
48
+ QFileDialog = QMessageBox = QPushButton = None
49
+ _HAS_QTPY = False
50
+
51
+ try:
52
+ from skimage.io import imread # , imsave
53
+
54
+ _HAS_SKIMAGE = True
55
+ except ImportError:
56
+ imread = None
57
+ _HAS_SKIMAGE = False
20
58
 
21
59
  sys.path.append("src/napari_tmidas")
22
60
 
@@ -27,6 +65,43 @@ class LabelInspector:
27
65
  self.image_label_pairs = []
28
66
  self.current_index = 0
29
67
 
68
+ # ------------------------------------------------------------------
69
+ # Internal helpers
70
+ # ------------------------------------------------------------------
71
+ def _can_show_message(self) -> bool:
72
+ """Return True if it's (probably) safe to show a QMessageBox.
73
+
74
+ On Windows CI (headless) creating a modal dialog without a running
75
+ QApplication or with a mocked viewer can cause access violations.
76
+ We suppress dialogs when:
77
+ * No QApplication instance exists
78
+ * Running under pytest (detected via env var)
79
+ * The provided viewer is a mock (has no 'window' attr)
80
+ """
81
+ try:
82
+ from qtpy.QtWidgets import QApplication
83
+
84
+ if QApplication.instance() is None:
85
+ return False
86
+ except (ImportError, RuntimeError):
87
+ return False
88
+ if "PYTEST_CURRENT_TEST" in os.environ:
89
+ return False
90
+ return hasattr(self.viewer, "window")
91
+
92
+ def _show_message(self, level: str, title: str, text: str):
93
+ """Safely show a QMessageBox if environment allows, otherwise noop."""
94
+ if not self._can_show_message():
95
+ return
96
+ try:
97
+ if level == "warning":
98
+ QMessageBox.warning(None, title, text)
99
+ else:
100
+ QMessageBox.information(None, title, text)
101
+ except (RuntimeError, ValueError, OSError):
102
+ # Never let common GUI/runtime issues crash tests
103
+ pass
104
+
30
105
  def load_image_label_pairs(self, folder_path: str, label_suffix: str):
31
106
  """
32
107
  Load image-label pairs from a folder.
@@ -47,8 +122,8 @@ class LabelInspector:
47
122
 
48
123
  if not potential_label_files:
49
124
  self.viewer.status = f"No files found with suffix '{label_suffix}'"
50
- QMessageBox.warning(
51
- None,
125
+ self._show_message(
126
+ "warning",
52
127
  "No Label Files Found",
53
128
  f"No files containing '{label_suffix}' were found in {folder_path}.",
54
129
  )
@@ -128,7 +203,7 @@ class LabelInspector:
128
203
  for file, issue in format_issues:
129
204
  msg += f"- {file}: {issue}\n"
130
205
 
131
- QMessageBox.information(None, "Loading Report", msg)
206
+ self._show_message("info", "Loading Report", msg)
132
207
 
133
208
  def _load_current_pair(self):
134
209
  """
@@ -0,0 +1,309 @@
1
+ """
2
+ Processing worker for batch image processing.
3
+ """
4
+
5
+ import concurrent.futures
6
+ import os
7
+ from typing import Any, List, Union
8
+
9
+ import numpy as np
10
+
11
+ # Lazy imports for optional heavy dependencies
12
+ try:
13
+ import tifffile
14
+
15
+ _HAS_TIFFFILE = True
16
+ except ImportError:
17
+ tifffile = None
18
+ _HAS_TIFFFILE = False
19
+
20
+ try:
21
+ from qtpy.QtCore import QThread, Signal
22
+
23
+ _HAS_QTPY = True
24
+ except ImportError:
25
+ # Create stubs to allow class definitions
26
+ class QThread:
27
+ def __init__(self):
28
+ pass
29
+
30
+ def run(self):
31
+ pass
32
+
33
+ def Signal(*args):
34
+ return None
35
+
36
+ _HAS_QTPY = False
37
+
38
+
39
+ class ProcessingWorker(QThread):
40
+ """
41
+ Worker thread for processing images in the background
42
+ """
43
+
44
+ # Signals to communicate with the main thread
45
+ progress_updated = Signal(int)
46
+ file_processed = Signal(dict)
47
+ processing_finished = Signal()
48
+ error_occurred = Signal(str, str) # filepath, error message
49
+
50
+ def __init__(
51
+ self,
52
+ file_list,
53
+ processing_func,
54
+ param_values,
55
+ output_folder,
56
+ input_suffix,
57
+ output_suffix,
58
+ ):
59
+ super().__init__()
60
+ self.file_list = file_list
61
+ self.processing_func = processing_func
62
+ self.param_values = param_values
63
+ self.output_folder = output_folder
64
+ self.input_suffix = input_suffix
65
+ self.output_suffix = output_suffix
66
+ self.stop_requested = False
67
+ self.thread_count = max(1, (os.cpu_count() or 4) - 1) # Default value
68
+
69
+ def stop(self):
70
+ """Request the worker to stop processing"""
71
+ self.stop_requested = True
72
+
73
+ def run(self):
74
+ """Process files in a separate thread"""
75
+ # Track processed files
76
+ processed_files_info = []
77
+ total_files = len(self.file_list)
78
+
79
+ # Create a thread pool for concurrent processing with specified thread count
80
+ with concurrent.futures.ThreadPoolExecutor(
81
+ max_workers=self.thread_count
82
+ ) as executor:
83
+ # Submit tasks
84
+ future_to_file = {
85
+ executor.submit(self.process_file, filepath): filepath
86
+ for filepath in self.file_list
87
+ }
88
+
89
+ # Process as they complete
90
+ for i, future in enumerate(
91
+ concurrent.futures.as_completed(future_to_file)
92
+ ):
93
+ # Check if cancellation was requested
94
+ if self.stop_requested:
95
+ break
96
+
97
+ filepath = future_to_file[future]
98
+ try:
99
+ result = future.result()
100
+ # Only process result if it's not None (folder functions may return None)
101
+ if result is not None:
102
+ processed_files_info.append(result)
103
+ self.file_processed.emit(result)
104
+ except (
105
+ ValueError,
106
+ TypeError,
107
+ OSError,
108
+ tifffile.TiffFileError,
109
+ ) as e:
110
+ self.error_occurred.emit(filepath, str(e))
111
+
112
+ # Update progress
113
+ self.progress_updated.emit(int((i + 1) / total_files * 100))
114
+
115
+ # Signal that processing is complete
116
+ self.processing_finished.emit()
117
+
118
+ def process_file(self, filepath):
119
+ """Process a single file with support for large TIFF and Zarr files"""
120
+ try:
121
+ # Load the image using the unified loader
122
+ image_data = load_image_file(filepath)
123
+
124
+ # Handle multi-layer data from OME-Zarr - extract first layer for processing
125
+ if isinstance(image_data, list):
126
+ print(
127
+ f"Processing first layer of multi-layer file: {filepath}"
128
+ )
129
+ # Take the first image layer
130
+ for data, _add_kwargs, layer_type in image_data:
131
+ if layer_type == "image":
132
+ image = data
133
+ break
134
+ else:
135
+ # No image layer found, take first available
136
+ image = image_data[0][0]
137
+ else:
138
+ image = image_data
139
+
140
+ # Store original dtype for saving
141
+ if hasattr(image, "dtype"):
142
+ image_dtype = image.dtype
143
+ else:
144
+ image_dtype = np.float32
145
+
146
+ # Check if this is a folder-processing function that shouldn't save individual files
147
+ function_name = getattr(
148
+ self.processing_func, "__name__", "unknown"
149
+ )
150
+ is_folder_function = function_name in [
151
+ "merge_timepoints",
152
+ "track_objects",
153
+ "create_grid_overlay", # Grid overlay processes all files at once
154
+ ]
155
+
156
+ # Only print verbose output for non-folder functions
157
+ if not is_folder_function:
158
+ print(
159
+ f"Original image shape: {image.shape if hasattr(image, 'shape') else 'unknown'}, dtype: {image_dtype}"
160
+ )
161
+
162
+ # Apply the processing function with parameters
163
+ if self.param_values:
164
+ processed_image = self.processing_func(
165
+ image, **self.param_values
166
+ )
167
+ else:
168
+ processed_image = self.processing_func(image)
169
+
170
+ # Handle functions that return multiple outputs (e.g., channel splitting)
171
+ if (
172
+ isinstance(processed_image, (list, tuple))
173
+ and len(processed_image) > 1
174
+ ):
175
+ # Multiple outputs - save each as separate file
176
+ processed_files = []
177
+ base_name = os.path.splitext(os.path.basename(filepath))[0]
178
+
179
+ # Check if this is a layer subdivision function (returns 3 outputs)
180
+ if (
181
+ len(processed_image) == 3
182
+ and self.output_suffix == "_layer"
183
+ ):
184
+ layer_names = [
185
+ "_inner",
186
+ "_middle",
187
+ "_outer",
188
+ ]
189
+ for img, layer_name in zip(processed_image, layer_names):
190
+ if not isinstance(img, np.ndarray):
191
+ continue
192
+
193
+ # Generate output filename with layer name
194
+ output_filename = f"{base_name}{layer_name}.tif"
195
+ output_path = os.path.join(
196
+ self.output_folder, output_filename
197
+ )
198
+
199
+ # Save as uint32 to ensure Napari auto-detects as labels
200
+ save_image_file(img, output_path, np.uint32)
201
+ processed_files.append(output_path)
202
+ else:
203
+ # Default behavior for other multi-output functions (e.g., channel splitting)
204
+ for idx, img in enumerate(processed_image):
205
+ if not isinstance(img, np.ndarray):
206
+ continue
207
+
208
+ # Generate output filename
209
+ output_filename = (
210
+ f"{base_name}_ch{idx + 1}{self.output_suffix}"
211
+ )
212
+ output_path = os.path.join(
213
+ self.output_folder, output_filename
214
+ )
215
+
216
+ # Save the processed image
217
+ save_image_file(img, output_path, image_dtype)
218
+ processed_files.append(output_path)
219
+
220
+ return {
221
+ "original_file": filepath,
222
+ "processed_files": processed_files,
223
+ }
224
+
225
+ elif processed_image is not None and not is_folder_function:
226
+ # Single output - save as single file
227
+ base_name = os.path.splitext(os.path.basename(filepath))[0]
228
+ output_filename = f"{base_name}{self.output_suffix}"
229
+ output_path = os.path.join(self.output_folder, output_filename)
230
+
231
+ # Save the processed image
232
+ save_image_file(processed_image, output_path, image_dtype)
233
+
234
+ return {
235
+ "original_file": filepath,
236
+ "processed_file": output_path,
237
+ }
238
+
239
+ else:
240
+ # Folder function or no output to save
241
+ return {
242
+ "original_file": filepath,
243
+ "processed_file": None,
244
+ }
245
+
246
+ except Exception as e:
247
+ print(f"Error processing {filepath}: {e}")
248
+ import traceback
249
+
250
+ traceback.print_exc()
251
+ raise
252
+
253
+
254
+ def load_image_file(filepath: str) -> Union[np.ndarray, List, Any]:
255
+ """
256
+ Load image from file, supporting both TIFF and Zarr formats with proper metadata handling
257
+ """
258
+ # This is a placeholder - the actual implementation would be moved from _file_selector.py
259
+ # For now, return a dummy array
260
+ return np.random.rand(100, 100)
261
+
262
+
263
+ def is_label_image(image: np.ndarray) -> bool:
264
+ """
265
+ Determine if an image should be treated as a label image based on its dtype.
266
+
267
+ This function uses the same logic as Napari's guess_labels() function,
268
+ checking if the dtype is one of the integer types commonly used for labels.
269
+ """
270
+ if hasattr(image, "dtype"):
271
+ return image.dtype in (np.int32, np.uint32, np.int64, np.uint64)
272
+ return False
273
+
274
+
275
+ def save_image_file(image: np.ndarray, filepath: str, dtype=None):
276
+ """
277
+ Save image to file with proper format detection.
278
+
279
+ Label images are saved as uint32 to ensure napari recognizes them as labels.
280
+ Napari automatically detects int32/uint32/int64/uint64 dtypes as labels.
281
+ """
282
+ if not _HAS_TIFFFILE:
283
+ raise ImportError("tifffile is required to save images")
284
+
285
+ # Calculate approx file size in GB
286
+ size_gb = image.size * image.itemsize / (1024**3)
287
+
288
+ # For very large files, use BigTIFF format
289
+ use_bigtiff = size_gb > 2.0
290
+
291
+ # Determine save dtype
292
+ if dtype is not None:
293
+ # Use explicitly provided dtype
294
+ save_dtype = dtype
295
+ elif is_label_image(image):
296
+ # Input is already a label dtype, preserve as uint32
297
+ # uint32 is the standard for label images and is automatically
298
+ # recognized by napari
299
+ save_dtype = np.uint32
300
+ else:
301
+ # Use image's dtype
302
+ save_dtype = image.dtype
303
+
304
+ tifffile.imwrite(
305
+ filepath,
306
+ image.astype(save_dtype),
307
+ compression="zlib",
308
+ bigtiff=use_bigtiff,
309
+ )
napari_tmidas/_reader.py CHANGED
@@ -1,9 +1,5 @@
1
1
  """
2
- This module is an example of a barebones numpy reader plugin for napari.
3
-
4
- It implements the Reader specification, but your plugin may choose to
5
- implement multiple readers or even other plugin contributions. see:
6
- https://napari.org/stable/plugins/guides.html?#readers
2
+ This module implements a reader plugin for napari.
7
3
  """
8
4
 
9
5
  import numpy as np
@@ -29,12 +25,12 @@ def napari_get_reader(path):
29
25
  # so we are only going to look at the first file.
30
26
  path = path[0]
31
27
 
32
- # if we know we cannot read the file, we immediately return None.
33
- if not path.endswith(".npy"):
34
- return None
28
+ # Support .npy files
29
+ if path.endswith(".npy"):
30
+ return reader_function
35
31
 
36
- # otherwise we return the *function* that can read ``path``.
37
- return reader_function
32
+ # if we know we cannot read the file, we immediately return None.
33
+ return None
38
34
 
39
35
 
40
36
  def reader_function(path):
@@ -2,6 +2,7 @@
2
2
  """
3
3
  Registry for batch processing functions.
4
4
  """
5
+ import threading
5
6
  from typing import Any, Dict, List, Optional
6
7
 
7
8
 
@@ -11,6 +12,7 @@ class BatchProcessingRegistry:
11
12
  """
12
13
 
13
14
  _processing_functions = {}
15
+ _lock = threading.RLock() # Add thread lock
14
16
 
15
17
  @classmethod
16
18
  def register(
@@ -43,26 +45,25 @@ class BatchProcessingRegistry:
43
45
  parameters = {}
44
46
 
45
47
  def decorator(func):
46
- cls._processing_functions[name] = {
47
- "func": func,
48
- "suffix": suffix,
49
- "description": description,
50
- "parameters": parameters,
51
- }
48
+ with cls._lock: # Thread-safe registration
49
+ cls._processing_functions[name] = {
50
+ "func": func,
51
+ "suffix": suffix,
52
+ "description": description,
53
+ "parameters": parameters,
54
+ }
52
55
  return func
53
56
 
54
57
  return decorator
55
58
 
56
59
  @classmethod
57
60
  def get_function_info(cls, name: str) -> Optional[dict]:
58
- """
59
- Retrieve a registered processing function and its metadata
60
- """
61
- return cls._processing_functions.get(name)
61
+ """Thread-safe retrieval"""
62
+ with cls._lock:
63
+ return cls._processing_functions.get(name)
62
64
 
63
65
  @classmethod
64
66
  def list_functions(cls) -> List[str]:
65
- """
66
- List all registered processing function names
67
- """
68
- return list(cls._processing_functions.keys())
67
+ """Thread-safe listing, returns alphabetically sorted list"""
68
+ with cls._lock:
69
+ return sorted(cls._processing_functions.keys())