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

1""" 

2Multi-Template Matching functions for OpenHCS. 

3 

4This module provides template matching capabilities using the Multi-Template-Matching library 

5to detect and crop regions of interest in image stacks. 

6""" 

7 

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 

16 

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") 

22 

23from openhcs.core.memory.decorators import numpy as numpy_func 

24from openhcs.core.pipeline.function_contracts import special_outputs 

25 

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 

37 

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') 

41 

42 rows = [] 

43 for result in data: 

44 slice_idx = result.slice_index 

45 

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) 

52 

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 }) 

79 

80 if rows: 

81 df = pd.DataFrame(rows) 

82 

83 # Add analysis columns 

84 if len(df) > 0 and 'confidence_score' in df.columns: 

85 df['high_confidence'] = df['confidence_score'] > 0.8 

86 

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') 

98 

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 

112 

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) 

118 

119 return csv_path 

120 

121 

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. 

142 

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. 

146 

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 

178 

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") 

185 

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 """ 

193 

194 if MTM is None: 

195 raise ImportError("MTM library not available. Install with: pip install Multi-Template-Matching") 

196 

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')}") 

199 

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) 

204 

205 if image_stack.ndim != 3: 

206 raise ValueError(f"Expected 3D image stack, got {image_stack.ndim}D array") 

207 

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") 

210 

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}") 

215 

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") 

218 

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)] 

225 

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 ) 

238 

239 logging.info(f"Reference channel {reference_channel} matching: {reference_result.num_matches} matches, " 

240 f"best score: {reference_result.match_score:.3f}") 

241 

242 # Apply the reference channel's crop to ALL channels 

243 cropped_slices = [] 

244 match_results = [] 

245 

246 for z_idx in range(image_stack.shape[0]): 

247 slice_img = image_stack[z_idx] 

248 

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) 

265 

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] 

270 

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 

277 

278 cropped_slices.append(cropped_slice) 

279 

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}") 

288 

289 return cropped_stack, match_results 

290 

291 

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. 

313 

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. 

317 

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 

351 

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") 

358 

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 """ 

368 

369 if target_channels is None: 

370 # Default: crop all channels 

371 target_channels = list(range(image_stack.shape[0])) 

372 

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") 

377 

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.") 

381 

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 ) 

389 

390 # Extract only the target channels 

391 target_slices = [] 

392 target_results = [] 

393 

394 reference_result = full_results[reference_channel] 

395 

396 for target_ch in target_channels: 

397 slice_img = image_stack[target_ch] 

398 

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] 

403 

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 

409 

410 target_slices.append(cropped_slice) 

411 

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) 

427 

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}") 

437 

438 return cropped_stack, target_results 

439 

440 

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. 

460  

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. 

464  

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 

493 

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 

501  

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 """ 

509 

510 if MTM is None: 

511 raise ImportError("MTM library not available. Install with: pip install Multi-Template-Matching") 

512 

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__}") 

520 

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)}") 

527 

528 if image_stack.ndim != 3: 

529 raise ValueError(f"Expected 3D image stack, got {image_stack.ndim}D array") 

530 

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}") 

535 

536 logging.info(f"Loaded template of size {template.shape} from {template_path}") 

537 

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)] 

544 

545 # Results storage 

546 cropped_slices = [] 

547 match_results = [] 

548 

549 logging.info(f"Processing {image_stack.shape[0]} slices with template matching") 

550 

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 ) 

565 

566 match_results.append(result) 

567 

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] 

572 

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 

579 

580 cropped_slices.append(cropped_slice) 

581 

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}") 

590 

591 return cropped_stack, match_results 

592 

593 

594 

595 

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 = [] 

599 

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) 

608 

609 for angle in angles: 

610 rotated_template = _rotate_image(template, angle) 

611 templates.append((f"template_{angle:.1f}", rotated_template)) 

612 

613 return templates 

614 

615 

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 

620 

621 # Get image center 

622 center = (image.shape[1] // 2, image.shape[0] // 2) 

623 

624 # Create rotation matrix 

625 rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1.0) 

626 

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)) 

632 

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] 

636 

637 # Perform rotation 

638 rotated = cv2.warpAffine(image, rotation_matrix, (new_width, new_height), 

639 borderMode=cv2.BORDER_CONSTANT, borderValue=0) 

640 

641 return rotated 

642 

643 

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.""" 

656 

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])}") 

666 

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) 

677 

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 ) 

687 

688 # Process results 

689 best_match = None 

690 crop_bbox = None 

691 best_rotation_angle = 0.0 

692 

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 

697 

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 

706 

707 # Extract bounding box (x, y, width, height) 

708 bbox = best_match[1] 

709 x, y, w, h = bbox 

710 

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) 

716 

717 crop_bbox = (x_start, y_start, x_end - x_start, y_end - y_start) 

718 

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 ) 

729 

730 # REMOVED: Exception handling - let errors fail loud instead of silent warnings 

731 

732 

733def _stack_with_padding(cropped_slices: List[np.ndarray], pad_mode: str) -> np.ndarray: 

734 """Stack cropped slices with padding to ensure consistent dimensions.""" 

735 

736 if not cropped_slices: 

737 raise ValueError("No cropped slices to stack") 

738 

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) 

742 

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 

749 

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 

760 

761 padded_slices.append(padded) 

762 

763 # Stack into 3D array 

764 return np.stack(padded_slices, axis=0)