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.
- napari_tmidas/_crop_anything.py +1942 -607
- napari_tmidas/_file_selector.py +99 -16
- napari_tmidas/_registry.py +15 -14
- napari_tmidas/_tests/test_file_selector.py +90 -0
- napari_tmidas/_tests/test_registry.py +67 -0
- napari_tmidas/_version.py +2 -2
- napari_tmidas/processing_functions/basic.py +494 -23
- napari_tmidas/processing_functions/careamics_denoising.py +324 -0
- napari_tmidas/processing_functions/careamics_env_manager.py +339 -0
- napari_tmidas/processing_functions/cellpose_env_manager.py +55 -20
- napari_tmidas/processing_functions/cellpose_segmentation.py +105 -218
- napari_tmidas/processing_functions/sam2_mp4.py +283 -0
- napari_tmidas/processing_functions/skimage_filters.py +31 -1
- napari_tmidas/processing_functions/timepoint_merger.py +490 -0
- napari_tmidas/processing_functions/trackastra_tracking.py +322 -0
- {napari_tmidas-0.2.0.dist-info → napari_tmidas-0.2.2.dist-info}/METADATA +37 -17
- {napari_tmidas-0.2.0.dist-info → napari_tmidas-0.2.2.dist-info}/RECORD +21 -14
- {napari_tmidas-0.2.0.dist-info → napari_tmidas-0.2.2.dist-info}/WHEEL +1 -1
- {napari_tmidas-0.2.0.dist-info → napari_tmidas-0.2.2.dist-info}/entry_points.txt +0 -0
- {napari_tmidas-0.2.0.dist-info → napari_tmidas-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {napari_tmidas-0.2.0.dist-info → napari_tmidas-0.2.2.dist-info}/top_level.txt +0 -0
napari_tmidas/_file_selector.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
633
|
-
|
|
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
|
-
|
|
759
|
-
|
|
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
|
-
#
|
|
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 =
|
|
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 {
|
|
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"""
|
napari_tmidas/_registry.py
CHANGED
|
@@ -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.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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