napari-tmidas 0.2.0__py3-none-any.whl → 0.2.2__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.
@@ -558,6 +558,10 @@ class ProcessingWorker(QThread):
558
558
  self.stop_requested = False
559
559
  self.thread_count = max(1, (os.cpu_count() or 4) - 1) # Default value
560
560
 
561
+ def stop(self):
562
+ """Request the worker to stop processing"""
563
+ self.stop_requested = True
564
+
561
565
  def run(self):
562
566
  """Process files in a separate thread"""
563
567
  # Track processed files
@@ -585,7 +589,8 @@ class ProcessingWorker(QThread):
585
589
  filepath = future_to_file[future]
586
590
  try:
587
591
  result = future.result()
588
- if result:
592
+ # Only process result if it's not None (folder functions may return None)
593
+ if result is not None:
589
594
  processed_files_info.append(result)
590
595
  self.file_processed.emit(result)
591
596
  except (
@@ -611,6 +616,16 @@ class ProcessingWorker(QThread):
611
616
 
612
617
  print(f"Original image shape: {image.shape}, dtype: {image_dtype}")
613
618
 
619
+ # Check if this is a folder-processing function that shouldn't save individual files
620
+ function_name = getattr(
621
+ self.processing_func, "__name__", "unknown"
622
+ )
623
+ is_folder_function = (
624
+ "timepoint" in function_name.lower()
625
+ or "merge" in function_name.lower()
626
+ or "folder" in function_name.lower()
627
+ )
628
+
614
629
  # Apply processing with parameters
615
630
  processed_image = self.processing_func(image, **self.param_values)
616
631
 
@@ -618,6 +633,19 @@ class ProcessingWorker(QThread):
618
633
  f"Processed image shape before removing singletons: {processed_image.shape}, dtype: {processed_image.dtype}"
619
634
  )
620
635
 
636
+ # For folder functions, check if the output is the same as input (indicating no individual file should be saved)
637
+ if is_folder_function:
638
+ # If the function returns the original image unchanged, it means it handled saving internally
639
+ if np.array_equal(processed_image, image):
640
+ print(
641
+ "Folder function returned unchanged image - skipping individual file save"
642
+ )
643
+ return None # Return None to indicate no file should be created
644
+ else:
645
+ print(
646
+ "Folder function returned different data - will save individual file"
647
+ )
648
+
621
649
  # Remove ALL singleton dimensions from the processed image
622
650
  # This will keep only dimensions with size > 1
623
651
  processed_image = np.squeeze(processed_image)
@@ -629,9 +657,12 @@ class ProcessingWorker(QThread):
629
657
  # Generate new filename base
630
658
  filename = os.path.basename(filepath)
631
659
  name, ext = os.path.splitext(filename)
632
- new_filename_base = (
633
- name.replace(self.input_suffix, "") + self.output_suffix
634
- )
660
+ if name.endswith(self.input_suffix):
661
+ new_filename_base = (
662
+ name[: -len(self.input_suffix)] + self.output_suffix
663
+ )
664
+ else:
665
+ new_filename_base = name + self.output_suffix
635
666
 
636
667
  # Check if the first dimension should be treated as channels
637
668
  # If processed_image has more dimensions than the original image,
@@ -755,13 +786,8 @@ class ProcessingWorker(QThread):
755
786
  "labels" in new_filename_base
756
787
  or "semantic" in new_filename_base
757
788
  ):
758
- # Choose appropriate integer type based on data range
759
- if data_max <= 255:
760
- save_dtype = np.uint8
761
- elif data_max <= 65535:
762
- save_dtype = np.uint16
763
- else:
764
- save_dtype = np.uint32
789
+
790
+ save_dtype = np.uint32
765
791
 
766
792
  print(
767
793
  f"Saving label image as {save_dtype.__name__} with bigtiff={use_bigtiff}"
@@ -939,9 +965,49 @@ class FileResultsWidget(QWidget):
939
965
 
940
966
  # Update description
941
967
  description = function_info.get("description", "")
942
- self.function_description.setText(description)
943
968
 
944
- # Update parameters
969
+ # Check if this is a folder-processing function that needs single threading
970
+ is_folder_function = (
971
+ "folder" in function_name.lower()
972
+ or "timepoint" in function_name.lower()
973
+ or "merge" in function_name.lower()
974
+ or "folder" in description.lower()
975
+ or "cellpose" in description.lower()
976
+ or "careamics" in description.lower()
977
+ or "trackastra" in description.lower()
978
+ )
979
+
980
+ # Disable threading controls for folder functions
981
+ if is_folder_function:
982
+ self.thread_count.setValue(1)
983
+ self.thread_count.setEnabled(False)
984
+ self.thread_count.setToolTip(
985
+ "This function processes entire folders and must run with 1 thread only."
986
+ )
987
+
988
+ # Add warning to description if not already present
989
+ if (
990
+ "IMPORTANT:" not in description
991
+ and "WARNING:" not in description
992
+ ):
993
+ description += "\nThis function has to run single-threaded."
994
+
995
+ self.function_description.setText(description)
996
+
997
+ # Change the description color to make it more prominent
998
+ self.function_description.setStyleSheet(
999
+ "QLabel { color: #ff6b00; font-weight: bold; }"
1000
+ )
1001
+ else:
1002
+ # Re-enable threading controls for normal functions
1003
+ self.thread_count.setEnabled(True)
1004
+ self.thread_count.setToolTip(
1005
+ "Number of threads to use for parallel processing"
1006
+ )
1007
+ self.function_description.setStyleSheet("") # Reset styling
1008
+ self.function_description.setText(description)
1009
+
1010
+ # Get parameters
945
1011
  parameters = function_info.get("parameters", {})
946
1012
 
947
1013
  # Remove old parameters widget if it exists
@@ -1014,6 +1080,23 @@ class FileResultsWidget(QWidget):
1014
1080
  self.batch_button.setEnabled(False)
1015
1081
  self.cancel_button.setEnabled(True)
1016
1082
 
1083
+ # Set thread count based on function properties
1084
+ worker_thread_count = self.thread_count.value()
1085
+
1086
+ # Check if function should run single-threaded
1087
+ if (
1088
+ hasattr(processing_func, "thread_safe")
1089
+ and not processing_func.thread_safe
1090
+ ):
1091
+ worker_thread_count = 1
1092
+ self.viewer.status = (
1093
+ "Processing with a single thread (function is not thread-safe)"
1094
+ )
1095
+ else:
1096
+ self.viewer.status = (
1097
+ f"Processing with {worker_thread_count} threads"
1098
+ )
1099
+
1017
1100
  # Create and start the worker thread
1018
1101
  self.worker = ProcessingWorker(
1019
1102
  self.file_list,
@@ -1024,8 +1107,8 @@ class FileResultsWidget(QWidget):
1024
1107
  output_suffix,
1025
1108
  )
1026
1109
 
1027
- # Set the thread count from the UI
1028
- self.worker.thread_count = self.thread_count.value()
1110
+ # Set the thread count from the UI or function attribute
1111
+ self.worker.thread_count = worker_thread_count
1029
1112
 
1030
1113
  # Connect signals
1031
1114
  self.worker.progress_updated.connect(self.update_progress)
@@ -1037,7 +1120,7 @@ class FileResultsWidget(QWidget):
1037
1120
  self.worker.start()
1038
1121
 
1039
1122
  # Update status
1040
- self.viewer.status = f"Processing {len(self.file_list)} files with {selected_function_name} using {self.thread_count.value()} threads"
1123
+ self.viewer.status = f"Processing {len(self.file_list)} files with {selected_function_name} using {worker_thread_count} threads"
1041
1124
 
1042
1125
  def update_progress(self, value):
1043
1126
  """Update the progress bar"""
@@ -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"""
68
+ with cls._lock:
69
+ return list(cls._processing_functions.keys())
@@ -0,0 +1,90 @@
1
+ # src/napari_tmidas/_tests/test_file_selector.py
2
+ import os
3
+ import tempfile
4
+ from unittest.mock import Mock
5
+
6
+ import numpy as np
7
+
8
+ from napari_tmidas._file_selector import ProcessingWorker, file_selector
9
+ from napari_tmidas._registry import BatchProcessingRegistry
10
+
11
+
12
+ class TestProcessingWorker:
13
+ def setup_method(self):
14
+ """Setup test environment"""
15
+ self.temp_dir = tempfile.mkdtemp()
16
+ BatchProcessingRegistry._processing_functions.clear()
17
+
18
+ # Register a test function
19
+ @BatchProcessingRegistry.register(name="Test Process", suffix="_proc")
20
+ def test_process(image):
21
+ return image * 2
22
+
23
+ self.test_func = BatchProcessingRegistry.get_function_info(
24
+ "Test Process"
25
+ )["func"]
26
+
27
+ def teardown_method(self):
28
+ """Cleanup"""
29
+ import shutil
30
+
31
+ shutil.rmtree(self.temp_dir)
32
+
33
+ def test_process_file(self):
34
+ """Test processing a single file"""
35
+ # Create test image
36
+ test_image = np.random.rand(100, 100)
37
+ input_path = os.path.join(self.temp_dir, "test.tif")
38
+
39
+ import tifffile
40
+
41
+ tifffile.imwrite(input_path, test_image)
42
+
43
+ # Create worker
44
+ worker = ProcessingWorker(
45
+ [input_path], self.test_func, {}, self.temp_dir, "", "_proc"
46
+ )
47
+
48
+ # Process file
49
+ result = worker.process_file(input_path)
50
+
51
+ assert result is not None
52
+ assert "original_file" in result
53
+ assert "processed_file" in result
54
+ assert os.path.exists(result["processed_file"])
55
+
56
+ def test_multi_channel_output(self):
57
+ """Test processing that outputs multiple channels"""
58
+
59
+ @BatchProcessingRegistry.register(
60
+ name="Split Channels", suffix="_split"
61
+ )
62
+ def split_channels(image):
63
+ return np.stack([image, image * 2, image * 3])
64
+
65
+ test_image = np.random.rand(100, 100)
66
+ input_path = os.path.join(self.temp_dir, "test.tif")
67
+
68
+ import tifffile
69
+
70
+ tifffile.imwrite(input_path, test_image)
71
+
72
+ func_info = BatchProcessingRegistry.get_function_info("Split Channels")
73
+ worker = ProcessingWorker(
74
+ [input_path], func_info["func"], {}, self.temp_dir, "", "_split"
75
+ )
76
+
77
+ result = worker.process_file(input_path)
78
+
79
+ assert "processed_files" in result
80
+ assert len(result["processed_files"]) == 3
81
+
82
+
83
+ class TestFileSelector:
84
+ def test_file_selector_widget_creation(self):
85
+ """Test that file selector widget is created properly"""
86
+ viewer_mock = Mock()
87
+
88
+ # Test the widget can be called
89
+ result = file_selector(viewer_mock, "/tmp", ".tif")
90
+ assert isinstance(result, list)
@@ -0,0 +1,67 @@
1
+ # src/napari_tmidas/_tests/test_registry.py
2
+ from napari_tmidas._registry import BatchProcessingRegistry
3
+
4
+
5
+ class TestBatchProcessingRegistry:
6
+ def setup_method(self):
7
+ """Clear registry before each test"""
8
+ BatchProcessingRegistry._processing_functions.clear()
9
+
10
+ def test_register_function(self):
11
+ """Test registering a processing function"""
12
+
13
+ @BatchProcessingRegistry.register(
14
+ name="Test Function",
15
+ suffix="_test",
16
+ description="Test description",
17
+ parameters={"param1": {"type": int, "default": 5}},
18
+ )
19
+ def test_func(image, param1=5):
20
+ return image + param1
21
+
22
+ assert "Test Function" in BatchProcessingRegistry.list_functions()
23
+ info = BatchProcessingRegistry.get_function_info("Test Function")
24
+ assert info["suffix"] == "_test"
25
+ assert info["description"] == "Test description"
26
+ assert info["func"] == test_func
27
+
28
+ def test_list_functions(self):
29
+ """Test listing registered functions"""
30
+
31
+ @BatchProcessingRegistry.register(name="Func1")
32
+ def func1(image):
33
+ return image
34
+
35
+ @BatchProcessingRegistry.register(name="Func2")
36
+ def func2(image):
37
+ return image
38
+
39
+ functions = BatchProcessingRegistry.list_functions()
40
+ assert len(functions) == 2
41
+ assert "Func1" in functions
42
+ assert "Func2" in functions
43
+
44
+ def test_thread_safety(self):
45
+ """Test thread-safe registration"""
46
+ import threading
47
+
48
+ results = []
49
+
50
+ def register_func(i):
51
+ @BatchProcessingRegistry.register(name=f"ThreadFunc{i}")
52
+ def func(image):
53
+ return image
54
+
55
+ results.append(i)
56
+
57
+ threads = [
58
+ threading.Thread(target=register_func, args=(i,))
59
+ for i in range(10)
60
+ ]
61
+ for t in threads:
62
+ t.start()
63
+ for t in threads:
64
+ t.join()
65
+
66
+ assert len(results) == 10
67
+ assert len(BatchProcessingRegistry.list_functions()) == 10
napari_tmidas/_version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.2.0'
21
- __version_tuple__ = version_tuple = (0, 2, 0)
20
+ __version__ = version = '0.2.2'
21
+ __version_tuple__ = version_tuple = (0, 2, 2)