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

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 json 

14import pandas as pd 

15from openhcs.constants.constants import Backend 

16from pathlib import Path 

17 

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

23 

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

25from openhcs.core.pipeline.function_contracts import special_outputs 

26 

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 

38 

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

42 

43 rows = [] 

44 for result in data: 

45 slice_idx = result.slice_index 

46 

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) 

53 

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

80 

81 if rows: 

82 df = pd.DataFrame(rows) 

83 

84 # Add analysis columns 

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

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

87 

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

99 

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 

113 

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) 

117 

118 return csv_path 

119 

120 

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. 

141 

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. 

145 

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 

177 

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

184 

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

192 

193 if MTM is None: 

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

195 

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

198 

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) 

203 

204 if image_stack.ndim != 3: 

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

206 

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

209 

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

214 

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

217 

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

224 

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 ) 

237 

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

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

240 

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

242 cropped_slices = [] 

243 match_results = [] 

244 

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

246 slice_img = image_stack[z_idx] 

247 

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) 

264 

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] 

269 

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 

276 

277 cropped_slices.append(cropped_slice) 

278 

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

287 

288 return cropped_stack, match_results 

289 

290 

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. 

312 

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. 

316 

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 

350 

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

357 

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

367 

368 if target_channels is None: 

369 # Default: crop all channels 

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

371 

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

376 

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

380 

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 ) 

388 

389 # Extract only the target channels 

390 target_slices = [] 

391 target_results = [] 

392 

393 reference_result = full_results[reference_channel] 

394 

395 for target_ch in target_channels: 

396 slice_img = image_stack[target_ch] 

397 

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] 

402 

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 

408 

409 target_slices.append(cropped_slice) 

410 

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) 

426 

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

436 

437 return cropped_stack, target_results 

438 

439 

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. 

459  

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. 

463  

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 

492 

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 

500  

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

508 

509 if MTM is None: 

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

511 

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

519 

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

526 

527 if image_stack.ndim != 3: 

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

529 

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

534 

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

536 

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

543 

544 # Results storage 

545 cropped_slices = [] 

546 match_results = [] 

547 

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

549 

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 ) 

564 

565 match_results.append(result) 

566 

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] 

571 

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 

578 

579 cropped_slices.append(cropped_slice) 

580 

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

589 

590 return cropped_stack, match_results 

591 

592 

593 

594 

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

598 

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) 

607 

608 for angle in angles: 

609 rotated_template = _rotate_image(template, angle) 

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

611 

612 return templates 

613 

614 

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 

619 

620 # Get image center 

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

622 

623 # Create rotation matrix 

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

625 

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

631 

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] 

635 

636 # Perform rotation 

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

638 borderMode=cv2.BORDER_CONSTANT, borderValue=0) 

639 

640 return rotated 

641 

642 

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

655 

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

665 

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) 

676 

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 ) 

686 

687 # Process results 

688 best_match = None 

689 crop_bbox = None 

690 best_rotation_angle = 0.0 

691 

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 

696 

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 

705 

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

707 bbox = best_match[1] 

708 x, y, w, h = bbox 

709 

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) 

715 

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

717 

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 ) 

728 

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

730 

731 

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

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

734 

735 if not cropped_slices: 

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

737 

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) 

741 

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 

748 

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 

759 

760 padded_slices.append(padded) 

761 

762 # Stack into 3D array 

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