use std::collections::HashMap;
use projective_grid::{Coord, LatticeKind};
use super::error::TargetValidationError;
use super::fiducials::OriginFiducials;
use super::lattice::{HexGeometry, LatticeGeometry, RectGeometry};
use super::ring::{CodedRingSpec, MarkerCoding, RingGeometry};
const DEFAULT_HEX_NAME: &str = "ringgrid_200mm_hex";
const DEFAULT_HEX_PITCH_MM: f32 = 8.0;
const DEFAULT_HEX_ROWS: usize = 15;
const DEFAULT_HEX_LONG_ROW_COLS: usize = 14;
const DEFAULT_OUTER_RADIUS_MM: f32 = 4.8;
const DEFAULT_INNER_RADIUS_MM: f32 = 3.2;
const DEFAULT_RING_WIDTH_MM: f32 = 1.152;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TargetCell {
pub coord: Coord,
pub xy_mm: [f32; 2],
pub id: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct TargetLayout {
name: String,
lattice: LatticeGeometry,
marker: RingGeometry,
coding: MarkerCoding,
fiducials: Option<OriginFiducials>,
cells: Vec<TargetCell>,
id_to_idx: HashMap<usize, usize>,
coord_to_idx: HashMap<Coord, usize>,
}
impl TargetLayout {
pub fn new(
name: impl Into<String>,
lattice: LatticeGeometry,
marker: RingGeometry,
coding: MarkerCoding,
fiducials: Option<OriginFiducials>,
) -> Result<Self, TargetValidationError> {
let name = name.into();
if name.trim().is_empty() {
return Err(TargetValidationError::EmptyName);
}
lattice.validate()?;
marker.validate()?;
let raw_cells = lattice.generate_cells()?;
coding.validate(&marker, raw_cells.len())?;
let min_center_spacing = lattice.min_center_spacing_mm();
if marker.outer_radius_mm * 2.0 >= min_center_spacing {
return Err(
TargetValidationError::OuterDiameterExceedsMinCenterSpacing {
marker_outer_diameter_mm: marker.outer_radius_mm * 2.0,
min_center_spacing_mm: min_center_spacing,
},
);
}
let draw_diameter_mm = 2.0 * coding.outer_draw_radius_mm(&marker);
if draw_diameter_mm >= min_center_spacing {
return Err(
TargetValidationError::MarkerDrawDiameterExceedsMinCenterSpacing {
marker_draw_diameter_mm: draw_diameter_mm,
min_center_spacing_mm: min_center_spacing,
},
);
}
if let Some(fiducials) = &fiducials {
let positions: Vec<[f32; 2]> = raw_cells.iter().map(|(_, xy)| *xy).collect();
fiducials.validate(
lattice.kind(),
&positions,
coding.outer_draw_radius_mm(&marker),
)?;
}
let assignment = match &coding {
MarkerCoding::Coded16(spec) => spec.id_assignment.as_deref(),
MarkerCoding::Plain => None,
};
let cells: Vec<TargetCell> = raw_cells
.into_iter()
.enumerate()
.map(|(idx, (coord, xy_mm))| TargetCell {
coord,
xy_mm,
id: coding.is_coded().then(|| match assignment {
Some(ids) => ids[idx],
None => idx,
}),
})
.collect();
let id_to_idx = cells
.iter()
.enumerate()
.filter_map(|(idx, cell)| cell.id.map(|id| (id, idx)))
.collect();
let coord_to_idx = cells
.iter()
.enumerate()
.map(|(idx, cell)| (cell.coord, idx))
.collect();
Ok(Self {
name,
lattice,
marker,
coding,
fiducials,
cells,
id_to_idx,
coord_to_idx,
})
}
pub fn coded_hex(
pitch_mm: f32,
rows: usize,
long_row_cols: usize,
outer_radius_mm: f32,
inner_radius_mm: f32,
ring_width_mm: f32,
) -> Result<Self, TargetValidationError> {
Self::new(
hex_generated_name(
pitch_mm,
rows,
long_row_cols,
outer_radius_mm,
inner_radius_mm,
ring_width_mm,
),
LatticeGeometry::Hex(HexGeometry {
rows,
long_row_cols,
pitch_mm,
}),
RingGeometry {
outer_radius_mm,
inner_radius_mm,
},
MarkerCoding::Coded16(CodedRingSpec {
ring_width_mm,
id_assignment: None,
}),
None,
)
}
pub fn default_hex() -> Self {
Self::new(
DEFAULT_HEX_NAME,
LatticeGeometry::Hex(HexGeometry {
rows: DEFAULT_HEX_ROWS,
long_row_cols: DEFAULT_HEX_LONG_ROW_COLS,
pitch_mm: DEFAULT_HEX_PITCH_MM,
}),
RingGeometry {
outer_radius_mm: DEFAULT_OUTER_RADIUS_MM,
inner_radius_mm: DEFAULT_INNER_RADIUS_MM,
},
MarkerCoding::Coded16(CodedRingSpec {
ring_width_mm: DEFAULT_RING_WIDTH_MM,
id_assignment: None,
}),
None,
)
.expect("default hex target spec must be valid")
}
pub fn rect_24x24() -> Self {
Self::new(
"rect_24x24",
LatticeGeometry::Rect(RectGeometry {
rows: 24,
cols: 24,
pitch_mm: 14.0,
}),
RingGeometry {
outer_radius_mm: 5.6,
inner_radius_mm: 2.8,
},
MarkerCoding::Plain,
Some(OriginFiducials {
dot_radius_mm: 1.4,
dots_mm: vec![[161.0, 161.0], [147.0, 161.0], [161.0, 175.0]],
}),
)
.expect("rect_24x24 preset must be valid")
}
pub fn name(&self) -> &str {
&self.name
}
pub fn lattice(&self) -> &LatticeGeometry {
&self.lattice
}
pub fn lattice_kind(&self) -> LatticeKind {
self.lattice.kind()
}
pub fn ring(&self) -> RingGeometry {
self.marker
}
pub fn coding(&self) -> &MarkerCoding {
&self.coding
}
pub fn fiducials(&self) -> Option<&OriginFiducials> {
self.fiducials.as_ref()
}
pub fn is_coded(&self) -> bool {
self.coding.is_coded()
}
pub fn pitch_mm(&self) -> f32 {
self.lattice.pitch_mm()
}
pub fn min_center_spacing_mm(&self) -> f32 {
self.lattice.min_center_spacing_mm()
}
pub fn outer_draw_radius_mm(&self) -> f32 {
self.coding.outer_draw_radius_mm(&self.marker)
}
pub fn code_band_bounds_mm(&self) -> Option<(f32, f32)> {
self.coding.code_band_bounds_mm(&self.marker)
}
pub fn cells(&self) -> &[TargetCell] {
&self.cells
}
pub fn n_cells(&self) -> usize {
self.cells.len()
}
pub fn cell_xy_mm(&self, coord: Coord) -> Option<[f32; 2]> {
self.coord_to_idx.get(&coord).map(|&i| self.cells[i].xy_mm)
}
pub fn id_of(&self, coord: Coord) -> Option<usize> {
self.coord_to_idx
.get(&coord)
.and_then(|&i| self.cells[i].id)
}
pub fn coord_of_id(&self, id: usize) -> Option<Coord> {
self.id_to_idx.get(&id).map(|&i| self.cells[i].coord)
}
pub fn xy_mm_of_id(&self, id: usize) -> Option<[f32; 2]> {
self.id_to_idx.get(&id).map(|&i| self.cells[i].xy_mm)
}
pub fn cell_of_id(&self, id: usize) -> Option<&TargetCell> {
self.id_to_idx.get(&id).map(|&i| &self.cells[i])
}
pub fn marker_ids(&self) -> impl Iterator<Item = usize> + '_ {
self.cells.iter().filter_map(|cell| cell.id)
}
pub fn max_marker_id(&self) -> usize {
self.marker_ids().max().unwrap_or(0)
}
pub fn marker_bounds_mm(&self) -> Option<([f32; 2], [f32; 2])> {
let first = self.cells.first()?;
let mut min_xy = first.xy_mm;
let mut max_xy = first.xy_mm;
for cell in &self.cells[1..] {
min_xy[0] = min_xy[0].min(cell.xy_mm[0]);
min_xy[1] = min_xy[1].min(cell.xy_mm[1]);
max_xy[0] = max_xy[0].max(cell.xy_mm[0]);
max_xy[1] = max_xy[1].max(cell.xy_mm[1]);
}
Some((min_xy, max_xy))
}
pub fn marker_span_mm(&self) -> Option<[f32; 2]> {
self.marker_bounds_mm()
.map(|(min_xy, max_xy)| [max_xy[0] - min_xy[0], max_xy[1] - min_xy[1]])
}
}
impl Default for TargetLayout {
fn default() -> Self {
Self::default_hex()
}
}
pub(crate) fn hex_generated_name(
pitch_mm: f32,
rows: usize,
long_row_cols: usize,
outer_radius_mm: f32,
inner_radius_mm: f32,
ring_width_mm: f32,
) -> String {
format!(
"ringgrid_hex_r{rows}_c{long_row_cols}_p{pitch_mm:.3}_o{outer_radius_mm:.3}_i{inner_radius_mm:.3}_w{ring_width_mm:.3}"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn hex_lattice(rows: usize, long_row_cols: usize, pitch_mm: f32) -> LatticeGeometry {
LatticeGeometry::Hex(HexGeometry {
rows,
long_row_cols,
pitch_mm,
})
}
fn coded(ring_width_mm: f32) -> MarkerCoding {
MarkerCoding::Coded16(CodedRingSpec {
ring_width_mm,
id_assignment: None,
})
}
#[test]
fn rect_preset_has_expected_shape() {
let target = TargetLayout::rect_24x24();
assert_eq!(target.n_cells(), 576);
assert!(!target.is_coded());
assert_eq!(target.pitch_mm(), 14.0);
assert_eq!(target.min_center_spacing_mm(), 14.0);
assert_eq!(target.outer_draw_radius_mm(), 5.6);
assert_eq!(target.code_band_bounds_mm(), None);
let span = target.marker_span_mm().expect("span");
assert_eq!(span, [322.0, 322.0]);
assert_eq!(
target.cell_xy_mm(projective_grid::Coord::new(0, 0)),
Some([0.0, 0.0])
);
assert_eq!(
target.cell_xy_mm(projective_grid::Coord::new(23, 23)),
Some([322.0, 322.0])
);
let fiducials = target.fiducials().expect("preset has dots");
assert_eq!(fiducials.dots_mm.len(), 3);
assert_eq!(fiducials.dot_radius_mm, 1.4);
assert_eq!(target.marker_ids().count(), 0);
assert_eq!(target.id_of(projective_grid::Coord::new(0, 0)), None);
}
#[test]
fn coded_ids_are_sequential_without_assignment() {
let target = TargetLayout::new(
"t",
hex_lattice(3, 4, 8.0),
RingGeometry {
outer_radius_mm: 4.8,
inner_radius_mm: 3.2,
},
coded(1.152),
None,
)
.expect("valid");
for (idx, cell) in target.cells().iter().enumerate() {
assert_eq!(cell.id, Some(idx));
}
assert_eq!(target.coord_of_id(0), Some(target.cells()[0].coord));
assert_eq!(target.xy_mm_of_id(0), Some([0.0, 0.0]));
}
#[test]
fn rejects_codebook_capacity_overflow() {
let err = TargetLayout::new(
"t",
LatticeGeometry::Rect(RectGeometry {
rows: 40,
cols: 40,
pitch_mm: 14.0,
}),
RingGeometry {
outer_radius_mm: 5.6,
inner_radius_mm: 2.8,
},
coded(0.8),
None,
)
.expect_err("capacity exceeded");
assert!(matches!(
err,
TargetValidationError::CodebookCapacityExceeded { n_cells: 1600, .. }
));
TargetLayout::new(
"t",
LatticeGeometry::Rect(RectGeometry {
rows: 40,
cols: 40,
pitch_mm: 14.0,
}),
RingGeometry {
outer_radius_mm: 5.6,
inner_radius_mm: 2.8,
},
MarkerCoding::Plain,
None,
)
.expect("plain has no capacity limit");
}
#[test]
fn rejects_rotationally_symmetric_fiducials() {
let err = TargetLayout::new(
"t",
LatticeGeometry::Rect(RectGeometry {
rows: 4,
cols: 4,
pitch_mm: 14.0,
}),
RingGeometry {
outer_radius_mm: 5.6,
inner_radius_mm: 2.8,
},
MarkerCoding::Plain,
Some(OriginFiducials {
dot_radius_mm: 1.4,
dots_mm: vec![[21.0, 21.0]],
}),
)
.expect_err("symmetric dots");
assert!(matches!(
err,
TargetValidationError::FiducialsRotationallySymmetric { .. }
));
TargetLayout::new(
"t",
LatticeGeometry::Rect(RectGeometry {
rows: 4,
cols: 4,
pitch_mm: 14.0,
}),
RingGeometry {
outer_radius_mm: 5.6,
inner_radius_mm: 2.8,
},
MarkerCoding::Plain,
Some(OriginFiducials {
dot_radius_mm: 1.4,
dots_mm: vec![[21.0, 21.0], [7.0, 21.0]],
}),
)
.expect("asymmetric dots are valid");
}
#[test]
fn rejects_dot_overlapping_marker() {
let err = TargetLayout::new(
"t",
LatticeGeometry::Rect(RectGeometry {
rows: 4,
cols: 4,
pitch_mm: 14.0,
}),
RingGeometry {
outer_radius_mm: 5.6,
inner_radius_mm: 2.8,
},
MarkerCoding::Plain,
Some(OriginFiducials {
dot_radius_mm: 1.4,
dots_mm: vec![[14.0, 14.0]],
}),
)
.expect_err("dot on marker");
assert!(matches!(
err,
TargetValidationError::DotOverlapsMarker { index: 0 }
));
}
#[test]
fn rejects_empty_fiducial_dots_and_bad_radius() {
let make = |dot_radius_mm: f32, dots_mm: Vec<[f32; 2]>| {
TargetLayout::new(
"t",
LatticeGeometry::Rect(RectGeometry {
rows: 4,
cols: 4,
pitch_mm: 14.0,
}),
RingGeometry {
outer_radius_mm: 5.6,
inner_radius_mm: 2.8,
},
MarkerCoding::Plain,
Some(OriginFiducials {
dot_radius_mm,
dots_mm,
}),
)
};
assert!(matches!(
make(1.4, vec![]),
Err(TargetValidationError::EmptyFiducialDots)
));
assert!(matches!(
make(0.0, vec![[21.0, 21.0]]),
Err(TargetValidationError::InvalidDotRadius { .. })
));
assert!(matches!(
make(1.4, vec![[f32::NAN, 21.0]]),
Err(TargetValidationError::NonFiniteDot { index: 0 })
));
}
#[test]
fn validation_rejects_degenerate_geometry() {
let ring = |outer, inner| RingGeometry {
outer_radius_mm: outer,
inner_radius_mm: inner,
};
assert!(matches!(
TargetLayout::new(
"t",
hex_lattice(0, 4, 8.0),
ring(4.8, 3.2),
coded(1.152),
None
),
Err(TargetValidationError::InvalidRows { rows: 0 })
));
assert!(matches!(
TargetLayout::new(
"t",
hex_lattice(3, 1, 8.0),
ring(4.8, 3.2),
coded(1.152),
None
),
Err(TargetValidationError::InvalidLongRowColsForRows {
rows: 3,
long_row_cols: 1,
})
));
assert!(matches!(
TargetLayout::new(
"t",
hex_lattice(3, 4, 8.0),
ring(4.8, 4.8),
coded(1.152),
None
),
Err(TargetValidationError::InnerRadiusNotSmallerThanOuter { .. })
));
assert!(matches!(
TargetLayout::new(
"t",
hex_lattice(3, 4, 8.0),
ring(4.8, 4.1),
coded(1.152),
None
),
Err(TargetValidationError::NonPositiveCodeBandGap { .. })
));
assert!(matches!(
TargetLayout::new(
"t",
hex_lattice(3, 4, f32::NAN),
ring(4.8, 3.2),
coded(1.152),
None
),
Err(TargetValidationError::InvalidPitch { .. })
));
assert!(matches!(
TargetLayout::new(
"t",
hex_lattice(3, 4, 5.0),
ring(4.0, 2.0),
coded(1.152),
None
),
Err(TargetValidationError::MarkerDrawDiameterExceedsMinCenterSpacing { .. })
));
assert!(matches!(
TargetLayout::new(
"t",
hex_lattice(3, 4, 8.0),
ring(4.8, 3.2),
coded(0.0),
None
),
Err(TargetValidationError::InvalidRingWidth { .. })
));
assert!(matches!(
TargetLayout::new(
"",
hex_lattice(3, 4, 8.0),
ring(4.8, 3.2),
coded(1.152),
None
),
Err(TargetValidationError::EmptyName)
));
}
}