1use box_image_pyramid::PyramidBuffers;
29use chess_corners_core::{ChessBuffers, RadonBuffers};
30
31#[cfg(feature = "ml-refiner")]
32use crate::ml_refiner;
33use crate::multiscale;
34use crate::upscale::{self, UpscaleBuffers};
35use crate::{ChessError, CornerDescriptor, DetectorConfig, Roi};
36use chess_corners_core::ImageView;
37
38pub struct Detector {
43 cfg: DetectorConfig,
44 pyramid: PyramidBuffers,
45 chess_buffers: ChessBuffers,
46 radon_buffers: RadonBuffers,
47 upscale: UpscaleBuffers,
48 #[cfg(feature = "ml-refiner")]
49 ml_state: Option<ml_refiner::MlRefinerState>,
50 #[cfg(feature = "ml-refiner")]
51 ml_params: ml_refiner::MlRefinerParams,
52}
53
54impl Detector {
55 pub fn new(cfg: DetectorConfig) -> Result<Self, ChessError> {
62 cfg.upscale.validate()?;
63 Ok(Self {
64 cfg,
65 pyramid: PyramidBuffers::default(),
66 chess_buffers: ChessBuffers::default(),
67 radon_buffers: RadonBuffers::default(),
68 upscale: UpscaleBuffers::new(),
69 #[cfg(feature = "ml-refiner")]
70 ml_state: None,
71 #[cfg(feature = "ml-refiner")]
72 ml_params: ml_refiner::MlRefinerParams::default(),
73 })
74 }
75
76 pub fn with_default() -> Self {
78 Self::new(DetectorConfig::default()).expect("default DetectorConfig is always valid")
81 }
82
83 pub fn config(&self) -> &DetectorConfig {
85 &self.cfg
86 }
87
88 pub fn set_config(&mut self, cfg: DetectorConfig) -> Result<(), ChessError> {
95 cfg.upscale.validate()?;
96 self.cfg = cfg;
97 #[cfg(feature = "ml-refiner")]
100 {
101 self.ml_state = None;
102 }
103 Ok(())
104 }
105
106 pub fn detect_u8(
116 &mut self,
117 img: &[u8],
118 width: u32,
119 height: u32,
120 ) -> Result<Vec<CornerDescriptor>, ChessError> {
121 let src_w = width as usize;
122 let src_h = height as usize;
123 let expected = src_w * src_h;
124 if img.len() != expected {
125 return Err(ChessError::DimensionMismatch {
126 expected,
127 actual: img.len(),
128 });
129 }
130
131 let factor = self.cfg.upscale.effective_factor();
132 if factor <= 1 {
133 let view =
134 ImageView::from_u8_slice(src_w, src_h, img).expect("dimensions were checked above");
135 return Ok(Self::detect_view_inner(
136 &self.cfg,
137 &mut self.pyramid,
138 &mut self.chess_buffers,
139 &mut self.radon_buffers,
140 #[cfg(feature = "ml-refiner")]
141 &mut self.ml_state,
142 #[cfg(feature = "ml-refiner")]
143 &self.ml_params,
144 view,
145 ));
146 }
147
148 let upscaled = upscale::upscale_bilinear_u8(img, src_w, src_h, factor, &mut self.upscale)?;
153 let mut corners = Self::detect_view_inner(
154 &self.cfg,
155 &mut self.pyramid,
156 &mut self.chess_buffers,
157 &mut self.radon_buffers,
158 #[cfg(feature = "ml-refiner")]
159 &mut self.ml_state,
160 #[cfg(feature = "ml-refiner")]
161 &self.ml_params,
162 upscaled,
163 );
164 upscale::rescale_descriptors_to_input(&mut corners, factor);
165 Ok(corners)
166 }
167
168 #[cfg(feature = "image")]
175 pub fn detect(&mut self, img: &image::GrayImage) -> Result<Vec<CornerDescriptor>, ChessError> {
176 self.detect_u8(img.as_raw(), img.width(), img.height())
177 }
178
179 pub fn detect_u8_roi(
240 &mut self,
241 img: &[u8],
242 width: u32,
243 height: u32,
244 roi: Roi,
245 ) -> Result<Vec<CornerDescriptor>, ChessError> {
246 let src_w = width as usize;
247 let src_h = height as usize;
248 let expected = src_w * src_h;
249 if img.len() != expected {
250 return Err(ChessError::DimensionMismatch {
251 expected,
252 actual: img.len(),
253 });
254 }
255
256 #[cfg(feature = "ml-refiner")]
259 if Self::is_ml_refiner(&self.cfg) {
260 return Err(ChessError::RoiRefinerUnsupported);
261 }
262
263 let view =
264 ImageView::from_u8_slice(src_w, src_h, img).expect("dimensions were checked above");
265 Ok(multiscale::detect_roi_with_buffers(
266 view,
267 roi,
268 &self.cfg,
269 &mut self.chess_buffers,
270 &mut self.radon_buffers,
271 ))
272 }
273
274 #[cfg(feature = "image")]
285 pub fn detect_roi(
286 &mut self,
287 img: &image::GrayImage,
288 roi: Roi,
289 ) -> Result<Vec<CornerDescriptor>, ChessError> {
290 self.detect_u8_roi(img.as_raw(), img.width(), img.height(), roi)
291 }
292
293 pub fn diagnostics(&self) -> crate::diagnostics::DetectorDiagnostics<'_> {
309 crate::diagnostics::DetectorDiagnostics::new(self)
310 }
311
312 fn detect_view_inner(
313 cfg: &DetectorConfig,
314 pyramid: &mut PyramidBuffers,
315 chess_buffers: &mut ChessBuffers,
316 radon_buffers: &mut RadonBuffers,
317 #[cfg(feature = "ml-refiner")] ml_state: &mut Option<ml_refiner::MlRefinerState>,
318 #[cfg(feature = "ml-refiner")] ml_params: &ml_refiner::MlRefinerParams,
319 view: ImageView<'_>,
320 ) -> Vec<CornerDescriptor> {
321 #[cfg(feature = "ml-refiner")]
322 if Self::is_ml_refiner(cfg) {
323 if ml_state.is_none() {
324 let fallback = chess_corners_core::RefinerKind::CenterOfMass(
325 chess_corners_core::CenterOfMassConfig::default(),
326 );
327 *ml_state = Some(ml_refiner::MlRefinerState::new(ml_params, &fallback));
328 }
329 let state = ml_state.as_mut().expect("ml_state initialised above");
330 return multiscale::detect_with_ml(
331 view,
332 cfg,
333 pyramid,
334 chess_buffers,
335 radon_buffers,
336 ml_params,
337 state,
338 );
339 }
340
341 multiscale::detect_with_buffers(view, cfg, pyramid, chess_buffers, radon_buffers)
342 }
343
344 #[cfg(feature = "ml-refiner")]
348 #[inline]
349 fn is_ml_refiner(cfg: &DetectorConfig) -> bool {
350 matches!(
351 &cfg.strategy,
352 crate::DetectionStrategy::Chess(c) if matches!(c.refiner, crate::ChessRefiner::Ml)
353 )
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::UpscaleConfig;
361 use chess_corners_testutil::{aa_chessboard, gaussian_blur};
362
363 fn synthetic_board(size: usize) -> Vec<u8> {
364 aa_chessboard(size, 12, (0.0, 0.0), 20, 220)
365 }
366
367 fn roi(x0: usize, y0: usize, x1: usize, y1: usize) -> Roi {
368 Roi::new(x0, y0, x1, y1).expect("valid roi")
369 }
370
371 fn bit_eq(a: f32, b: f32) -> bool {
372 a.to_bits() == b.to_bits()
373 }
374
375 #[test]
376 fn detect_u8_reports_dimension_mismatch() {
377 let mut det = Detector::with_default();
378 let img = vec![0u8; 10];
379 let err = det.detect_u8(&img, 8, 8).unwrap_err();
380 match err {
381 ChessError::DimensionMismatch { expected, actual } => {
382 assert_eq!(expected, 64);
383 assert_eq!(actual, 10);
384 }
385 other => panic!("expected ChessError::DimensionMismatch, got {other:?}"),
386 }
387 }
388
389 #[test]
390 fn set_config_valid_swap_changes_detection_outcome() {
391 let size = 96usize;
392 let img = synthetic_board(size);
393
394 let mut det = Detector::new(DetectorConfig::chess().with_threshold(30.0)).unwrap();
395 let low_threshold = det.detect_u8(&img, size as u32, size as u32).unwrap();
396 assert!(
397 !low_threshold.is_empty(),
398 "expected corners at the default threshold"
399 );
400
401 det.set_config(DetectorConfig::chess().with_threshold(5000.0))
402 .expect("valid upscale config");
403 let high_threshold = det.detect_u8(&img, size as u32, size as u32).unwrap();
404 assert!(
405 high_threshold.len() < low_threshold.len(),
406 "raising the response threshold far above real corner strengths \
407 must suppress detections: low={} high={}",
408 low_threshold.len(),
409 high_threshold.len()
410 );
411 }
412
413 #[test]
414 fn set_config_rejects_invalid_upscale_and_leaves_detector_usable() {
415 let mut det = Detector::new(DetectorConfig::chess()).unwrap();
416 let original_upscale = det.config().upscale;
417
418 let bad = DetectorConfig::chess().with_upscale(UpscaleConfig::fixed(5));
419 let err = det.set_config(bad).unwrap_err();
420 assert!(matches!(err, ChessError::Upscale(_)));
421
422 assert_eq!(det.config().upscale, original_upscale);
424
425 let img = synthetic_board(64);
427 let corners = det.detect_u8(&img, 64, 64).unwrap();
428 assert!(!corners.is_empty());
429 }
430
431 #[test]
432 fn detect_u8_roi_reports_dimension_mismatch() {
433 let mut det = Detector::with_default();
434 let img = vec![0u8; 10];
435 let err = det.detect_u8_roi(&img, 8, 8, roi(0, 0, 4, 4)).unwrap_err();
436 assert!(matches!(err, ChessError::DimensionMismatch { .. }));
437 }
438
439 #[test]
448 fn chess_roi_matches_full_frame_on_interior() {
449 let size = 100usize;
450 let img = aa_chessboard(size, 12, (0.35, 0.7), 20, 220);
451 let mut det = Detector::new(DetectorConfig::chess()).unwrap();
452
453 let full = det.detect_u8(&img, size as u32, size as u32).unwrap();
454 let sub = det
455 .detect_u8_roi(&img, size as u32, size as u32, roi(20, 20, 80, 80))
456 .unwrap();
457
458 let border = 7.0f32;
460 let (rx0, ry0, rx1, ry1) = (20.0f32, 20.0, 80.0, 80.0);
461 let mut checked = 0usize;
462 for fc in &full {
463 let strictly_inside = fc.x > rx0 + border
464 && fc.x < rx1 - border
465 && fc.y > ry0 + border
466 && fc.y < ry1 - border;
467 if !strictly_inside {
468 continue;
469 }
470 let found = sub.iter().any(|sc| {
473 bit_eq(sc.response, fc.response)
474 && (sc.x - fc.x).abs() < 1e-3
475 && (sc.y - fc.y).abs() < 1e-3
476 });
477 assert!(
478 found,
479 "interior corner ({:.4},{:.4}) r={:.4} missing or outside parity tolerance in ROI result",
480 fc.x, fc.y, fc.response
481 );
482 checked += 1;
483 }
484 assert!(
485 checked >= 4,
486 "expected several strictly-interior corners to compare, got {checked}"
487 );
488 }
489
490 #[test]
494 fn chess_roi_coordinates_are_global() {
495 let size = 100usize;
496 let img = aa_chessboard(size, 12, (0.35, 0.7), 20, 220);
497 let mut det = Detector::new(DetectorConfig::chess()).unwrap();
498
499 let full = det.detect_u8(&img, size as u32, size as u32).unwrap();
500 let sub = det
501 .detect_u8_roi(&img, size as u32, size as u32, roi(48, 48, 92, 92))
502 .unwrap();
503
504 assert!(!sub.is_empty(), "expected corners in the interior ROI");
505 for sc in &sub {
506 let matched = full
507 .iter()
508 .any(|fc| (fc.x - sc.x).abs() < 0.01 && (fc.y - sc.y).abs() < 0.01);
509 assert!(
510 matched,
511 "ROI corner ({:.3},{:.3}) does not match any full-frame (global) corner",
512 sc.x, sc.y
513 );
514 }
515 }
516
517 #[test]
518 fn roi_edges_and_out_of_bounds() {
519 let size = 100usize;
520 let img = aa_chessboard(size, 12, (0.35, 0.7), 20, 220);
521 let mut det = Detector::new(DetectorConfig::chess()).unwrap();
522 let w = size as u32;
523
524 let border_roi = det.detect_u8_roi(&img, w, w, roi(0, 0, 45, 45)).unwrap();
527 assert!(
528 !border_roi.is_empty(),
529 "expected corners in a border-touching ROI"
530 );
531
532 let tiny = det.detect_u8_roi(&img, w, w, roi(50, 50, 53, 53)).unwrap();
534 assert!(tiny.is_empty(), "sub-support ROI must yield no corners");
535
536 let _clamped = det
539 .detect_u8_roi(&img, w, w, roi(80, 80, 300, 300))
540 .unwrap();
541
542 let gone = det
544 .detect_u8_roi(&img, w, w, roi(150, 150, 300, 300))
545 .unwrap();
546 assert!(
547 gone.is_empty(),
548 "fully out-of-range ROI must yield no corners"
549 );
550 }
551
552 #[test]
553 fn roi_respects_orientation_config() {
554 let size = 100usize;
555 let img = aa_chessboard(size, 12, (0.35, 0.7), 20, 220);
556 let w = size as u32;
557 let region = roi(24, 24, 84, 84);
558
559 let mut with_axes = Detector::new(DetectorConfig::chess()).unwrap();
560 let described = with_axes.detect_u8_roi(&img, w, w, region).unwrap();
561 assert!(!described.is_empty());
562 assert!(
563 described.iter().all(|c| c.axes.is_some()),
564 "default config must attach orientation axes"
565 );
566
567 let mut no_axes = Detector::new(DetectorConfig::chess().without_orientation()).unwrap();
568 let bare = no_axes.detect_u8_roi(&img, w, w, region).unwrap();
569 assert!(!bare.is_empty());
570 assert!(
571 bare.iter().all(|c| c.axes.is_none()),
572 "without_orientation() must skip the orientation fit"
573 );
574 }
575
576 #[test]
577 fn roi_detection_is_deterministic() {
578 let size = 100usize;
579 let img = aa_chessboard(size, 12, (0.35, 0.7), 20, 220);
580 let w = size as u32;
581 let region = roi(20, 20, 80, 80);
582 let mut det = Detector::new(DetectorConfig::chess()).unwrap();
583
584 let a = det.detect_u8_roi(&img, w, w, region).unwrap();
585 let b = det.detect_u8_roi(&img, w, w, region).unwrap();
586 assert_eq!(a.len(), b.len(), "corner count must be stable");
587 for (ca, cb) in a.iter().zip(&b) {
588 assert!(
589 bit_eq(ca.x, cb.x) && bit_eq(ca.y, cb.y) && bit_eq(ca.response, cb.response),
590 "ROI detection must be bit-deterministic"
591 );
592 }
593 }
594
595 #[test]
600 fn radon_roi_smoke_matches_full_frame() {
601 let size = 129usize;
602 let mut img = aa_chessboard(size, 12, (0.4, 0.6), 30, 220);
603 gaussian_blur(&mut img, size, 1.2);
604 let w = size as u32;
605 let mut det = Detector::new(DetectorConfig::radon()).unwrap();
606
607 let full = det.detect_u8(&img, w, w).unwrap();
608 let sub = det.detect_u8_roi(&img, w, w, roi(36, 36, 96, 96)).unwrap();
609
610 assert!(!sub.is_empty(), "Radon ROI detection must find corners");
611 for sc in &sub {
612 let near = full
613 .iter()
614 .any(|fc| (fc.x - sc.x).abs() < 2.0 && (fc.y - sc.y).abs() < 2.0);
615 assert!(
616 near,
617 "Radon ROI corner ({:.2},{:.2}) has no nearby full-frame corner",
618 sc.x, sc.y
619 );
620 }
621 }
622
623 #[test]
631 fn radon_roi_keeps_boundary_peaks() {
632 let size = 129usize;
633 let mut img = aa_chessboard(size, 12, (0.4, 0.6), 30, 220);
634 gaussian_blur(&mut img, size, 1.2);
635 let w = size as u32;
636
637 for upsample in [1u32, 2] {
638 let cfg = DetectorConfig::radon().with_radon(|r| r.image_upsample = upsample);
639 let mut det = Detector::new(cfg).unwrap();
640 let full = det.detect_u8(&img, w, w).unwrap();
641 assert!(!full.is_empty(), "full-frame Radon must find corners");
642
643 let mut checked = 0usize;
650 for fc in &full {
651 let px = fc.x.round();
652 let py = fc.y.round();
653 let (fx, fy) = (fc.x - px, fc.y - py);
654 if fx.abs() < 0.05 || fy.abs() < 0.05 {
655 continue;
656 }
657 let (px, py) = (px as usize, py as usize);
658 let (x0, x1) = if fx > 0.0 {
659 (px, (px + 50).min(size))
660 } else {
661 ((px + 1).saturating_sub(50), px + 1)
662 };
663 let (y0, y1) = if fy > 0.0 {
664 (py, (py + 50).min(size))
665 } else {
666 ((py + 1).saturating_sub(50), py + 1)
667 };
668 let region = roi(x0, y0, x1, y1);
669 let sub = det.detect_u8_roi(&img, w, w, region).unwrap();
670 let found = sub
671 .iter()
672 .any(|sc| (sc.x - fc.x).abs() < 0.5 && (sc.y - fc.y).abs() < 0.5);
673 assert!(
674 found,
675 "upsample={upsample}: boundary corner ({:.3},{:.3}) dropped from ROI ({x0},{y0})-({x1},{y1})",
676 fc.x, fc.y
677 );
678 checked += 1;
679 }
680 assert!(
681 checked >= 3,
682 "upsample={upsample}: expected several boundary-peak corners to check, got {checked}"
683 );
684 }
685 }
686
687 #[cfg(feature = "ml-refiner")]
688 #[test]
689 fn roi_rejects_ml_refiner_without_silent_downgrade() {
690 let size = 64usize;
691 let img = synthetic_board(size);
692 let cfg = DetectorConfig::chess().with_chess(|c| c.refiner = crate::ChessRefiner::Ml);
693 let mut det = Detector::new(cfg).unwrap();
694 let err = det
695 .detect_u8_roi(&img, size as u32, size as u32, roi(10, 10, 50, 50))
696 .unwrap_err();
697 assert!(matches!(err, ChessError::RoiRefinerUnsupported));
698 }
699}