Coverage for openhcs/processing/backends/analysis/multi_template_matching.py: 9.8%
264 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
1"""
2Multi-Template Matching functions for OpenHCS.
4This module provides template matching capabilities using the Multi-Template-Matching library
5to detect and crop regions of interest in image stacks.
6"""
8import numpy as np
9import cv2
10from typing import Tuple, List, Dict, Any, Optional
11from dataclasses import dataclass
12import logging
13import pandas as pd
14from openhcs.constants.constants import Backend
15from pathlib import Path
17try:
18 import MTM
19except ImportError:
20 MTM = None
21 logging.warning("MTM (Multi-Template-Matching) not available. Install with: pip install Multi-Template-Matching")
23from openhcs.core.memory.decorators import numpy as numpy_func
24from openhcs.core.pipeline.function_contracts import special_outputs
26@dataclass
27class TemplateMatchResult:
28 """Results for a single slice template matching operation."""
29 slice_index: int
30 matches: List[Dict[str, Any]] # List of hits from MTM.matchTemplates
31 best_match: Optional[Dict[str, Any]] # Best scoring match
32 crop_bbox: Optional[Tuple[int, int, int, int]] # (x, y, width, height) if cropped
33 match_score: float
34 num_matches: int
35 best_rotation_angle: float # Angle of best matching template
36 error_message: Optional[str] = None
38def materialize_mtm_match_results(data: List[TemplateMatchResult], path: str, filemanager, backend: str) -> str:
39 """Materialize MTM match results as analysis-ready CSV with confidence analysis."""
40 csv_path = path.replace('.pkl', '_mtm_matches.csv')
42 rows = []
43 for result in data:
44 slice_idx = result.slice_index
46 # Process all matches for this slice
47 # MTM hits format: [label, bbox, score] where bbox is (x, y, width, height)
48 for i, match in enumerate(result.matches):
49 if len(match) >= 3:
50 template_label, bbox, score = match[0], match[1], match[2]
51 x, y, w, h = bbox if len(bbox) >= 4 else (0, 0, 0, 0)
53 rows.append({
54 'slice_index': slice_idx,
55 'match_id': f"slice_{slice_idx}_match_{i}",
56 'bbox_x': x,
57 'bbox_y': y,
58 'bbox_width': w,
59 'bbox_height': h,
60 'confidence_score': score,
61 'template_name': template_label,
62 'is_best_match': (match == result.best_match),
63 'was_cropped': result.crop_bbox is not None
64 })
65 else:
66 # Handle malformed match data
67 rows.append({
68 'slice_index': slice_idx,
69 'match_id': f"slice_{slice_idx}_match_{i}",
70 'bbox_x': 0,
71 'bbox_y': 0,
72 'bbox_width': 0,
73 'bbox_height': 0,
74 'confidence_score': 0.0,
75 'template_name': 'malformed_match',
76 'is_best_match': False,
77 'was_cropped': result.crop_bbox is not None
78 })
80 if rows:
81 df = pd.DataFrame(rows)
83 # Add analysis columns
84 if len(df) > 0 and 'confidence_score' in df.columns:
85 df['high_confidence'] = df['confidence_score'] > 0.8
87 # Only create quartiles if we have enough unique values
88 unique_scores = df['confidence_score'].nunique()
89 if unique_scores >= 4:
90 try:
91 df['confidence_quartile'] = pd.qcut(df['confidence_score'], 4, labels=['Q1', 'Q2', 'Q3', 'Q4'], duplicates='drop')
92 except ValueError:
93 # Fallback to simple binning if qcut fails
94 df['confidence_quartile'] = pd.cut(df['confidence_score'], 4, labels=['Q1', 'Q2', 'Q3', 'Q4'])
95 else:
96 # Not enough unique values for quartiles, use simple high/low classification
97 df['confidence_quartile'] = df['confidence_score'].apply(lambda x: 'High' if x > 0.8 else 'Low')
99 # Add spatial clustering if we have position data
100 if 'bbox_x' in df.columns and 'bbox_y' in df.columns:
101 try:
102 from sklearn.cluster import KMeans
103 if len(df) >= 3:
104 coords = df[['bbox_x', 'bbox_y']].values
105 n_clusters = min(3, len(df))
106 kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
107 df['spatial_cluster'] = kmeans.fit_predict(coords)
108 else:
109 df['spatial_cluster'] = 0
110 except ImportError:
111 df['spatial_cluster'] = 0
113 csv_content = df.to_csv(index=False)
114 # Ensure output directory exists for disk backend
115 if backend == Backend.DISK.value:
116 filemanager.ensure_directory(Path(csv_path).parent, backend)
117 filemanager.save(csv_content, csv_path, backend)
119 return csv_path
122@numpy_func
123@special_outputs(("match_results", materialize_mtm_match_results))
124def multi_template_crop_reference_channel(
125 image_stack: np.ndarray,
126 template_path: str,
127 reference_channel: int = 0,
128 score_threshold: float = 0.8,
129 max_matches: int = 1,
130 crop_margin: int = 0,
131 method: int = cv2.TM_CCOEFF_NORMED,
132 use_best_match_only: bool = True,
133 normalize_input: bool = True,
134 pad_mode: str = "constant",
135 rotation_range: float = 0.0,
136 rotation_step: float = 45.0,
137 rotate_result: bool = True,
138 crop_enabled: bool = True
139) -> Tuple[np.ndarray, List[TemplateMatchResult]]:
140 """
141 Perform template matching on a reference channel and apply the same crop to all channels.
143 This function uses ONE channel (e.g., brightfield) for template matching, then applies
144 the same crop coordinates to ALL channels in the stack. Perfect for multi-channel imaging
145 where you want to use a bright, high-contrast channel for matching but crop all channels.
147 Parameters
148 ----------
149 image_stack : np.ndarray
150 3D array of shape (Z, Y, X) where Z represents channels/slices
151 template_path : str
152 Path to the template image file (supports common formats: PNG, JPEG, TIFF)
153 reference_channel : int, default=0
154 Channel index to use for template matching (0-based). All other channels
155 will be cropped using the coordinates found in this channel.
156 score_threshold : float, default=0.8
157 Minimum correlation score for template matches (0.0 to 1.0)
158 max_matches : int, default=1
159 Maximum number of matches to find in the reference channel
160 crop_margin : int, default=0
161 Additional pixels to include around the matched template region
162 method : int, default=cv2.TM_CCOEFF_NORMED,
163 OpenCV template matching method (currently not used by MTM)
164 use_best_match_only : bool, default=True
165 If True, only crop around the best match in the reference channel
166 normalize_input : bool, default=True
167 Whether to normalize input slices to uint8 range for MTM processing
168 pad_mode : str, default="constant"
169 Padding mode for size normalization ('constant', 'edge', 'reflect', etc.)
170 rotation_range : float, default=0.0
171 Total rotation range in degrees (e.g., 360.0 for full rotation)
172 rotation_step : float, default=45.0
173 Rotation increment in degrees (e.g., 45.0 for 8 orientations)
174 rotate_result : bool, default=True
175 Whether to rotate cropped results back to upright orientation
176 crop_enabled : bool, default=True
177 Whether to crop regions around matches. If False, returns original stack
179 Returns
180 -------
181 cropped_stack : np.ndarray
182 3D array where ALL channels are cropped using the reference channel's best match
183 match_results : List[TemplateMatchResult]
184 Results from the reference channel matching (other channels marked as "applied")
186 Raises
187 ------
188 ImportError
189 If MTM library is not installed
190 ValueError
191 If template image cannot be loaded, reference_channel is invalid, or input dimensions are invalid
192 """
194 if MTM is None:
195 raise ImportError("MTM library not available. Install with: pip install Multi-Template-Matching")
197 # Debug: Check input type and convert if necessary
198 logging.debug(f"MTM input type: {type(image_stack)}, shape: {getattr(image_stack, 'shape', 'no shape attr')}")
200 # Ensure image_stack is a numpy array
201 if not isinstance(image_stack, np.ndarray):
202 logging.warning(f"MTM: Converting input from {type(image_stack)} to numpy array")
203 image_stack = np.array(image_stack)
205 if image_stack.ndim != 3:
206 raise ValueError(f"Expected 3D image stack, got {image_stack.ndim}D array")
208 if reference_channel < 0 or reference_channel >= image_stack.shape[0]:
209 raise ValueError(f"reference_channel {reference_channel} is out of range for stack with {image_stack.shape[0]} channels")
211 # Load template image
212 template = cv2.imread(template_path, cv2.IMREAD_GRAYSCALE)
213 if template is None:
214 raise ValueError(f"Could not load template image from {template_path}")
216 logging.info(f"Loaded template of size {template.shape} from {template_path}")
217 logging.info(f"Using channel {reference_channel} as reference for template matching")
219 # Generate rotated templates if rotation is enabled
220 if rotation_range > 0:
221 template_list = _create_rotated_templates(template, rotation_range, rotation_step)
222 logging.info(f"Generated {len(template_list)} rotated templates (range: {rotation_range}°, step: {rotation_step}°)")
223 else:
224 template_list = [("template_0", template)]
226 # Process ONLY the reference channel for template matching
227 reference_slice = image_stack[reference_channel]
228 reference_result = _process_single_slice(
229 reference_slice,
230 template_list,
231 reference_channel,
232 score_threshold,
233 max_matches,
234 crop_margin,
235 use_best_match_only,
236 normalize_input
237 )
239 logging.info(f"Reference channel {reference_channel} matching: {reference_result.num_matches} matches, "
240 f"best score: {reference_result.match_score:.3f}")
242 # Apply the reference channel's crop to ALL channels
243 cropped_slices = []
244 match_results = []
246 for z_idx in range(image_stack.shape[0]):
247 slice_img = image_stack[z_idx]
249 if z_idx == reference_channel:
250 # Use the actual matching result for reference channel
251 match_results.append(reference_result)
252 else:
253 # Create a "applied" result for other channels
254 applied_result = TemplateMatchResult(
255 slice_index=z_idx,
256 matches=[], # No matching performed on this channel
257 best_match=reference_result.best_match, # Copy reference match
258 crop_bbox=reference_result.crop_bbox, # Use reference crop
259 match_score=reference_result.match_score, # Copy reference score
260 num_matches=0, # No matching performed
261 best_rotation_angle=reference_result.best_rotation_angle, # Copy reference angle
262 error_message=f"Crop applied from reference channel {reference_channel}"
263 )
264 match_results.append(applied_result)
266 # Apply the same crop to all channels
267 if crop_enabled and reference_result.crop_bbox is not None:
268 x, y, w, h = reference_result.crop_bbox
269 cropped_slice = slice_img[y:y+h, x:x+w]
271 # Rotate cropped slice back to upright if rotation was used
272 if rotate_result and reference_result.best_rotation_angle != 0:
273 cropped_slice = _rotate_image(cropped_slice, -reference_result.best_rotation_angle)
274 else:
275 # Use original slice (either cropping disabled or no match found)
276 cropped_slice = slice_img
278 cropped_slices.append(cropped_slice)
280 # Stack slices with consistent dimensions (only pad if cropping was enabled)
281 if crop_enabled:
282 cropped_stack = _stack_with_padding(cropped_slices, pad_mode)
283 logging.info(f"Reference-based template matching complete. Cropped output shape: {cropped_stack.shape}")
284 else:
285 # Return original stack when cropping is disabled
286 cropped_stack = image_stack
287 logging.info(f"Reference-based template matching complete. Original stack shape preserved: {cropped_stack.shape}")
289 return cropped_stack, match_results
292@numpy_func
293@special_outputs("match_results")
294def multi_template_crop_subset(
295 image_stack: np.ndarray,
296 template_path: str,
297 reference_channel: int = 0,
298 target_channels: Optional[List[int]] = None,
299 score_threshold: float = 0.8,
300 max_matches: int = 1,
301 crop_margin: int = 0,
302 method: int = cv2.TM_CCOEFF,
303 use_best_match_only: bool = True,
304 normalize_input: bool = True,
305 pad_mode: str = "constant",
306 rotation_range: float = 0.0,
307 rotation_step: float = 45.0,
308 rotate_result: bool = True,
309 crop_enabled: bool = True
310) -> Tuple[np.ndarray, List[TemplateMatchResult]]:
311 """
312 Perform template matching on a reference channel and crop only specified target channels.
314 This function uses ONE channel for template matching, then crops only the specified
315 subset of channels. Perfect when you want to use brightfield for matching but only
316 crop specific fluorescence channels.
318 Parameters
319 ----------
320 image_stack : np.ndarray
321 3D array of shape (Z, Y, X) where Z represents channels/slices
322 template_path : str
323 Path to the template image file
324 reference_channel : int, default=0
325 Channel index to use for template matching (0-based)
326 target_channels : List[int], optional
327 List of channel indices to crop. If None, crops all channels.
328 Example: [0, 2, 3] to crop channels 0, 2, and 3 only
329 score_threshold : float, default=0.8
330 Minimum correlation score for template matches
331 max_matches : int, default=1
332 Maximum number of matches to find in the reference channel
333 crop_margin : int, default=0
334 Additional pixels around the matched region
335 method : int, default=cv2.TM_CCOEFF_NORMED,
336 OpenCV template matching method
337 use_best_match_only : bool, default=True
338 If True, only crop around the best match
339 normalize_input : bool, default=True
340 Whether to normalize input for MTM processing
341 pad_mode : str, default="constant"
342 Padding mode for size normalization
343 rotation_range : float, default=0.0
344 Total rotation range in degrees
345 rotation_step : float, default=45.0
346 Rotation increment in degrees
347 rotate_result : bool, default=True
348 Whether to rotate cropped results back to upright
349 crop_enabled : bool, default=True
350 Whether to crop regions around matches
352 Returns
353 -------
354 cropped_stack : np.ndarray
355 3D array containing only the specified target channels, cropped using reference channel
356 match_results : List[TemplateMatchResult]
357 Results for each target channel (reference channel gets actual results, others get "applied")
359 Examples
360 --------
361 # Use brightfield (channel 0) to match, crop only DAPI (channel 1) and GFP (channel 2)
362 cropped, results = multi_template_crop_subset(
363 stack, "template.png",
364 reference_channel=0,
365 target_channels=[1, 2]
366 )
367 """
369 if target_channels is None:
370 # Default: crop all channels
371 target_channels = list(range(image_stack.shape[0]))
373 # Validate target channels
374 for ch in target_channels:
375 if ch < 0 or ch >= image_stack.shape[0]:
376 raise ValueError(f"target_channel {ch} is out of range for stack with {image_stack.shape[0]} channels")
378 if reference_channel not in target_channels:
379 logging.warning(f"Reference channel {reference_channel} is not in target_channels {target_channels}. "
380 f"Template matching will be performed but reference channel won't be in output.")
382 # Use the reference-channel function to get crop coordinates
383 _, full_results = multi_template_crop_reference_channel(
384 image_stack, template_path, reference_channel,
385 score_threshold, max_matches, crop_margin, method,
386 use_best_match_only, normalize_input, pad_mode,
387 rotation_range, rotation_step, rotate_result, crop_enabled
388 )
390 # Extract only the target channels
391 target_slices = []
392 target_results = []
394 reference_result = full_results[reference_channel]
396 for target_ch in target_channels:
397 slice_img = image_stack[target_ch]
399 # Apply the reference channel's crop
400 if crop_enabled and reference_result.crop_bbox is not None:
401 x, y, w, h = reference_result.crop_bbox
402 cropped_slice = slice_img[y:y+h, x:x+w]
404 # Rotate if needed
405 if rotate_result and reference_result.best_rotation_angle != 0:
406 cropped_slice = _rotate_image(cropped_slice, -reference_result.best_rotation_angle)
407 else:
408 cropped_slice = slice_img
410 target_slices.append(cropped_slice)
412 # Create result for this target channel
413 if target_ch == reference_channel:
414 target_results.append(reference_result)
415 else:
416 applied_result = TemplateMatchResult(
417 slice_index=target_ch,
418 matches=[],
419 best_match=reference_result.best_match,
420 crop_bbox=reference_result.crop_bbox,
421 match_score=reference_result.match_score,
422 num_matches=0,
423 best_rotation_angle=reference_result.best_rotation_angle,
424 error_message=f"Crop applied from reference channel {reference_channel}"
425 )
426 target_results.append(applied_result)
428 # Stack target slices
429 if crop_enabled and target_slices:
430 cropped_stack = _stack_with_padding(target_slices, pad_mode)
431 logging.info(f"Subset template matching complete. Output shape: {cropped_stack.shape} "
432 f"(channels {target_channels})")
433 else:
434 # Return subset of original stack
435 cropped_stack = image_stack[target_channels]
436 logging.info(f"Subset template matching complete. Original subset shape: {cropped_stack.shape}")
438 return cropped_stack, target_results
441@numpy_func
442@special_outputs("match_results")
443def multi_template_crop(
444 image_stack: np.ndarray,
445 template_path: str,
446 score_threshold: float = 0.8,
447 max_matches: int = 1,
448 crop_margin: int = 0,
449 method: int = cv2.TM_CCOEFF_NORMED,
450 use_best_match_only: bool = True,
451 normalize_input: bool = True,
452 pad_mode: str = "constant",
453 rotation_range: float = 0.0,
454 rotation_step: float = 45.0,
455 rotate_result: bool = True,
456 crop_enabled: bool = True
457) -> Tuple[np.ndarray, List[TemplateMatchResult]]:
458 """
459 Perform multi-template matching on each slice of a 3D image stack and return cropped regions.
461 This function applies template matching to each Z-slice independently, finds the best matches,
462 and crops the regions around the matched templates. All cropped regions are stacked back into
463 a 3D array with consistent dimensions.
465 Parameters
466 ----------
467 image_stack : np.ndarray
468 3D array of shape (Z, Y, X) containing the image slices to process
469 template_path : str
470 Path to the template image file (supports common formats: PNG, JPEG, TIFF)
471 score_threshold : float, default=0.8
472 Minimum correlation score for template matches (0.0 to 1.0)
473 max_matches : int, default=1
474 Maximum number of matches to find per slice
475 crop_margin : int, default=0
476 Additional pixels to include around the matched template region
477 method : int, default=cv2.TM_CCOEFF_NORMED
478 OpenCV template matching method (currently not used by MTM)
479 use_best_match_only : bool, default=True
480 If True, only crop around the best match per slice
481 normalize_input : bool, default=True
482 Whether to normalize input slices to uint8 range for MTM processing
483 pad_mode : str, default="constant"
484 Padding mode for size normalization ('constant', 'edge', 'reflect', etc.)
485 rotation_range : float, default=0.0
486 Total rotation range in degrees (e.g., 360.0 for full rotation)
487 rotation_step : float, default=45.0
488 Rotation increment in degrees (e.g., 45.0 for 8 orientations)
489 rotate_result : bool, default=True
490 Whether to rotate cropped results back to upright orientation
491 crop_enabled : bool, default=True
492 Whether to crop regions around matches. If False, returns original stack with match results
494 Returns
495 -------
496 cropped_stack : np.ndarray
497 3D array of cropped regions stacked together (if crop_enabled=True),
498 or original image stack (if crop_enabled=False)
499 match_results : List[TemplateMatchResult]
500 Detailed results for each slice including match info and crop coordinates
502 Raises
503 ------
504 ImportError
505 If MTM library is not installed
506 ValueError
507 If template image cannot be loaded or input dimensions are invalid
508 """
510 if MTM is None:
511 raise ImportError("MTM library not available. Install with: pip install Multi-Template-Matching")
513 # DETAILED DEBUG: Trace the exact issue
514 logging.error(f"MTM DEBUG: Input type: {type(image_stack)}")
515 logging.error(f"MTM DEBUG: Input shape: {getattr(image_stack, 'shape', 'NO SHAPE ATTRIBUTE')}")
516 logging.error(f"MTM DEBUG: Input ndim: {getattr(image_stack, 'ndim', 'NO NDIM ATTRIBUTE')}")
517 logging.error(f"MTM DEBUG: Is numpy array: {isinstance(image_stack, np.ndarray)}")
518 logging.error(f"MTM DEBUG: Input class module: {image_stack.__class__.__module__}")
519 logging.error(f"MTM DEBUG: Input class name: {image_stack.__class__.__name__}")
521 # Test slicing to see what we get
522 if hasattr(image_stack, 'shape') and len(image_stack.shape) > 0:
523 test_slice = image_stack[0]
524 logging.error(f"MTM DEBUG: First slice type: {type(test_slice)}")
525 logging.error(f"MTM DEBUG: First slice shape: {getattr(test_slice, 'shape', 'NO SHAPE ATTRIBUTE')}")
526 logging.error(f"MTM DEBUG: First slice is numpy: {isinstance(test_slice, np.ndarray)}")
528 if image_stack.ndim != 3:
529 raise ValueError(f"Expected 3D image stack, got {image_stack.ndim}D array")
531 # Load template image
532 template = cv2.imread(template_path, cv2.IMREAD_GRAYSCALE)
533 if template is None:
534 raise ValueError(f"Could not load template image from {template_path}")
536 logging.info(f"Loaded template of size {template.shape} from {template_path}")
538 # Generate rotated templates if rotation is enabled
539 if rotation_range > 0:
540 template_list = _create_rotated_templates(template, rotation_range, rotation_step)
541 logging.info(f"Generated {len(template_list)} rotated templates (range: {rotation_range}°, step: {rotation_step}°)")
542 else:
543 template_list = [("template_0", template)]
545 # Results storage
546 cropped_slices = []
547 match_results = []
549 logging.info(f"Processing {image_stack.shape[0]} slices with template matching")
551 # Process each slice
552 for z_idx in range(image_stack.shape[0]):
553 slice_img = image_stack[z_idx]
554 logging.debug(f"MTM: Processing slice {z_idx}, slice_img type: {type(slice_img)}, shape: {getattr(slice_img, 'shape', 'NO SHAPE ATTRIBUTE')}")
555 result = _process_single_slice(
556 slice_img,
557 template_list,
558 z_idx,
559 score_threshold,
560 max_matches,
561 crop_margin,
562 use_best_match_only,
563 normalize_input
564 )
566 match_results.append(result)
568 # Extract cropped slice from result or use original slice
569 if crop_enabled and result.crop_bbox is not None:
570 x, y, w, h = result.crop_bbox
571 cropped_slice = slice_img[y:y+h, x:x+w]
573 # Rotate cropped slice back to upright if rotation was used
574 if rotate_result and result.best_rotation_angle != 0:
575 cropped_slice = _rotate_image(cropped_slice, -result.best_rotation_angle)
576 else:
577 # Use original slice (either cropping disabled or no match found)
578 cropped_slice = slice_img
580 cropped_slices.append(cropped_slice)
582 # Stack slices with consistent dimensions (only pad if cropping was enabled)
583 if crop_enabled:
584 cropped_stack = _stack_with_padding(cropped_slices, pad_mode)
585 logging.info(f"Template matching complete. Cropped output shape: {cropped_stack.shape}")
586 else:
587 # Return original stack when cropping is disabled
588 cropped_stack = image_stack
589 logging.info(f"Template matching complete. Original stack shape preserved: {cropped_stack.shape}")
591 return cropped_stack, match_results
596def _create_rotated_templates(template: np.ndarray, rotation_range: float, rotation_step: float) -> List[Tuple[str, np.ndarray]]:
597 """Create rotated versions of a template."""
598 templates = []
600 # Generate rotation angles
601 if rotation_range >= 360:
602 # Full rotation
603 angles = np.arange(0, 360, rotation_step)
604 else:
605 # Symmetric range around 0
606 half_range = rotation_range / 2
607 angles = np.arange(-half_range, half_range + rotation_step, rotation_step)
609 for angle in angles:
610 rotated_template = _rotate_image(template, angle)
611 templates.append((f"template_{angle:.1f}", rotated_template))
613 return templates
616def _rotate_image(image: np.ndarray, angle: float) -> np.ndarray:
617 """Rotate an image by the specified angle in degrees."""
618 if angle == 0:
619 return image
621 # Get image center
622 center = (image.shape[1] // 2, image.shape[0] // 2)
624 # Create rotation matrix
625 rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
627 # Calculate new bounding dimensions
628 cos_val = np.abs(rotation_matrix[0, 0])
629 sin_val = np.abs(rotation_matrix[0, 1])
630 new_width = int((image.shape[0] * sin_val) + (image.shape[1] * cos_val))
631 new_height = int((image.shape[0] * cos_val) + (image.shape[1] * sin_val))
633 # Adjust rotation matrix for new center
634 rotation_matrix[0, 2] += (new_width / 2) - center[0]
635 rotation_matrix[1, 2] += (new_height / 2) - center[1]
637 # Perform rotation
638 rotated = cv2.warpAffine(image, rotation_matrix, (new_width, new_height),
639 borderMode=cv2.BORDER_CONSTANT, borderValue=0)
641 return rotated
644def _process_single_slice(
645 slice_img: np.ndarray,
646 template_list: List[Tuple[str, np.ndarray]],
647 z_idx: int,
648 score_threshold: float,
649 max_matches: int,
650 crop_margin: int,
651 use_best_match_only: bool,
652 normalize_input: bool,
653 method: int = cv2.TM_CCOEFF_NORMED
654) -> TemplateMatchResult:
655 """Process a single slice for template matching."""
657 # DETAILED DEBUG: Check what we received
658 logging.error(f"_process_single_slice DEBUG: slice_img type: {type(slice_img)}")
659 logging.error(f"_process_single_slice DEBUG: slice_img shape: {getattr(slice_img, 'shape', 'NO SHAPE')}")
660 logging.error(f"_process_single_slice DEBUG: template_list type: {type(template_list)}")
661 logging.error(f"_process_single_slice DEBUG: template_list length: {len(template_list) if hasattr(template_list, '__len__') else 'NO LEN'}")
662 if template_list and len(template_list) > 0:
663 logging.error(f"_process_single_slice DEBUG: first template type: {type(template_list[0])}")
664 if len(template_list[0]) > 1:
665 logging.error(f"_process_single_slice DEBUG: first template array type: {type(template_list[0][1])}")
667 # Prepare slice for MTM - LET ERRORS FAIL LOUD
668 if normalize_input and slice_img.dtype != np.uint8:
669 # Normalize to 0-255 range
670 slice_min, slice_max = slice_img.min(), slice_img.max()
671 if slice_max > slice_min:
672 slice_normalized = ((slice_img - slice_min) / (slice_max - slice_min) * 255).astype(np.uint8)
673 else:
674 slice_normalized = np.zeros_like(slice_img, dtype=np.uint8)
675 else:
676 slice_normalized = slice_img.astype(np.uint8)
678 # Perform template matching - FIXED ARGUMENT ORDER
679 hits = MTM.matchTemplates(
680 template_list, # First parameter: listTemplates
681 slice_normalized, # Second parameter: image
682 score_threshold=score_threshold,
683 maxOverlap=0.25, # Prevent overlapping matches
684 N_object=max_matches,
685 method=method
686 )
688 # Process results
689 best_match = None
690 crop_bbox = None
691 best_rotation_angle = 0.0
693 if hits:
694 # Sort by score (hits format: [label, bbox, score])
695 hits_sorted = sorted(hits, key=lambda x: x[2])
696 best_match = hits_sorted[0] if hits_sorted else None
698 if best_match and use_best_match_only:
699 # Extract rotation angle from template label
700 template_label = best_match[0]
701 if template_label.startswith("template_"):
702 try:
703 best_rotation_angle = float(template_label.split("_")[1])
704 except (IndexError, ValueError):
705 best_rotation_angle = 0.0
707 # Extract bounding box (x, y, width, height)
708 bbox = best_match[1]
709 x, y, w, h = bbox
711 # Apply margin and clamp to image bounds
712 x_start = max(0, x - crop_margin)
713 y_start = max(0, y - crop_margin)
714 x_end = min(slice_img.shape[1], x + w + crop_margin)
715 y_end = min(slice_img.shape[0], y + h + crop_margin)
717 crop_bbox = (x_start, y_start, x_end - x_start, y_end - y_start)
719 # Create result
720 return TemplateMatchResult(
721 slice_index=z_idx,
722 matches=hits,
723 best_match=best_match,
724 crop_bbox=crop_bbox,
725 match_score=best_match[2] if best_match else 0.0,
726 num_matches=len(hits),
727 best_rotation_angle=best_rotation_angle
728 )
730 # REMOVED: Exception handling - let errors fail loud instead of silent warnings
733def _stack_with_padding(cropped_slices: List[np.ndarray], pad_mode: str) -> np.ndarray:
734 """Stack cropped slices with padding to ensure consistent dimensions."""
736 if not cropped_slices:
737 raise ValueError("No cropped slices to stack")
739 # Find maximum dimensions
740 max_h = max(slice_arr.shape[0] for slice_arr in cropped_slices)
741 max_w = max(slice_arr.shape[1] for slice_arr in cropped_slices)
743 # Pad all slices to same size
744 padded_slices = []
745 for slice_arr in cropped_slices:
746 h, w = slice_arr.shape
747 pad_h = max_h - h
748 pad_w = max_w - w
750 if pad_h > 0 or pad_w > 0:
751 # Pad with specified mode
752 padded = np.pad(
753 slice_arr,
754 ((0, pad_h), (0, pad_w)),
755 mode=pad_mode,
756 constant_values=0 if pad_mode == 'constant' else None
757 )
758 else:
759 padded = slice_arr
761 padded_slices.append(padded)
763 # Stack into 3D array
764 return np.stack(padded_slices, axis=0)