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.
- napari_tmidas/__init__.py +35 -5
- napari_tmidas/_crop_anything.py +1458 -499
- napari_tmidas/_env_manager.py +76 -0
- napari_tmidas/_file_conversion.py +1646 -1131
- napari_tmidas/_file_selector.py +1464 -223
- napari_tmidas/_label_inspection.py +83 -8
- napari_tmidas/_processing_worker.py +309 -0
- napari_tmidas/_reader.py +6 -10
- napari_tmidas/_registry.py +15 -14
- napari_tmidas/_roi_colocalization.py +1221 -84
- napari_tmidas/_tests/test_crop_anything.py +123 -0
- napari_tmidas/_tests/test_env_manager.py +89 -0
- napari_tmidas/_tests/test_file_selector.py +90 -0
- napari_tmidas/_tests/test_grid_view_overlay.py +193 -0
- napari_tmidas/_tests/test_init.py +98 -0
- napari_tmidas/_tests/test_intensity_label_filter.py +222 -0
- napari_tmidas/_tests/test_label_inspection.py +86 -0
- napari_tmidas/_tests/test_processing_basic.py +500 -0
- napari_tmidas/_tests/test_processing_worker.py +142 -0
- napari_tmidas/_tests/test_regionprops_analysis.py +547 -0
- napari_tmidas/_tests/test_registry.py +135 -0
- napari_tmidas/_tests/test_scipy_filters.py +168 -0
- napari_tmidas/_tests/test_skimage_filters.py +259 -0
- napari_tmidas/_tests/test_split_channels.py +217 -0
- napari_tmidas/_tests/test_spotiflow.py +87 -0
- napari_tmidas/_tests/test_tyx_display_fix.py +142 -0
- napari_tmidas/_tests/test_ui_utils.py +68 -0
- napari_tmidas/_tests/test_widget.py +30 -0
- napari_tmidas/_tests/test_windows_basic.py +66 -0
- napari_tmidas/_ui_utils.py +57 -0
- napari_tmidas/_version.py +16 -3
- napari_tmidas/_widget.py +41 -4
- napari_tmidas/processing_functions/basic.py +557 -20
- napari_tmidas/processing_functions/careamics_env_manager.py +72 -99
- napari_tmidas/processing_functions/cellpose_env_manager.py +415 -112
- napari_tmidas/processing_functions/cellpose_segmentation.py +132 -191
- napari_tmidas/processing_functions/colocalization.py +513 -56
- napari_tmidas/processing_functions/grid_view_overlay.py +703 -0
- napari_tmidas/processing_functions/intensity_label_filter.py +422 -0
- napari_tmidas/processing_functions/regionprops_analysis.py +1280 -0
- napari_tmidas/processing_functions/sam2_env_manager.py +53 -69
- napari_tmidas/processing_functions/sam2_mp4.py +274 -195
- napari_tmidas/processing_functions/scipy_filters.py +403 -8
- napari_tmidas/processing_functions/skimage_filters.py +424 -212
- napari_tmidas/processing_functions/spotiflow_detection.py +949 -0
- napari_tmidas/processing_functions/spotiflow_env_manager.py +591 -0
- napari_tmidas/processing_functions/timepoint_merger.py +334 -86
- napari_tmidas/processing_functions/trackastra_tracking.py +24 -5
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/METADATA +92 -39
- napari_tmidas-0.2.4.dist-info/RECORD +63 -0
- napari_tmidas/_tests/__init__.py +0 -0
- napari_tmidas-0.2.1.dist-info/RECORD +0 -38
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/WHEEL +0 -0
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/entry_points.txt +0 -0
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {napari_tmidas-0.2.1.dist-info → napari_tmidas-0.2.4.dist-info}/top_level.txt +0 -0
|
@@ -4,93 +4,77 @@ processing_functions/sam2_env_manager.py
|
|
|
4
4
|
This module manages a dedicated virtual environment for SAM2.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import os
|
|
8
|
-
import platform
|
|
9
|
-
import shutil
|
|
10
7
|
import subprocess
|
|
11
|
-
import venv
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
9
|
+
from napari_tmidas._env_manager import BaseEnvironmentManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SAM2EnvironmentManager(BaseEnvironmentManager):
|
|
13
|
+
"""Environment manager for SAM2."""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
super().__init__("sam2-env")
|
|
17
|
+
|
|
18
|
+
def _install_dependencies(self, env_python: str) -> None:
|
|
19
|
+
"""Install SAM2-specific dependencies."""
|
|
20
|
+
# Install numpy and torch first for compatibility
|
|
21
|
+
print("Installing torch and torchvision...")
|
|
22
|
+
subprocess.check_call(
|
|
23
|
+
[env_python, "-m", "pip", "install", "torch", "torchvision"]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Install sam2 from GitHub
|
|
27
|
+
print("Installing SAM2 from GitHub...")
|
|
28
|
+
subprocess.check_call(
|
|
29
|
+
[
|
|
30
|
+
env_python,
|
|
31
|
+
"-m",
|
|
32
|
+
"pip",
|
|
33
|
+
"install",
|
|
34
|
+
"git+https://github.com/facebookresearch/sam2.git",
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
subprocess.run(
|
|
39
|
+
[
|
|
40
|
+
env_python,
|
|
41
|
+
"-c",
|
|
42
|
+
"import torch; import torchvision; print('PyTorch version:', torch.__version__); print('Torchvision version:', torchvision.__version__); print('CUDA is available:', torch.cuda.is_available())",
|
|
43
|
+
]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def is_package_installed(self) -> bool:
|
|
47
|
+
"""Check if SAM2 is installed in the current environment."""
|
|
48
|
+
try:
|
|
49
|
+
import importlib.util
|
|
50
|
+
|
|
51
|
+
return importlib.util.find_spec("sam2") is not None
|
|
52
|
+
except ImportError:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Global instance for backward compatibility
|
|
57
|
+
manager = SAM2EnvironmentManager()
|
|
17
58
|
|
|
18
59
|
|
|
19
60
|
def is_sam2_installed():
|
|
20
61
|
"""Check if SAM2 is installed in the current environment."""
|
|
21
|
-
|
|
22
|
-
import importlib.util
|
|
23
|
-
|
|
24
|
-
return importlib.util.find_spec("sam2-env") is not None
|
|
25
|
-
except ImportError:
|
|
26
|
-
return False
|
|
62
|
+
return manager.is_package_installed()
|
|
27
63
|
|
|
28
64
|
|
|
29
65
|
def is_env_created():
|
|
30
66
|
"""Check if the dedicated environment exists."""
|
|
31
|
-
|
|
32
|
-
return os.path.exists(env_python)
|
|
67
|
+
return manager.is_env_created()
|
|
33
68
|
|
|
34
69
|
|
|
35
70
|
def get_env_python_path():
|
|
36
71
|
"""Get the path to the Python executable in the environment."""
|
|
37
|
-
|
|
38
|
-
return os.path.join(ENV_DIR, "Scripts", "python.exe")
|
|
39
|
-
else:
|
|
40
|
-
return os.path.join(ENV_DIR, "bin", "python")
|
|
72
|
+
return manager.get_env_python_path()
|
|
41
73
|
|
|
42
74
|
|
|
43
75
|
def create_sam2_env():
|
|
44
76
|
"""Create a dedicated virtual environment for SAM2."""
|
|
45
|
-
|
|
46
|
-
os.makedirs(os.path.dirname(ENV_DIR), exist_ok=True)
|
|
47
|
-
|
|
48
|
-
# Remove existing environment if it exists
|
|
49
|
-
if os.path.exists(ENV_DIR):
|
|
50
|
-
shutil.rmtree(ENV_DIR)
|
|
51
|
-
|
|
52
|
-
print(f"Creating SAM2 environment at {ENV_DIR}...")
|
|
53
|
-
|
|
54
|
-
# Create a new virtual environment
|
|
55
|
-
venv.create(ENV_DIR, with_pip=True)
|
|
56
|
-
|
|
57
|
-
# Path to the Python executable in the new environment
|
|
58
|
-
env_python = get_env_python_path()
|
|
59
|
-
|
|
60
|
-
# Upgrade pip
|
|
61
|
-
print("Upgrading pip...")
|
|
62
|
-
subprocess.check_call(
|
|
63
|
-
[env_python, "-m", "pip", "install", "--upgrade", "pip"]
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
# Install numpy and torch first for compatibility
|
|
67
|
-
print("Installing torch and torchvision...")
|
|
68
|
-
subprocess.check_call(
|
|
69
|
-
[env_python, "-m", "pip", "install", "torch", "torchvision"]
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
# Install sam2 from GitHub
|
|
73
|
-
print("Installing SAM2 from GitHub...")
|
|
74
|
-
subprocess.check_call(
|
|
75
|
-
[
|
|
76
|
-
env_python,
|
|
77
|
-
"-m",
|
|
78
|
-
"pip",
|
|
79
|
-
"install",
|
|
80
|
-
"git+https://github.com/facebookresearch/sam2.git",
|
|
81
|
-
]
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
subprocess.run(
|
|
85
|
-
[
|
|
86
|
-
env_python,
|
|
87
|
-
"-c",
|
|
88
|
-
"import torch; import torchvision; print('PyTorch version:', torch.__version__); print('Torchvision version:', torchvision.__version__); print('CUDA is available:', torch.cuda.is_available())",
|
|
89
|
-
]
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
print("SAM2 environment created successfully.")
|
|
93
|
-
return env_python
|
|
77
|
+
return manager.create_env()
|
|
94
78
|
|
|
95
79
|
|
|
96
80
|
def run_sam2_in_env(func_name, args_dict):
|
|
@@ -3,14 +3,31 @@ import subprocess
|
|
|
3
3
|
import tempfile
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
-
import cv2
|
|
7
6
|
import numpy as np
|
|
8
|
-
import tifffile
|
|
9
7
|
|
|
8
|
+
# Lazy imports for optional heavy dependencies
|
|
9
|
+
try:
|
|
10
|
+
import cv2
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
_HAS_CV2 = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
cv2 = None
|
|
15
|
+
_HAS_CV2 = False
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import tifffile
|
|
19
|
+
|
|
20
|
+
_HAS_TIFFFILE = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
tifffile = None
|
|
23
|
+
_HAS_TIFFFILE = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def tif_to_mp4(
|
|
27
|
+
input_path, fps=10, cleanup_temp=True, use_ffmpeg=False, crf=17
|
|
28
|
+
):
|
|
12
29
|
"""
|
|
13
|
-
Convert a TIF stack to MP4
|
|
30
|
+
Convert a TIF stack to MP4 with optimized performance.
|
|
14
31
|
|
|
15
32
|
Parameters:
|
|
16
33
|
-----------
|
|
@@ -21,7 +38,15 @@ def tif_to_mp4(input_path, fps=10, cleanup_temp=True):
|
|
|
21
38
|
Frames per second for the video. Default is 10.
|
|
22
39
|
|
|
23
40
|
cleanup_temp : bool, optional
|
|
24
|
-
Whether to clean up temporary
|
|
41
|
+
Whether to clean up temporary files (only used if use_ffmpeg=True). Default is True.
|
|
42
|
+
|
|
43
|
+
use_ffmpeg : bool, optional
|
|
44
|
+
Whether to use FFmpeg for encoding (slower but higher quality).
|
|
45
|
+
If False (default), uses OpenCV VideoWriter which is much faster.
|
|
46
|
+
|
|
47
|
+
crf : int, optional
|
|
48
|
+
Constant Rate Factor for quality (0-51, lower is better). Default is 17.
|
|
49
|
+
Only used when use_ffmpeg=True.
|
|
25
50
|
|
|
26
51
|
Returns:
|
|
27
52
|
--------
|
|
@@ -33,229 +58,285 @@ def tif_to_mp4(input_path, fps=10, cleanup_temp=True):
|
|
|
33
58
|
# Generate output MP4 path in the same folder
|
|
34
59
|
output_path = input_path.with_suffix(".mp4")
|
|
35
60
|
|
|
36
|
-
#
|
|
37
|
-
|
|
61
|
+
# Use fast OpenCV-based encoding by default
|
|
62
|
+
if not use_ffmpeg:
|
|
63
|
+
return _tif_to_mp4_opencv(input_path, output_path, fps)
|
|
64
|
+
|
|
65
|
+
# Otherwise use FFmpeg-based encoding (slower but potentially higher quality)
|
|
66
|
+
return _tif_to_mp4_ffmpeg(input_path, output_path, fps, crf, cleanup_temp)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_tiff_stack(input_path):
|
|
70
|
+
"""
|
|
71
|
+
Load TIFF stack and normalize to uint8 format.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
--------
|
|
75
|
+
tuple
|
|
76
|
+
(frames, is_grayscale) where frames is a numpy array and is_grayscale is a bool
|
|
77
|
+
"""
|
|
78
|
+
print(f"Reading {input_path}...")
|
|
38
79
|
|
|
39
80
|
try:
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if stack.shape[3] == 3: # (T/Z, Y, X, 3) - color
|
|
59
|
-
print(
|
|
60
|
-
f"Detected 4D color stack with shape {stack.shape}"
|
|
61
|
-
)
|
|
62
|
-
frames = stack
|
|
63
|
-
is_grayscale = False
|
|
64
|
-
else:
|
|
65
|
-
# We have a 4D stack (likely T, Z, Y, X)
|
|
66
|
-
print(
|
|
67
|
-
f"Detected 4D stack with shape {stack.shape}. Flattening first two dimensions."
|
|
68
|
-
)
|
|
69
|
-
# Flatten first two dimensions
|
|
70
|
-
t_dim, z_dim = stack.shape[0], stack.shape[1]
|
|
71
|
-
height, width = stack.shape[2], stack.shape[3]
|
|
72
|
-
frames = stack.reshape(
|
|
73
|
-
t_dim * z_dim, height, width
|
|
74
|
-
)
|
|
75
|
-
is_grayscale = True
|
|
76
|
-
else:
|
|
77
|
-
raise ValueError(
|
|
78
|
-
f"Unsupported TIFF shape: {stack.shape}"
|
|
81
|
+
# Try using tifffile which handles scientific imaging formats better
|
|
82
|
+
with tifffile.TiffFile(input_path) as tif:
|
|
83
|
+
# Check if it's a multi-page TIFF (Z stack or time series)
|
|
84
|
+
if len(tif.pages) > 1:
|
|
85
|
+
# Read as a stack - this will handle TYX or ZYX format
|
|
86
|
+
stack = tifffile.imread(input_path)
|
|
87
|
+
print(f"Stack shape: {stack.shape}, dtype: {stack.dtype}")
|
|
88
|
+
|
|
89
|
+
# Check dimensions
|
|
90
|
+
if len(stack.shape) == 3:
|
|
91
|
+
# We have a 3D stack (T/Z, Y, X)
|
|
92
|
+
print(f"Detected 3D stack with shape {stack.shape}")
|
|
93
|
+
frames = stack
|
|
94
|
+
is_grayscale = True
|
|
95
|
+
elif len(stack.shape) == 4:
|
|
96
|
+
if stack.shape[3] == 3: # (T/Z, Y, X, 3) - color
|
|
97
|
+
print(
|
|
98
|
+
f"Detected 4D color stack with shape {stack.shape}"
|
|
79
99
|
)
|
|
80
|
-
|
|
81
|
-
# Single page TIFF
|
|
82
|
-
frame = tifffile.imread(input_path)
|
|
83
|
-
print(f"Detected single frame with shape {frame.shape}")
|
|
84
|
-
if len(frame.shape) == 2: # (Y, X) - grayscale
|
|
85
|
-
frames = np.array([frame])
|
|
86
|
-
is_grayscale = True
|
|
87
|
-
elif (
|
|
88
|
-
len(frame.shape) == 3 and frame.shape[2] == 3
|
|
89
|
-
): # (Y, X, 3) - color
|
|
90
|
-
frames = np.array([frame])
|
|
100
|
+
frames = stack
|
|
91
101
|
is_grayscale = False
|
|
92
102
|
else:
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
# We have a 4D stack (likely T, Z, Y, X)
|
|
104
|
+
print(
|
|
105
|
+
f"Detected 4D stack with shape {stack.shape}. Flattening first two dimensions."
|
|
95
106
|
)
|
|
107
|
+
# Flatten first two dimensions
|
|
108
|
+
t_dim, z_dim = stack.shape[0], stack.shape[1]
|
|
109
|
+
height, width = stack.shape[2], stack.shape[3]
|
|
110
|
+
frames = stack.reshape(t_dim * z_dim, height, width)
|
|
111
|
+
is_grayscale = True
|
|
112
|
+
else:
|
|
113
|
+
raise ValueError(f"Unsupported TIFF shape: {stack.shape}")
|
|
114
|
+
else:
|
|
115
|
+
# Single page TIFF
|
|
116
|
+
frame = tifffile.imread(input_path)
|
|
117
|
+
print(f"Detected single frame with shape {frame.shape}")
|
|
118
|
+
if len(frame.shape) == 2: # (Y, X) - grayscale
|
|
119
|
+
frames = np.array([frame])
|
|
120
|
+
is_grayscale = True
|
|
121
|
+
elif (
|
|
122
|
+
len(frame.shape) == 3 and frame.shape[2] == 3
|
|
123
|
+
): # (Y, X, 3) - color
|
|
124
|
+
frames = np.array([frame])
|
|
125
|
+
is_grayscale = False
|
|
126
|
+
else:
|
|
127
|
+
raise ValueError(f"Unsupported frame shape: {frame.shape}")
|
|
128
|
+
|
|
129
|
+
# Print min/max/mean values to help diagnose
|
|
130
|
+
sample_frame = frames[0]
|
|
131
|
+
print(
|
|
132
|
+
f"Sample frame - min: {np.min(sample_frame)}, max: {np.max(sample_frame)}, "
|
|
133
|
+
f"mean: {np.mean(sample_frame):.2f}, dtype: {sample_frame.dtype}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
except (
|
|
137
|
+
OSError,
|
|
138
|
+
tifffile.TiffFileError,
|
|
139
|
+
ValueError,
|
|
140
|
+
FileNotFoundError,
|
|
141
|
+
MemoryError,
|
|
142
|
+
) as e:
|
|
143
|
+
print(f"Error reading with tifffile: {e}")
|
|
144
|
+
print("Falling back to OpenCV...")
|
|
145
|
+
|
|
146
|
+
# Try with OpenCV as fallback
|
|
147
|
+
cap = cv2.VideoCapture(str(input_path))
|
|
148
|
+
if not cap.isOpened():
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Could not open file {input_path} with either tifffile or OpenCV"
|
|
151
|
+
) from e
|
|
152
|
+
|
|
153
|
+
frames = []
|
|
154
|
+
while True:
|
|
155
|
+
ret, frame = cap.read()
|
|
156
|
+
if not ret:
|
|
157
|
+
break
|
|
158
|
+
frames.append(frame)
|
|
159
|
+
|
|
160
|
+
frames = np.array(frames)
|
|
161
|
+
is_grayscale = len(frames[0].shape) == 2 or frames[0].shape[2] == 1
|
|
162
|
+
cap.release()
|
|
163
|
+
|
|
164
|
+
return frames, is_grayscale
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _normalize_frame_to_uint8(frame):
|
|
168
|
+
"""
|
|
169
|
+
Normalize a frame to uint8 format.
|
|
170
|
+
|
|
171
|
+
Parameters:
|
|
172
|
+
-----------
|
|
173
|
+
frame : numpy.ndarray
|
|
174
|
+
Input frame to normalize
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
--------
|
|
178
|
+
numpy.ndarray
|
|
179
|
+
Normalized uint8 frame
|
|
180
|
+
"""
|
|
181
|
+
if frame.dtype == np.uint8:
|
|
182
|
+
return frame
|
|
183
|
+
|
|
184
|
+
# Get actual data range
|
|
185
|
+
min_val, max_val = np.min(frame), np.max(frame)
|
|
96
186
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
187
|
+
# For float32 and other types, convert directly to uint8
|
|
188
|
+
if np.issubdtype(frame.dtype, np.floating) or min_val < max_val:
|
|
189
|
+
# Scale to full uint8 range [0, 255] with proper handling of min/max
|
|
190
|
+
frame = np.clip(
|
|
191
|
+
(frame - min_val) * 255.0 / (max_val - min_val + 1e-10),
|
|
192
|
+
0,
|
|
193
|
+
255,
|
|
194
|
+
).astype(np.uint8)
|
|
195
|
+
else:
|
|
196
|
+
# If min equals max (constant image), create a mid-gray image
|
|
197
|
+
frame = np.full_like(frame, 128, dtype=np.uint8)
|
|
198
|
+
|
|
199
|
+
return frame
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _tif_to_mp4_opencv(input_path, output_path, fps=10):
|
|
203
|
+
"""
|
|
204
|
+
Fast MP4 conversion using OpenCV VideoWriter.
|
|
205
|
+
This is much faster than the FFmpeg approach as it writes directly to MP4.
|
|
206
|
+
|
|
207
|
+
This method is 10-50x faster than the FFmpeg approach because:
|
|
208
|
+
1. No intermediate files (PNG/JP2) are created
|
|
209
|
+
2. Frames are written directly to the video stream
|
|
210
|
+
3. Uses hardware acceleration when available
|
|
211
|
+
"""
|
|
212
|
+
# Load the TIFF stack
|
|
213
|
+
frames, is_grayscale = _load_tiff_stack(input_path)
|
|
214
|
+
num_frames = len(frames)
|
|
215
|
+
print(f"Processing {num_frames} frames with OpenCV VideoWriter...")
|
|
216
|
+
|
|
217
|
+
# Get frame dimensions
|
|
218
|
+
first_frame = _normalize_frame_to_uint8(frames[0].copy())
|
|
219
|
+
|
|
220
|
+
# Convert grayscale to BGR for OpenCV
|
|
221
|
+
if is_grayscale and len(first_frame.shape) == 2:
|
|
222
|
+
first_frame = cv2.cvtColor(first_frame, cv2.COLOR_GRAY2BGR)
|
|
223
|
+
|
|
224
|
+
height, width = first_frame.shape[:2]
|
|
225
|
+
|
|
226
|
+
# Ensure dimensions are even (required for some codecs)
|
|
227
|
+
if height % 2 != 0:
|
|
228
|
+
height += 1
|
|
229
|
+
if width % 2 != 0:
|
|
230
|
+
width += 1
|
|
231
|
+
|
|
232
|
+
# Define the codec and create VideoWriter object
|
|
233
|
+
# Try different codecs in order of preference
|
|
234
|
+
codecs = [
|
|
235
|
+
("mp4v", ".mp4"), # MPEG-4, widely compatible
|
|
236
|
+
("avc1", ".mp4"), # H.264, better quality
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
writer = None
|
|
240
|
+
for codec, _ext in codecs:
|
|
241
|
+
fourcc = cv2.VideoWriter_fourcc(*codec)
|
|
242
|
+
writer = cv2.VideoWriter(
|
|
243
|
+
str(output_path), fourcc, fps, (width, height)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if writer.isOpened():
|
|
247
|
+
print(f"Using codec: {codec}")
|
|
248
|
+
break
|
|
249
|
+
else:
|
|
250
|
+
writer.release()
|
|
251
|
+
writer = None
|
|
252
|
+
|
|
253
|
+
if writer is None:
|
|
254
|
+
raise RuntimeError(
|
|
255
|
+
"Could not initialize VideoWriter with any supported codec"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
# Process and write frames
|
|
260
|
+
for i in range(num_frames):
|
|
261
|
+
# Get and normalize frame
|
|
262
|
+
frame = frames[i].copy()
|
|
263
|
+
frame = _normalize_frame_to_uint8(frame)
|
|
264
|
+
|
|
265
|
+
# Convert grayscale to BGR if needed
|
|
266
|
+
if is_grayscale and len(frame.shape) == 2:
|
|
267
|
+
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
|
268
|
+
|
|
269
|
+
# Resize if dimensions were adjusted
|
|
270
|
+
if frame.shape[0] != height or frame.shape[1] != width:
|
|
271
|
+
frame = cv2.resize(frame, (width, height))
|
|
272
|
+
|
|
273
|
+
# Write frame
|
|
274
|
+
writer.write(frame)
|
|
275
|
+
|
|
276
|
+
# Report progress
|
|
277
|
+
if (i + 1) % 50 == 0 or i == 0 or i == num_frames - 1:
|
|
278
|
+
print(f"Processed {i+1}/{num_frames} frames")
|
|
279
|
+
|
|
280
|
+
print(f"Successfully created MP4: {output_path}")
|
|
281
|
+
return str(output_path)
|
|
282
|
+
|
|
283
|
+
finally:
|
|
284
|
+
writer.release()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _tif_to_mp4_ffmpeg(
|
|
288
|
+
input_path, output_path, fps=10, crf=17, cleanup_temp=True
|
|
289
|
+
):
|
|
290
|
+
"""
|
|
291
|
+
MP4 conversion using FFmpeg with high quality settings.
|
|
292
|
+
This method is slower but provides more control over encoding parameters.
|
|
293
|
+
"""
|
|
294
|
+
# Create a temporary directory for frame files
|
|
295
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="tif_to_mp4_"))
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
# Load the TIFF stack
|
|
299
|
+
frames, is_grayscale = _load_tiff_stack(input_path)
|
|
133
300
|
num_frames = len(frames)
|
|
134
|
-
print(f"Processing {num_frames} frames...")
|
|
301
|
+
print(f"Processing {num_frames} frames with FFmpeg...")
|
|
135
302
|
|
|
136
303
|
# Check if ffmpeg is available
|
|
137
304
|
if not shutil.which("ffmpeg"):
|
|
138
305
|
raise RuntimeError("FFmpeg is required but was not found.")
|
|
139
306
|
|
|
140
|
-
# Process each frame and save as
|
|
141
|
-
jp2_paths = []
|
|
142
|
-
|
|
307
|
+
# Process each frame and save as PNG (simpler and faster than JP2)
|
|
143
308
|
for i in range(num_frames):
|
|
144
|
-
# Get
|
|
309
|
+
# Get and normalize frame
|
|
145
310
|
frame = frames[i].copy()
|
|
146
|
-
|
|
147
|
-
# For analysis and debugging
|
|
148
|
-
if i == 0 or i == num_frames - 1:
|
|
149
|
-
print(f"Frame {i} shape: {frame.shape}, dtype: {frame.dtype}")
|
|
150
|
-
print(
|
|
151
|
-
f"Frame {i} stats - min: {np.min(frame)}, max: {np.max(frame)}, mean: {np.mean(frame):.2f}"
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
# Improved handling for float32 and other types - prioritize conversion to uint8
|
|
155
|
-
if frame.dtype != np.uint8:
|
|
156
|
-
# Get actual data range
|
|
157
|
-
min_val, max_val = np.min(frame), np.max(frame)
|
|
158
|
-
|
|
159
|
-
# For float32 and other types, convert directly to uint8
|
|
160
|
-
if (
|
|
161
|
-
np.issubdtype(frame.dtype, np.floating)
|
|
162
|
-
or min_val < max_val
|
|
163
|
-
):
|
|
164
|
-
# Scale to full uint8 range [0, 255] with proper handling of min/max
|
|
165
|
-
frame = np.clip(
|
|
166
|
-
(frame - min_val)
|
|
167
|
-
* 255.0
|
|
168
|
-
/ (max_val - min_val + 1e-10),
|
|
169
|
-
0,
|
|
170
|
-
255,
|
|
171
|
-
).astype(np.uint8)
|
|
172
|
-
else:
|
|
173
|
-
# If min equals max (constant image), create a mid-gray image
|
|
174
|
-
frame = np.full_like(frame, 128, dtype=np.uint8)
|
|
175
|
-
|
|
176
|
-
# Report conversion stats for debugging
|
|
177
|
-
if i == 0 or i == num_frames - 1:
|
|
178
|
-
print(
|
|
179
|
-
f"After conversion - min: {np.min(frame)}, max: {np.max(frame)}, "
|
|
180
|
-
f"mean: {np.mean(frame):.2f}, dtype: {frame.dtype}"
|
|
181
|
-
)
|
|
311
|
+
frame = _normalize_frame_to_uint8(frame)
|
|
182
312
|
|
|
183
313
|
# Convert grayscale to RGB if needed for compatibility
|
|
184
314
|
if is_grayscale and len(frame.shape) == 2:
|
|
185
|
-
|
|
186
|
-
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB)
|
|
187
|
-
else:
|
|
188
|
-
rgb_frame = frame
|
|
315
|
+
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
|
189
316
|
|
|
190
|
-
# Save frame as
|
|
317
|
+
# Save frame as PNG
|
|
191
318
|
png_path = temp_dir / f"frame_{i:06d}.png"
|
|
192
|
-
cv2.imwrite(str(png_path),
|
|
193
|
-
|
|
194
|
-
# Use FFmpeg to convert PNG to lossless JPEG2000
|
|
195
|
-
jp2_path = temp_dir / f"frame_{i:06d}.jp2"
|
|
196
|
-
jp2_paths.append(jp2_path)
|
|
197
|
-
|
|
198
|
-
# FFmpeg command for lossless JP2 conversion
|
|
199
|
-
cmd = [
|
|
200
|
-
"ffmpeg",
|
|
201
|
-
"-y",
|
|
202
|
-
"-i",
|
|
203
|
-
str(png_path),
|
|
204
|
-
"-codec",
|
|
205
|
-
"jpeg2000",
|
|
206
|
-
"-vf",
|
|
207
|
-
"pad=ceil(iw/2)*2:ceil(ih/2)*2", # width and height are required to be even numbers
|
|
208
|
-
"-pix_fmt",
|
|
209
|
-
(
|
|
210
|
-
"rgb24"
|
|
211
|
-
if not is_grayscale or len(rgb_frame.shape) == 3
|
|
212
|
-
else "gray"
|
|
213
|
-
),
|
|
214
|
-
"-compression_level",
|
|
215
|
-
"0", # Lossless setting
|
|
216
|
-
str(jp2_path),
|
|
217
|
-
]
|
|
218
|
-
|
|
219
|
-
try:
|
|
220
|
-
subprocess.run(
|
|
221
|
-
cmd,
|
|
222
|
-
check=True,
|
|
223
|
-
capture_output=True,
|
|
224
|
-
)
|
|
225
|
-
except subprocess.CalledProcessError as e:
|
|
226
|
-
print(
|
|
227
|
-
f"FFmpeg JP2 encoding error: {e.stderr.decode() if e.stderr else 'Unknown error'}"
|
|
228
|
-
)
|
|
229
|
-
# Fallback to PNG if JP2 encoding fails
|
|
230
|
-
print(f"Falling back to PNG for frame {i}")
|
|
231
|
-
jp2_paths[-1] = png_path
|
|
232
|
-
|
|
233
|
-
# Delete the PNG file if JP2 was successful and not the same as fallback
|
|
234
|
-
if jp2_paths[-1] != png_path and png_path.exists():
|
|
235
|
-
png_path.unlink()
|
|
319
|
+
cv2.imwrite(str(png_path), frame)
|
|
236
320
|
|
|
237
321
|
# Report progress
|
|
238
322
|
if (i + 1) % 50 == 0 or i == 0 or i == num_frames - 1:
|
|
239
|
-
print(f"
|
|
323
|
+
print(f"Saved {i+1}/{num_frames} frames")
|
|
240
324
|
|
|
241
|
-
# Use FFmpeg to create MP4 from
|
|
242
|
-
print(f"Creating MP4 file from {
|
|
243
|
-
|
|
244
|
-
# Get the extension of the first frame to determine input pattern
|
|
245
|
-
ext = jp2_paths[0].suffix
|
|
325
|
+
# Use FFmpeg to create MP4 from PNG frames
|
|
326
|
+
print(f"Creating MP4 file from {num_frames} frames...")
|
|
246
327
|
|
|
247
328
|
cmd = [
|
|
248
329
|
"ffmpeg",
|
|
249
330
|
"-framerate",
|
|
250
331
|
str(fps),
|
|
251
332
|
"-i",
|
|
252
|
-
str(temp_dir /
|
|
333
|
+
str(temp_dir / "frame_%06d.png"),
|
|
253
334
|
"-c:v",
|
|
254
335
|
"libx264",
|
|
255
336
|
"-profile:v",
|
|
256
337
|
"high",
|
|
257
338
|
"-crf",
|
|
258
|
-
|
|
339
|
+
str(crf),
|
|
259
340
|
"-pix_fmt",
|
|
260
341
|
"yuv420p", # Compatible colorspace
|
|
261
342
|
"-y",
|
|
@@ -279,5 +360,3 @@ def tif_to_mp4(input_path, fps=10, cleanup_temp=True):
|
|
|
279
360
|
shutil.rmtree(temp_dir)
|
|
280
361
|
else:
|
|
281
362
|
print(f"Temporary files saved in: {temp_dir}")
|
|
282
|
-
|
|
283
|
-
return str(output_path)
|