use std::collections::HashMap;
const EXPECTED_RADIUS_K_NEIGHBORS: usize = 12;
use image::GrayImage;
use nalgebra::Point2;
use projective_grid::{Coord, predict_grid_position};
use crate::conic::Ellipse;
use crate::detector::MarkerRecord;
use crate::detector::id_correction::{affine_to_image, fit_local_affine};
use crate::detector::marker_build::DetectionSource;
use crate::homography::homography_project as project;
use crate::marker::codec::Codebook;
use crate::ring::edge_sample::EdgeSampleResult;
use super::{
CompletionConfig, DetectConfig, OuterFitCandidate,
fit_outer_candidate_from_prior_for_completion,
marker_build::{decode_metrics_from_result, fit_metrics_with_inner, fit_support_score},
median_neighbor_outer_radius_px,
};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct CompletionStats {
pub n_candidates_total: usize,
pub n_in_image: usize,
pub n_attempted: usize,
pub n_added: usize,
pub n_failed_fit: usize,
pub n_failed_gate: usize,
pub n_decode_mismatch: usize,
}
struct CandidateQuality {
center: [f64; 2],
arc_cov: f32,
fit_confidence: f32,
mean_axis: f32,
scale_ok: bool,
reproj_err: f32,
max_angular_gap_outer: f64,
radii_cv: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
enum CompletionGateRejectReason {
ArcCoverageLow,
FitConfidenceLow,
ReprojectionTooHigh,
ScaleOutOfRange,
PerfectDecodeRequired,
AngularGapTooLarge,
RadiiScatterTooHigh,
}
impl CompletionGateRejectReason {
const fn code(self) -> &'static str {
match self {
Self::ArcCoverageLow => "arc_coverage_low",
Self::FitConfidenceLow => "fit_confidence_low",
Self::ReprojectionTooHigh => "reprojection_too_high",
Self::ScaleOutOfRange => "scale_out_of_range",
Self::PerfectDecodeRequired => "perfect_decode_required",
Self::AngularGapTooLarge => "angular_gap_too_large",
Self::RadiiScatterTooHigh => "radii_scatter_too_high",
}
}
}
impl std::fmt::Display for CompletionGateRejectReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.code())
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum CompletionGateRejectContext {
ArcCoverageLow {
observed_arc_coverage: f32,
min_required_arc_coverage: f32,
},
FitConfidenceLow {
observed_fit_confidence: f32,
min_required_fit_confidence: f32,
},
ReprojectionTooHigh {
observed_reproj_error_px: f32,
max_allowed_reproj_error_px: f32,
},
ScaleOutOfRange {
observed_mean_axis_px: f32,
expected_radius_px: f32,
min_allowed_axis_px: f32,
max_allowed_axis_px: f32,
},
AngularGapTooLarge {
observed_gap_rad: f64,
max_allowed_gap_rad: f64,
},
RadiiScatterTooHigh {
observed_radii_cv: f32,
max_allowed_radii_cv: f32,
},
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CompletionGateReject {
reason: CompletionGateRejectReason,
context: CompletionGateRejectContext,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
enum CompletionDecodeNoticeReason {
DecodeMismatchAccepted,
}
impl CompletionDecodeNoticeReason {
const fn code(self) -> &'static str {
match self {
Self::DecodeMismatchAccepted => "decode_mismatch_accepted",
}
}
}
impl std::fmt::Display for CompletionDecodeNoticeReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.code())
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CompletionDecodeNotice {
reason: CompletionDecodeNoticeReason,
expected_id: usize,
observed_id: usize,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum CompletionTarget {
Id(usize),
Cell([i32; 2]),
}
impl std::fmt::Display for CompletionTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Id(id) => write!(f, "id={id}"),
Self::Cell([u, v]) => write!(f, "cell=({u},{v})"),
}
}
}
fn radii_coefficient_of_variation(radii: &[f32]) -> f32 {
if radii.len() < 2 {
return 0.0;
}
let mean = radii.iter().sum::<f32>() / radii.len() as f32;
if mean < 1.0 {
return 0.0;
}
let variance = radii.iter().map(|r| (r - mean).powi(2)).sum::<f32>() / radii.len() as f32;
variance.sqrt() / mean
}
fn local_affine_completion_seed(
target_board_xy: [f64; 2],
markers: &[MarkerRecord],
target: &crate::target::TargetLayout,
) -> Option<[f64; 2]> {
local_affine_seed_from_neighbors(
target_board_xy,
markers.iter().filter_map(|marker| {
let id = marker.id?;
let board_xy = target.xy_mm_of_id(id)?;
Some((
[f64::from(board_xy[0]), f64::from(board_xy[1])],
marker.center,
))
}),
)
}
fn local_affine_frame_seed(
target_frame_xy: [f64; 2],
markers: &[MarkerRecord],
) -> Option<[f64; 2]> {
local_affine_seed_from_neighbors(
target_frame_xy,
markers.iter().filter_map(|marker| {
marker.grid_coord?;
Some((marker.board_xy_mm?, marker.center))
}),
)
}
fn local_affine_seed_from_neighbors(
target_board_xy: [f64; 2],
neighbor_source: impl Iterator<Item = ([f64; 2], [f64; 2])>,
) -> Option<[f64; 2]> {
const MAX_NEIGHBORS: usize = 4;
let mut neighbors: Vec<([f64; 2], [f64; 2], f64)> = neighbor_source
.filter_map(|(board_xy, center)| {
if !(center[0].is_finite() && center[1].is_finite()) {
return None;
}
let dx = board_xy[0] - target_board_xy[0];
let dy = board_xy[1] - target_board_xy[1];
let d2 = dx * dx + dy * dy;
d2.is_finite().then_some((board_xy, center, d2))
})
.collect();
if neighbors.len() < 3 {
return None;
}
neighbors.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
neighbors.truncate(MAX_NEIGHBORS);
let board_pts: Vec<[f64; 2]> = neighbors.iter().map(|(board_xy, _, _)| *board_xy).collect();
let image_pts: Vec<[f64; 2]> = neighbors.iter().map(|(_, center, _)| *center).collect();
let affine = fit_local_affine(&board_pts, &image_pts)?;
let seed = affine_to_image(&affine, target_board_xy);
(seed[0].is_finite() && seed[1].is_finite()).then_some(seed)
}
fn lattice_neighbor_seed(
id: usize,
grid: &HashMap<Coord, Point2<f32>>,
target: &crate::target::TargetLayout,
) -> Option<[f64; 2]> {
let coord = target.coord_of_id(id)?;
let predicted = predict_grid_position(grid, coord, target.lattice_kind())?.position;
Some([f64::from(predicted.x), f64::from(predicted.y)])
}
fn projected_completion_seed(
id: usize,
h: &nalgebra::Matrix3<f64>,
markers: &[MarkerRecord],
target: &crate::target::TargetLayout,
grid: &HashMap<Coord, Point2<f32>>,
) -> Option<[f64; 2]> {
let board_xy = target.xy_mm_of_id(id)?;
let target_board_xy = [f64::from(board_xy[0]), f64::from(board_xy[1])];
lattice_neighbor_seed(id, grid, target)
.or_else(|| local_affine_completion_seed(target_board_xy, markers, target))
.or_else(|| Some(project(h, target_board_xy[0], target_board_xy[1])))
}
fn compute_candidate_quality(
edge: &EdgeSampleResult,
outer: &Ellipse,
outer_ransac: Option<&crate::conic::RansacResult>,
projected_center: [f64; 2],
r_expected: f32,
) -> CandidateQuality {
let center = outer.center();
let arc_cov = edge.n_good_rays as f32 / edge.n_total_rays.max(1) as f32;
let fit_confidence = fit_support_score(edge, outer_ransac);
let mean_axis = ((outer.a + outer.b) * 0.5) as f32;
let scale_ok = mean_axis.is_finite()
&& mean_axis >= (r_expected * 0.75)
&& mean_axis <= (r_expected * 1.33);
let reproj_err = {
let dx = center[0] - projected_center[0];
let dy = center[1] - projected_center[1];
(dx * dx + dy * dy).sqrt() as f32
};
let max_angular_gap_outer = super::outer_fit::max_angular_gap(center, &edge.outer_points);
let radii_cv = radii_coefficient_of_variation(&edge.outer_radii);
CandidateQuality {
center,
arc_cov,
fit_confidence,
mean_axis,
scale_ok,
reproj_err,
max_angular_gap_outer,
radii_cv,
}
}
fn check_decode_gate(
decode_result: Option<&crate::marker::decode::DecodeResult>,
expected_id: usize,
) -> Option<CompletionDecodeNotice> {
if let Some(d) = decode_result
&& d.id != expected_id
{
return Some(CompletionDecodeNotice {
reason: CompletionDecodeNoticeReason::DecodeMismatchAccepted,
expected_id,
observed_id: d.id,
});
}
None
}
fn check_quality_gates(
quality: &CandidateQuality,
params: &CompletionConfig,
r_expected: f32,
max_angular_gap_rad: f64,
) -> Result<(), CompletionGateReject> {
if quality.arc_cov < params.min_arc_coverage {
return Err(CompletionGateReject {
reason: CompletionGateRejectReason::ArcCoverageLow,
context: CompletionGateRejectContext::ArcCoverageLow {
observed_arc_coverage: quality.arc_cov,
min_required_arc_coverage: params.min_arc_coverage,
},
});
}
if quality.radii_cv > params.max_radii_std_ratio {
return Err(CompletionGateReject {
reason: CompletionGateRejectReason::RadiiScatterTooHigh,
context: CompletionGateRejectContext::RadiiScatterTooHigh {
observed_radii_cv: quality.radii_cv,
max_allowed_radii_cv: params.max_radii_std_ratio,
},
});
}
if quality.fit_confidence < params.min_fit_confidence {
return Err(CompletionGateReject {
reason: CompletionGateRejectReason::FitConfidenceLow,
context: CompletionGateRejectContext::FitConfidenceLow {
observed_fit_confidence: quality.fit_confidence,
min_required_fit_confidence: params.min_fit_confidence,
},
});
}
if (quality.reproj_err as f64) > (params.reproj_gate_px as f64) {
return Err(CompletionGateReject {
reason: CompletionGateRejectReason::ReprojectionTooHigh,
context: CompletionGateRejectContext::ReprojectionTooHigh {
observed_reproj_error_px: quality.reproj_err,
max_allowed_reproj_error_px: params.reproj_gate_px,
},
});
}
if !quality.scale_ok {
let min_allowed_axis_px = r_expected * 0.75;
let max_allowed_axis_px = r_expected * 1.33;
return Err(CompletionGateReject {
reason: CompletionGateRejectReason::ScaleOutOfRange,
context: CompletionGateRejectContext::ScaleOutOfRange {
observed_mean_axis_px: quality.mean_axis,
expected_radius_px: r_expected,
min_allowed_axis_px,
max_allowed_axis_px,
},
});
}
if quality.max_angular_gap_outer > max_angular_gap_rad {
return Err(CompletionGateReject {
reason: CompletionGateRejectReason::AngularGapTooLarge,
context: CompletionGateRejectContext::AngularGapTooLarge {
observed_gap_rad: quality.max_angular_gap_outer,
max_allowed_gap_rad: max_angular_gap_rad,
},
});
}
Ok(())
}
fn evaluate_completion_candidate(
target_cell: CompletionTarget,
cand: &OuterFitCandidate,
projected_center: [f64; 2],
r_expected: f32,
config: &DetectConfig,
active_codebook_min_cyclic_dist: u8,
stats: &mut CompletionStats,
) -> Result<CandidateQuality, ()> {
let params = &config.advanced.completion;
let quality = compute_candidate_quality(
&cand.edge,
&cand.outer,
cand.outer_ransac.as_ref(),
projected_center,
r_expected,
);
if let Err(reject) = check_quality_gates(
&quality,
params,
r_expected,
config.advanced.outer_fit.max_angular_gap_rad,
) {
tracing::trace!(
"Completion {} gate_reject={} context={:?}",
target_cell,
reject.reason,
reject.context
);
stats.n_failed_gate += 1;
return Err(());
}
if params.require_perfect_decode && config.target.is_coded() {
let is_perfect = cand
.decode_result
.as_ref()
.is_some_and(|d| d.dist == 0 && d.margin >= active_codebook_min_cyclic_dist);
if !is_perfect {
tracing::trace!(
"Completion {} gate_reject={} (dist={:?}, margin={:?})",
target_cell,
CompletionGateRejectReason::PerfectDecodeRequired.code(),
cand.decode_result.as_ref().map(|d| d.dist),
cand.decode_result.as_ref().map(|d| d.margin),
);
stats.n_failed_gate += 1;
return Err(());
}
}
Ok(quality)
}
fn assemble_completion_marker(
gray: &GrayImage,
target_cell: CompletionTarget,
cand: &OuterFitCandidate,
quality: &CandidateQuality,
config: &DetectConfig,
mapper: Option<&dyn crate::pixelmap::PixelMapper>,
) -> MarkerRecord {
let inner_fit = super::inner_fit::fit_inner_ellipse_from_outer_hint(
gray,
&cand.outer,
&config.advanced.marker_spec,
mapper,
&config.advanced.inner_fit,
false,
);
let fit = fit_metrics_with_inner(
&cand.edge,
&cand.outer,
cand.outer_ransac.as_ref(),
&inner_fit,
);
let (marker_id, grid_coord) = match target_cell {
CompletionTarget::Id(id) => (Some(id), None),
CompletionTarget::Cell(coord) => (None, Some(coord)),
};
let decode_metrics = decode_metrics_from_result(
cand.decode_result
.as_ref()
.filter(|d| marker_id == Some(d.id)),
);
let confidence = decode_metrics
.as_ref()
.map(|d| d.decode_confidence)
.unwrap_or(quality.fit_confidence);
MarkerRecord {
id: marker_id,
grid_coord,
confidence,
center: quality.center,
ellipse_outer: Some(cand.outer),
ellipse_inner: inner_fit.ellipse_inner,
edge_points_outer: Some(cand.edge.outer_points.clone()),
edge_points_inner: Some(inner_fit.points_inner.clone()),
fit,
decode: decode_metrics,
source: DetectionSource::Completion,
..MarkerRecord::default()
}
}
fn is_center_in_bounds(center: [f64; 2], img_w: f64, img_h: f64, safe_margin: f64) -> bool {
center[0].is_finite()
&& center[1].is_finite()
&& center[0] >= safe_margin
&& center[0] < (img_w - safe_margin)
&& center[1] >= safe_margin
&& center[1] < (img_h - safe_margin)
}
fn try_complete_marker(
gray: &GrayImage,
target_cell: CompletionTarget,
projected_center: [f64; 2],
markers: &[MarkerRecord],
config: &DetectConfig,
mapper: Option<&dyn crate::pixelmap::PixelMapper>,
stats: &mut CompletionStats,
) -> Option<MarkerRecord> {
let active_codebook_min_cyclic_dist =
Codebook::from_profile(config.advanced.decode.codebook_profile).min_cyclic_dist() as u8;
let r_expected = median_neighbor_outer_radius_px(
projected_center,
markers,
EXPECTED_RADIUS_K_NEIGHBORS,
None,
1,
)
.map(|r| r as f32)
.unwrap_or(config.marker_scale.nominal_outer_radius_px());
let cand = match fit_outer_candidate_from_prior_for_completion(
gray,
[projected_center[0] as f32, projected_center[1] as f32],
r_expected,
config,
mapper,
) {
Ok(v) => v,
Err(_) => {
stats.n_failed_fit += 1;
return None;
}
};
let quality = match evaluate_completion_candidate(
target_cell,
&cand,
projected_center,
r_expected,
config,
active_codebook_min_cyclic_dist,
stats,
) {
Ok(q) => q,
Err(()) => return None,
};
if let CompletionTarget::Id(id) = target_cell
&& let Some(notice) = check_decode_gate(cand.decode_result.as_ref(), id)
{
tracing::info!(
"Completion id={} {} expected={} observed={}",
id,
notice.reason,
notice.expected_id,
notice.observed_id
);
stats.n_decode_mismatch += 1;
}
tracing::debug!(
"Completion added {} reproj_err={:.2}px",
target_cell,
quality.reproj_err
);
Some(assemble_completion_marker(
gray,
target_cell,
&cand,
&quality,
config,
mapper,
))
}
pub(crate) fn complete_with_h(
gray: &GrayImage,
h: &nalgebra::Matrix3<f64>,
markers: &mut Vec<MarkerRecord>,
config: &DetectConfig,
target: &crate::target::TargetLayout,
mapper: Option<&dyn crate::pixelmap::PixelMapper>,
) -> CompletionStats {
use std::collections::HashSet;
let params = &config.advanced.completion;
if !params.enable {
return CompletionStats::default();
}
let (w, h_img) = gray.dimensions();
let w_f = w as f64;
let h_f = h_img as f64;
let roi_radius = params.roi_radius_px.clamp(8.0, 200.0) as f64;
let safe_margin = roi_radius + params.image_margin_px.max(0.0) as f64;
let present_ids: HashSet<usize> = markers.iter().filter_map(|m| m.id).collect();
let grid = crate::pipeline::build_grid_map(markers, target);
let mut stats = CompletionStats {
n_candidates_total: target.n_cells(),
..Default::default()
};
let mut attempted_fits = 0usize;
for id in target.marker_ids() {
let projected_center = match projected_completion_seed(id, h, markers, target, &grid) {
Some(center) => center,
None => continue,
};
if present_ids.contains(&id) {
continue;
}
if !is_center_in_bounds(projected_center, w_f, h_f, safe_margin) {
continue;
}
stats.n_in_image += 1;
if let Some(max) = params.max_attempts
&& attempted_fits >= max
{
break;
}
attempted_fits += 1;
stats.n_attempted += 1;
let marker = match try_complete_marker(
gray,
CompletionTarget::Id(id),
projected_center,
markers,
config,
mapper,
&mut stats,
) {
Some(m) => m,
None => continue,
};
markers.push(marker);
stats.n_added += 1;
}
tracing::info!(
"Completion: added {} markers (attempted {}, in_image {})",
stats.n_added,
stats.n_attempted,
stats.n_in_image
);
stats
}
const MAX_GROWTH_ROUNDS: usize = 32;
pub(crate) fn complete_plain_with_h(
gray: &GrayImage,
h: &nalgebra::Matrix3<f64>,
markers: &mut Vec<MarkerRecord>,
config: &DetectConfig,
target: &crate::target::TargetLayout,
anchored: bool,
mapper: Option<&dyn crate::pixelmap::PixelMapper>,
) -> CompletionStats {
use std::collections::HashSet;
let params = &config.advanced.completion;
if !params.enable {
return CompletionStats::default();
}
let (w, h_img) = gray.dimensions();
let w_f = w as f64;
let h_f = h_img as f64;
let roi_radius = params.roi_radius_px.clamp(8.0, 200.0) as f64;
let safe_margin = roi_radius + params.image_margin_px.max(0.0) as f64;
let mut present: HashSet<Coord> = markers
.iter()
.filter_map(|m| m.grid_coord.map(|c| Coord::new(c[0], c[1])))
.collect();
if present.is_empty() {
return CompletionStats::default();
}
let frame_xy = |coord: Coord| -> Option<[f64; 2]> {
if anchored {
let xy = target.cell_xy_mm(coord)?;
Some([f64::from(xy[0]), f64::from(xy[1])])
} else {
Some(crate::pipeline::frame_xy_mm(target, coord))
}
};
let lattice_kind = target.lattice_kind();
let mut stats = CompletionStats::default();
let mut tried: HashSet<Coord> = HashSet::new();
let mut attempted_fits = 0usize;
let mut capped = false;
for _round in 0..MAX_GROWTH_ROUNDS {
let Some(candidates) = plain_candidate_coords(anchored, target, &present) else {
break;
};
let grid = crate::pipeline::build_grid_map(markers, target);
let mut added_this_round = 0usize;
for coord in candidates {
if present.contains(&coord) || tried.contains(&coord) {
continue;
}
stats.n_candidates_total += 1;
let Some(target_frame_xy) = frame_xy(coord) else {
continue;
};
let projected_center = predict_grid_position(&grid, coord, lattice_kind)
.map(|p| [f64::from(p.position.x), f64::from(p.position.y)])
.or_else(|| local_affine_frame_seed(target_frame_xy, markers))
.unwrap_or_else(|| project(h, target_frame_xy[0], target_frame_xy[1]));
if !is_center_in_bounds(projected_center, w_f, h_f, safe_margin) {
continue;
}
stats.n_in_image += 1;
if let Some(max) = params.max_attempts
&& attempted_fits >= max
{
capped = true;
break;
}
attempted_fits += 1;
stats.n_attempted += 1;
tried.insert(coord);
let marker = match try_complete_marker(
gray,
CompletionTarget::Cell([coord.u, coord.v]),
projected_center,
markers,
config,
mapper,
&mut stats,
) {
Some(mut m) => {
m.board_xy_mm = Some(target_frame_xy);
m
}
None => continue,
};
markers.push(marker);
present.insert(coord);
stats.n_added += 1;
added_this_round += 1;
}
if capped || anchored || added_this_round == 0 {
break;
}
}
tracing::info!(
"Plain completion: added {} markers (attempted {}, in_image {}, anchored {})",
stats.n_added,
stats.n_attempted,
stats.n_in_image,
anchored,
);
stats
}
fn plain_candidate_coords(
anchored: bool,
target: &crate::target::TargetLayout,
present: &std::collections::HashSet<Coord>,
) -> Option<Vec<Coord>> {
if anchored {
return Some(target.cells().iter().map(|cell| cell.coord).collect());
}
let (min, max) = coord_bbox(present.iter().copied())?;
Some(
((min.v - 1)..=(max.v + 1))
.flat_map(|v| ((min.u - 1)..=(max.u + 1)).map(move |u| Coord::new(u, v)))
.collect(),
)
}
fn coord_bbox(coords: impl Iterator<Item = Coord>) -> Option<(Coord, Coord)> {
let mut it = coords;
let first = it.next()?;
let (mut min, mut max) = (first, first);
for c in it {
min.u = min.u.min(c.u);
min.v = min.v.min(c.v);
max.u = max.u.max(c.u);
max.v = max.v.max(c.v);
}
Some((min, max))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conic::Ellipse;
use crate::detector::marker_build::FitMetrics;
#[test]
fn completion_gate_reason_serialization_is_stable() {
let reason = CompletionGateRejectReason::ScaleOutOfRange;
assert_eq!(reason.to_string(), "scale_out_of_range");
let json = serde_json::to_string(&reason).expect("serialize completion gate reason");
assert_eq!(json, "\"scale_out_of_range\"");
}
#[test]
fn completion_quality_gate_reports_typed_arc_coverage_context() {
let quality = CandidateQuality {
center: [0.0, 0.0],
arc_cov: 0.2,
fit_confidence: 0.9,
mean_axis: 20.0,
scale_ok: true,
reproj_err: 0.5,
max_angular_gap_outer: 0.1,
radii_cv: 0.0,
};
let params = CompletionConfig::default();
let reject = check_quality_gates(&quality, ¶ms, 20.0, std::f64::consts::FRAC_PI_2)
.expect_err("expected gate fail");
assert_eq!(reject.reason, CompletionGateRejectReason::ArcCoverageLow);
match reject.context {
CompletionGateRejectContext::ArcCoverageLow {
observed_arc_coverage,
min_required_arc_coverage,
} => {
assert!(observed_arc_coverage < min_required_arc_coverage);
}
other => panic!("unexpected completion gate context: {other:?}"),
}
}
fn marker_with_id(id: usize, center: [f64; 2]) -> MarkerRecord {
MarkerRecord {
id: Some(id),
confidence: 1.0,
center,
ellipse_outer: Some(Ellipse {
cx: center[0],
cy: center[1],
a: 10.0,
b: 10.0,
angle: 0.0,
}),
fit: FitMetrics::default(),
source: DetectionSource::FitDecoded,
..MarkerRecord::default()
}
}
#[test]
fn local_affine_completion_seed_uses_nearest_decoded_neighbors() {
let board = crate::TargetLayout::default_hex();
let target_id = 16usize;
let neighbor_ids = [0usize, 1usize, 14usize, 15usize];
let affine = [[2.0, 0.1, 5.0], [-0.2, 1.5, 7.0]];
let markers: Vec<MarkerRecord> = neighbor_ids
.iter()
.map(|&id| {
let board_xy = board.xy_mm_of_id(id).expect("board xy");
let center =
affine_to_image(&affine, [f64::from(board_xy[0]), f64::from(board_xy[1])]);
marker_with_id(id, center)
})
.collect();
let target_board_xy = board.xy_mm_of_id(target_id).expect("target board xy");
let seed = local_affine_completion_seed(
[f64::from(target_board_xy[0]), f64::from(target_board_xy[1])],
&markers,
&board,
)
.expect("local affine seed");
let expected = affine_to_image(
&affine,
[f64::from(target_board_xy[0]), f64::from(target_board_xy[1])],
);
assert!((seed[0] - expected[0]).abs() < 1e-6);
assert!((seed[1] - expected[1]).abs() < 1e-6);
}
#[test]
fn projected_completion_seed_falls_back_to_h_with_fewer_than_three_neighbors() {
let board = crate::TargetLayout::default_hex();
let target_id = 16usize;
let markers = vec![
marker_with_id(0, [11.0, 7.0]),
marker_with_id(1, [19.0, 7.5]),
];
let h = nalgebra::Matrix3::new(1.0, 0.0, 3.0, 0.0, 1.0, -4.0, 0.0, 0.0, 1.0);
let target_board_xy = board.xy_mm_of_id(target_id).expect("target board xy");
let hex_grid = crate::pipeline::build_grid_map(&markers, &board);
let seed =
projected_completion_seed(target_id, &h, &markers, &board, &hex_grid).expect("seed");
let expected = project(
&h,
f64::from(target_board_xy[0]),
f64::from(target_board_xy[1]),
);
assert!((seed[0] - expected[0]).abs() < 1e-9);
assert!((seed[1] - expected[1]).abs() < 1e-9);
}
}