napari-tmidas 0.2.2__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 (54) hide show
  1. napari_tmidas/__init__.py +35 -5
  2. napari_tmidas/_crop_anything.py +1520 -609
  3. napari_tmidas/_env_manager.py +76 -0
  4. napari_tmidas/_file_conversion.py +1646 -1131
  5. napari_tmidas/_file_selector.py +1455 -216
  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 +2 -2
  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_grid_view_overlay.py +193 -0
  14. napari_tmidas/_tests/test_init.py +98 -0
  15. napari_tmidas/_tests/test_intensity_label_filter.py +222 -0
  16. napari_tmidas/_tests/test_label_inspection.py +86 -0
  17. napari_tmidas/_tests/test_processing_basic.py +500 -0
  18. napari_tmidas/_tests/test_processing_worker.py +142 -0
  19. napari_tmidas/_tests/test_regionprops_analysis.py +547 -0
  20. napari_tmidas/_tests/test_registry.py +70 -2
  21. napari_tmidas/_tests/test_scipy_filters.py +168 -0
  22. napari_tmidas/_tests/test_skimage_filters.py +259 -0
  23. napari_tmidas/_tests/test_split_channels.py +217 -0
  24. napari_tmidas/_tests/test_spotiflow.py +87 -0
  25. napari_tmidas/_tests/test_tyx_display_fix.py +142 -0
  26. napari_tmidas/_tests/test_ui_utils.py +68 -0
  27. napari_tmidas/_tests/test_widget.py +30 -0
  28. napari_tmidas/_tests/test_windows_basic.py +66 -0
  29. napari_tmidas/_ui_utils.py +57 -0
  30. napari_tmidas/_version.py +16 -3
  31. napari_tmidas/_widget.py +41 -4
  32. napari_tmidas/processing_functions/basic.py +557 -20
  33. napari_tmidas/processing_functions/careamics_env_manager.py +72 -99
  34. napari_tmidas/processing_functions/cellpose_env_manager.py +415 -112
  35. napari_tmidas/processing_functions/cellpose_segmentation.py +132 -191
  36. napari_tmidas/processing_functions/colocalization.py +513 -56
  37. napari_tmidas/processing_functions/grid_view_overlay.py +703 -0
  38. napari_tmidas/processing_functions/intensity_label_filter.py +422 -0
  39. napari_tmidas/processing_functions/regionprops_analysis.py +1280 -0
  40. napari_tmidas/processing_functions/sam2_env_manager.py +53 -69
  41. napari_tmidas/processing_functions/sam2_mp4.py +274 -195
  42. napari_tmidas/processing_functions/scipy_filters.py +403 -8
  43. napari_tmidas/processing_functions/skimage_filters.py +424 -212
  44. napari_tmidas/processing_functions/spotiflow_detection.py +949 -0
  45. napari_tmidas/processing_functions/spotiflow_env_manager.py +591 -0
  46. napari_tmidas/processing_functions/timepoint_merger.py +334 -86
  47. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/METADATA +70 -30
  48. napari_tmidas-0.2.4.dist-info/RECORD +63 -0
  49. napari_tmidas/_tests/__init__.py +0 -0
  50. napari_tmidas-0.2.2.dist-info/RECORD +0 -40
  51. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/WHEEL +0 -0
  52. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/entry_points.txt +0 -0
  53. {napari_tmidas-0.2.2.dist-info → napari_tmidas-0.2.4.dist-info}/licenses/LICENSE +0 -0
  54. {napari_tmidas-0.2.2.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
- # Define the environment directory in user's home folder
14
- ENV_DIR = os.path.join(
15
- os.path.expanduser("~"), ".napari-tmidas", "envs", "sam2-env"
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
- try:
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
- env_python = get_env_python_path()
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
- if platform.system() == "Windows":
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
- # Ensure the environment directory exists
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
- def tif_to_mp4(input_path, fps=10, cleanup_temp=True):
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 using JPEG2000 lossless as an intermediate format.
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 JP2 files. Default is True.
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
- # Create a temporary directory for JP2 files
37
- temp_dir = Path(tempfile.mkdtemp(prefix="tif_to_jp2_"))
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
- # Read the TIFF file
41
- print(f"Reading {input_path}...")
42
- try:
43
- # Try using tifffile which handles scientific imaging formats better
44
- with tifffile.TiffFile(input_path) as tif:
45
- # Check if it's a multi-page TIFF (Z stack or time series)
46
- if len(tif.pages) > 1:
47
- # Read as a stack - this will handle TYX or ZYX format
48
- stack = tifffile.imread(input_path)
49
- print(f"Stack shape: {stack.shape}, dtype: {stack.dtype}")
50
-
51
- # Check dimensions
52
- if len(stack.shape) == 3:
53
- # We have a 3D stack (T/Z, Y, X)
54
- print(f"Detected 3D stack with shape {stack.shape}")
55
- frames = stack
56
- is_grayscale = True
57
- elif len(stack.shape) == 4:
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
- else:
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
- raise ValueError(
94
- f"Unsupported frame shape: {frame.shape}"
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
- # Print min/max/mean values to help diagnose
98
- sample_frame = frames[0]
99
- print(
100
- f"Sample frame - min: {np.min(sample_frame)}, max: {np.max(sample_frame)}, "
101
- f"mean: {np.mean(sample_frame):.2f}, dtype: {sample_frame.dtype}"
102
- )
103
-
104
- except (
105
- OSError,
106
- tifffile.TiffFileError,
107
- ValueError,
108
- FileNotFoundError,
109
- MemoryError,
110
- ) as e:
111
- print(f"Error reading with tifffile: {e}")
112
- print("Falling back to OpenCV...")
113
-
114
- # Try with OpenCV as fallback
115
- cap = cv2.VideoCapture(str(input_path))
116
- if not cap.isOpened():
117
- raise ValueError(
118
- f"Could not open file {input_path} with either tifffile or OpenCV"
119
- ) from e
120
-
121
- frames = []
122
- while True:
123
- ret, frame = cap.read()
124
- if not ret:
125
- break
126
- frames.append(frame)
127
-
128
- frames = np.array(frames)
129
- is_grayscale = len(frames[0].shape) == 2 or frames[0].shape[2] == 1
130
- cap.release()
131
-
132
- # Get the number of frames
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 lossless JP2
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 the frame
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
- # For uint8, we can use cv2.cvtColor
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 intermediate PNG
317
+ # Save frame as PNG
191
318
  png_path = temp_dir / f"frame_{i:06d}.png"
192
- cv2.imwrite(str(png_path), rgb_frame)
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"Processed {i+1}/{num_frames} frames")
323
+ print(f"Saved {i+1}/{num_frames} frames")
240
324
 
241
- # Use FFmpeg to create MP4 from JP2/PNG frames
242
- print(f"Creating MP4 file from {len(jp2_paths)} frames...")
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 / f"frame_%06d{ext}"),
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
- "17", # High quality
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)