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