projective_grid/square/grow.rs
1//! Generic BFS-style growth from a 2×2 seed over a square lattice.
2//!
3//! The growth algorithm — BFS queue, KD-tree candidate search, per-
4//! neighbour prediction averaging, ambiguity filtering — is pure
5//! geometry and works for any square-grid pattern. Pattern-specific
6//! invariants (parity rules, axis clustering, marker constraints)
7//! plug in via the [`GrowValidator`] trait.
8//!
9//! # Design
10//!
11//! The generic function manages:
12//! - The labelled `(i, j) → corner_index` map.
13//! - The BFS boundary queue and "seen" set.
14//! - A KD-tree over eligible candidate positions.
15//! - Per-neighbour prediction averaging (grid vectors `u`, `v`).
16//! - Ambiguity resolution (nearest vs second-nearest ratio).
17//! - Final rebase so the bounding-box minimum is `(0, 0)`.
18//!
19//! The validator is asked four questions:
20//! - **`is_eligible(idx)`** — can this corner index be considered as
21//! a candidate at all? (typically: pre-filtered / in a cluster / not
22//! blacklisted)
23//! - **`required_label_at(i, j)`** — what pattern label is required at
24//! this grid cell? Opaque `u8`; the validator picks the scheme.
25//! `None` means "no label constraint".
26//! - **`accept_candidate(idx, at, prediction, neighbours)`** — once
27//! the generic search has found a candidate passing geometric
28//! checks, is it pattern-legal?
29//! - **`edge_ok(candidate_idx, neighbour_idx, at_cand, at_neigh)`** —
30//! soft per-edge check at attachment time.
31//!
32//! # Non-goals
33//!
34//! This function does **not** do post-growth validation (line
35//! collinearity / local-H residuals). See
36//! [`crate::square::validate`](mod@crate::square::validate) for
37//! that.
38
39use kiddo::{KdTree, SquaredEuclidean};
40use nalgebra::{Point2, Vector2};
41use std::collections::{HashMap, HashSet, VecDeque};
42
43pub use crate::square::seed::Seed;
44
45/// Per-candidate decision from a [`GrowValidator`].
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum Admit {
48 /// Accept this candidate at the given grid cell.
49 Accept,
50 /// Reject this candidate; the generic code may move on to the
51 /// next nearest (if any).
52 Reject,
53}
54
55/// Information about an existing labelled neighbour, passed to the
56/// validator during candidate evaluation.
57#[derive(Clone, Copy, Debug)]
58pub struct LabelledNeighbour {
59 /// Index of the neighbour corner in the caller's position array.
60 pub idx: usize,
61 /// The neighbour's `(i, j)` grid cell.
62 pub at: (i32, i32),
63 /// The neighbour's position in image pixels.
64 pub position: Point2<f32>,
65}
66
67/// Pattern-specific validation hooks for [`bfs_grow`].
68///
69/// Implementations typically hold references to the caller's corner
70/// data (axes, labels, strengths) plus the pattern's tuning
71/// parameters, and use `idx` to look up the relevant per-corner
72/// record inside each callback.
73pub trait GrowValidator {
74 /// Is this corner index a possible candidate at all? Called
75 /// once per corner when the KD-tree is built.
76 fn is_eligible(&self, idx: usize) -> bool;
77
78 /// Optional pattern-required label at grid cell `(i, j)`.
79 /// Return `None` for no constraint.
80 fn required_label_at(&self, i: i32, j: i32) -> Option<u8>;
81
82 /// Return the label of the corner at `idx`. Must agree with
83 /// `required_label_at` at attachment time. Called during
84 /// candidate filtering.
85 fn label_of(&self, idx: usize) -> Option<u8>;
86
87 /// Accept or reject a candidate for attachment at grid cell
88 /// `at` given its geometric prediction and existing labelled
89 /// neighbours. Called per candidate in order of increasing
90 /// distance to `prediction`.
91 fn accept_candidate(
92 &self,
93 idx: usize,
94 at: (i32, i32),
95 prediction: Point2<f32>,
96 neighbours: &[LabelledNeighbour],
97 ) -> Admit;
98
99 /// Soft per-edge check: is the induced edge between the just-
100 /// attached candidate and one of its cardinal-labelled neighbours
101 /// admissible? At least one cardinal edge must pass for the
102 /// attachment to stick; otherwise the position is marked a hole
103 /// and the candidate is rolled back.
104 ///
105 /// Default: accept all edges (no soft check).
106 fn edge_ok(
107 &self,
108 _candidate_idx: usize,
109 _neighbour_idx: usize,
110 _at_candidate: (i32, i32),
111 _at_neighbour: (i32, i32),
112 ) -> bool {
113 true
114 }
115
116 /// Optional widened eligibility used by the fill-pass booster.
117 ///
118 /// Defaults to [`Self::is_eligible`]; patterns whose precision
119 /// core admits only `Clustered` corners but want to admit a few
120 /// near-cluster corners during the booster pass override this to
121 /// expand the admissible set. The fill pass calls this when
122 /// building its KD-tree; the regular grow / boundary-extension
123 /// passes ignore it.
124 fn eligible_for_fill(&self, idx: usize) -> bool {
125 self.is_eligible(idx)
126 }
127
128 /// Optional fill-pass edge check that has access to the full
129 /// labelled set and the position table via [`FillEdgeCtx`].
130 ///
131 /// The default delegates to [`Self::edge_ok`], ignoring the extra
132 /// context. Pattern implementations that need a directional edge
133 /// metric (e.g., a strongly anisotropic component where the
134 /// horizontal pitch is much larger than the vertical pitch and a
135 /// scalar `cell_size` rejects legitimate vertical extrapolations)
136 /// override this to consult the labelled set when computing the
137 /// expected edge length.
138 ///
139 /// Only invoked by [`crate::square::fill::fill_grid_holes`]; the
140 /// regular grow and boundary-extension passes call [`Self::edge_ok`]
141 /// directly.
142 fn fill_edge_ok(&self, ctx: FillEdgeCtx<'_>) -> bool {
143 self.edge_ok(
144 ctx.candidate_idx,
145 ctx.neighbour_idx,
146 ctx.at_candidate,
147 ctx.at_neighbour,
148 )
149 }
150}
151
152/// Context passed to [`GrowValidator::fill_edge_ok`].
153///
154/// Bundles every piece of state the validator needs to make a
155/// labelled-set-aware edge decision: the candidate + cardinal
156/// neighbour indices, their `(i, j)` cells, the full labelled map,
157/// the corner position array, and the scalar fallback cell size.
158#[non_exhaustive]
159#[derive(Clone, Copy)]
160pub struct FillEdgeCtx<'a> {
161 /// Index of the candidate corner being evaluated.
162 pub candidate_idx: usize,
163 /// Index of the already-labelled cardinal neighbour.
164 pub neighbour_idx: usize,
165 /// The candidate's prospective `(i, j)` cell.
166 pub at_candidate: (i32, i32),
167 /// The cardinal neighbour's `(i, j)` cell.
168 pub at_neighbour: (i32, i32),
169 /// The full `(i, j) → corner_idx` labelled map at this point in the grow.
170 pub labelled: &'a HashMap<(i32, i32), usize>,
171 /// Corner positions in image pixels, indexed by the values of `labelled`.
172 pub positions: &'a [Point2<f32>],
173 /// Scalar fallback cell size in pixels, used when no local estimate exists.
174 pub cell_size: f32,
175}
176
177/// Tolerances for [`bfs_grow`].
178#[non_exhaustive]
179#[derive(Clone, Copy, Debug)]
180pub struct GrowParams {
181 /// Candidate-search radius (fraction of `cell_size`) around each
182 /// prediction. Applies when the target is being **interpolated**
183 /// between labelled neighbours on opposite sides.
184 pub attach_search_rel: f32,
185 /// Ambiguity factor: if the second-nearest candidate is within
186 /// `factor × nearest_distance`, the attachment is skipped.
187 pub attach_ambiguity_factor: f32,
188 /// Multiplier on `attach_search_rel` when the target is being
189 /// **extrapolated** outward from the labelled set (every labelled
190 /// neighbour sits on the same side of the target along at least one
191 /// axis). Defaults to 2.0 — opens the search up enough to absorb
192 /// the perspective-foreshortening overshoot at the image edge while
193 /// still rejecting marker-internal corners which sit several cell-
194 /// widths away.
195 pub boundary_search_factor: f32,
196}
197
198impl Default for GrowParams {
199 fn default() -> Self {
200 Self {
201 attach_search_rel: 0.35,
202 attach_ambiguity_factor: 1.5,
203 boundary_search_factor: 2.0,
204 }
205 }
206}
207
208impl GrowParams {
209 /// Construct grow parameters from the interpolation search radius and
210 /// ambiguity factor; `boundary_search_factor` keeps its default.
211 pub fn new(attach_search_rel: f32, attach_ambiguity_factor: f32) -> Self {
212 Self {
213 attach_search_rel,
214 attach_ambiguity_factor,
215 ..Self::default()
216 }
217 }
218}
219
220/// Outcome of a grow pass.
221#[derive(Debug, Default)]
222pub struct GrowResult {
223 /// `(i, j) → corner_index` map of accepted labels. Rebased so the
224 /// bounding-box minimum is `(0, 0)`.
225 pub labelled: HashMap<(i32, i32), usize>,
226 /// Inverse map.
227 pub by_corner: HashMap<usize, (i32, i32)>,
228 /// Positions with ≥ 2 candidates inside the ambiguity window.
229 pub ambiguous: HashSet<(i32, i32)>,
230 /// Positions with no accepted candidate.
231 pub holes: HashSet<(i32, i32)>,
232 /// Grid `i`-axis vector (pixels per cell) carried forward — overlays
233 /// and boosters use it.
234 pub axis_i: Vector2<f32>,
235 /// Grid `j`-axis vector (pixels per cell) carried forward — overlays
236 /// and boosters use it.
237 pub axis_j: Vector2<f32>,
238 /// Parity shift applied during the post-BFS rebase, modulo 2.
239 ///
240 /// BFS walks in pre-rebase coords where the seed's `(0, 0)` cell
241 /// has its caller-defined parity (e.g., `Canonical` for the
242 /// chessboard convention). After BFS finishes, the labelled map
243 /// is rebased so the bounding-box minimum is `(0, 0)`. If the
244 /// rebase shift `(min_i, min_j)` has odd Manhattan parity (i.e.
245 /// `(min_i + min_j) % 2 == 1`), the rebase flips the parity at
246 /// every cell — what was at an even-parity cell is now at an
247 /// odd-parity cell post-rebase.
248 ///
249 /// For chessboard-style consumers that derive a "required label at
250 /// `(i, j)`" from `(i + j) % 2`, this means the post-rebase
251 /// `required_label_at(i, j)` gives the WRONG answer on every
252 /// cell when `parity_shift == 1`. Such consumers must instead
253 /// query `required_label_at(i + parity_shift_i, j + parity_shift_j)`
254 /// to recover the pre-rebase parity at the post-rebase cell.
255 ///
256 /// `parity_shift_i + parity_shift_j` is always equivalent to
257 /// `parity_shift` mod 2; this struct exposes both individual
258 /// shifts so consumers that depend on the absolute (i, j) parity
259 /// (rare) can adjust without re-deriving them.
260 ///
261 /// For non-chessboard consumers (no parity invariant), this field
262 /// can be ignored.
263 pub parity_shift_i: i32,
264 /// See [`Self::parity_shift_i`].
265 pub parity_shift_j: i32,
266}
267
268/// Grow a labelled `(i, j)` grid from a 2×2 seed using BFS over the
269/// lattice boundary.
270///
271/// `positions` must be indexed 1:1 with the caller's corner array;
272/// the validator uses the same indices.
273///
274/// Returns the labelled map rebased so the bounding-box minimum is
275/// `(0, 0)`. The caller is responsible for any per-corner state
276/// updates after the call (e.g., marking corners as "labelled" in a
277/// local stage enum).
278#[cfg_attr(
279 feature = "tracing",
280 tracing::instrument(
281 level = "info",
282 skip_all,
283 fields(num_corners = positions.len(), cell_size = cell_size),
284 )
285)]
286pub fn bfs_grow<V: GrowValidator>(
287 positions: &[Point2<f32>],
288 seed: Seed,
289 cell_size: f32,
290 params: &GrowParams,
291 validator: &V,
292) -> GrowResult {
293 // Grid unit vectors inferred from the seed corners (pixel space).
294 let axis_i = {
295 let raw = positions[seed.b] - positions[seed.a];
296 let n = raw.norm().max(1e-6);
297 raw / n
298 };
299 let axis_j = {
300 let raw = positions[seed.c] - positions[seed.a];
301 let n = raw.norm().max(1e-6);
302 raw / n
303 };
304
305 // KD-tree over eligible corners.
306 let mut tree: KdTree<f32, 2> = KdTree::new();
307 let mut tree_slot_to_corner: Vec<usize> = Vec::new();
308 for (idx, pos) in positions.iter().enumerate() {
309 if validator.is_eligible(idx) {
310 tree.add(&[pos.x, pos.y], tree_slot_to_corner.len() as u64);
311 tree_slot_to_corner.push(idx);
312 }
313 }
314
315 let mut labelled: HashMap<(i32, i32), usize> = HashMap::new();
316 let mut by_corner: HashMap<usize, (i32, i32)> = HashMap::new();
317 let mut ambiguous: HashSet<(i32, i32)> = HashSet::new();
318 let mut holes: HashSet<(i32, i32)> = HashSet::new();
319
320 for (ij, idx) in [
321 ((0, 0), seed.a),
322 ((1, 0), seed.b),
323 ((0, 1), seed.c),
324 ((1, 1), seed.d),
325 ] {
326 labelled.insert(ij, idx);
327 by_corner.insert(idx, ij);
328 }
329
330 let mut boundary: VecDeque<(i32, i32)> = VecDeque::new();
331 let mut seen_boundary: HashSet<(i32, i32)> = HashSet::new();
332 for ij in labelled.keys().copied().collect::<Vec<_>>() {
333 enqueue_cardinal_neighbours(ij, &labelled, &mut boundary, &mut seen_boundary);
334 }
335
336 while let Some(pos) = boundary.pop_front() {
337 if labelled.contains_key(&pos) {
338 continue;
339 }
340 let ctx = BoundaryCtx {
341 positions,
342 labelled: &labelled,
343 by_corner: &by_corner,
344 tree: &tree,
345 tree_slot_to_corner: &tree_slot_to_corner,
346 axis_i,
347 axis_j,
348 cell_size,
349 params,
350 validator,
351 };
352 let (decision, _neighbours) = process_boundary_cell(pos, &ctx);
353 match decision {
354 BoundaryDecision::Hole | BoundaryDecision::EdgeRejected => {
355 holes.insert(pos);
356 }
357 BoundaryDecision::Ambiguous => {
358 ambiguous.insert(pos);
359 }
360 BoundaryDecision::Attach(c_idx) => {
361 labelled.insert(pos, c_idx);
362 by_corner.insert(c_idx, pos);
363 enqueue_cardinal_neighbours(pos, &labelled, &mut boundary, &mut seen_boundary);
364 }
365 }
366 }
367
368 // Rebase so (min_i, min_j) = (0, 0).
369 let (min_i, min_j) = labelled
370 .keys()
371 .fold((i32::MAX, i32::MAX), |(a, b), &(i, j)| (a.min(i), b.min(j)));
372 if min_i != 0 || min_j != 0 {
373 let rebased: HashMap<(i32, i32), usize> = labelled
374 .into_iter()
375 .map(|((i, j), idx)| ((i - min_i, j - min_j), idx))
376 .collect();
377 let rebased_by_corner: HashMap<usize, (i32, i32)> =
378 rebased.iter().map(|(&ij, &idx)| (idx, ij)).collect();
379 labelled = rebased;
380 by_corner = rebased_by_corner;
381 }
382 let rebase_pos = |(i, j)| (i - min_i, j - min_j);
383 let ambiguous: HashSet<(i32, i32)> = ambiguous.into_iter().map(rebase_pos).collect();
384 let holes: HashSet<(i32, i32)> = holes.into_iter().map(rebase_pos).collect();
385
386 // Parity shifts: when the rebase shifts a coord by an odd amount,
387 // the post-rebase parity at any cell flips relative to pre-rebase.
388 // Chessboard consumers that derive a "required label at (i, j)"
389 // from `(i + j) % 2` must add these shifts back to recover the
390 // pre-rebase parity. Stored mod 2 for clarity; downstream may
391 // also use the simpler combined `(parity_shift_i + parity_shift_j)
392 // % 2` for chessboard parity since the convention only depends on
393 // `(i + j) % 2`.
394 let parity_shift_i = min_i.rem_euclid(2);
395 let parity_shift_j = min_j.rem_euclid(2);
396
397 GrowResult {
398 labelled,
399 by_corner,
400 ambiguous,
401 holes,
402 axis_i,
403 axis_j,
404 parity_shift_i,
405 parity_shift_j,
406 }
407}
408
409pub(super) fn enqueue_cardinal_neighbours(
410 pos: (i32, i32),
411 labelled: &HashMap<(i32, i32), usize>,
412 boundary: &mut VecDeque<(i32, i32)>,
413 seen: &mut HashSet<(i32, i32)>,
414) {
415 for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
416 let neigh = (pos.0 + di, pos.1 + dj);
417 if !labelled.contains_key(&neigh) && seen.insert(neigh) {
418 boundary.push_back(neigh);
419 }
420 }
421}
422
423pub(crate) fn collect_labelled_neighbours(
424 pos: (i32, i32),
425 window_half: i32,
426 labelled: &HashMap<(i32, i32), usize>,
427 positions: &[Point2<f32>],
428) -> Vec<LabelledNeighbour> {
429 let mut out = Vec::new();
430 for dj in -window_half..=window_half {
431 for di in -window_half..=window_half {
432 if di == 0 && dj == 0 {
433 continue;
434 }
435 let at = (pos.0 + di, pos.1 + dj);
436 if let Some(&idx) = labelled.get(&at) {
437 out.push(LabelledNeighbour {
438 idx,
439 at,
440 position: positions[idx],
441 });
442 }
443 }
444 }
445 out
446}
447
448/// Distance-weighted average of per-neighbour axis-vector predictions.
449///
450/// Use this function for in-the-loop BFS attachment where arbitrary
451/// labelled neighbours are available. For post-grow outlier detection
452/// using cardinal midpoint averaging, see
453/// [`crate::square::smoothness::square_predict_grid_position`].
454///
455/// For each labelled neighbour `N_k` at `(i_k, j_k)`, the prediction is
456/// `pred_k = pos(N_k) + (Δi · i_step_k) + (Δj · j_step_k)` where
457/// `Δi = target.i − i_k`, `Δj = target.j − j_k`, and `i_step_k` /
458/// `j_step_k` are the **local** grid-step vectors observed at `N_k`:
459///
460/// - If `(i_k+1, j_k)` and `(i_k−1, j_k)` are both labelled, the i-step is
461/// the central difference `(pos(i_k+1, j_k) − pos(i_k−1, j_k)) / 2`.
462/// - Otherwise, a one-sided difference from whichever neighbour is
463/// labelled.
464/// - Otherwise, fall back to the global `cell_size · u`. Same for j.
465///
466/// This linearises the grid **at every neighbour individually** instead of
467/// trusting the seed's global `(u, v, cell_size)` — critical under strong
468/// perspective foreshortening, where the cell pitch on the far edge of
469/// the labelled set is materially different from the seed's mean. With
470/// the global-only model, BFS predictions on the foreshortened side
471/// overshoot the next true corner by more than the search radius and
472/// growth terminates prematurely.
473///
474/// Predictions are averaged with weights `1 / (Δi² + Δj²)` so cardinal
475/// neighbours (grid distance 1) carry weight 1.0 while diagonal
476/// neighbours (grid distance √2) carry weight 0.5 — variance addition
477/// per grid step.
478///
479/// A neighbour at the target cell itself (`Δi = Δj = 0`) would yield an
480/// infinite weight; in practice [`bfs_grow`] never enqueues such a
481/// neighbour (they're already labelled), but for robustness we treat
482/// `Δi = Δj = 0` as weight 1.0 to avoid `NaN`.
483pub fn predict_from_neighbours(
484 target: (i32, i32),
485 neighbours: &[LabelledNeighbour],
486 u: Vector2<f32>,
487 v: Vector2<f32>,
488 cell_size: f32,
489 labelled: &HashMap<(i32, i32), usize>,
490 positions: &[Point2<f32>],
491) -> Point2<f32> {
492 debug_assert!(!neighbours.is_empty());
493 let global_i_step = u * cell_size;
494 let global_j_step = v * cell_size;
495
496 let mut sum_x = 0.0_f32;
497 let mut sum_y = 0.0_f32;
498 let mut sum_w = 0.0_f32;
499 for n in neighbours {
500 let di = (target.0 - n.at.0) as f32;
501 let dj = (target.1 - n.at.1) as f32;
502 let d2 = di * di + dj * dj;
503 let w = if d2 > 0.0 { 1.0 / d2 } else { 1.0 };
504
505 let i_step = local_step_at(n.at, (1, 0), labelled, positions).unwrap_or(global_i_step);
506 let j_step = local_step_at(n.at, (0, 1), labelled, positions).unwrap_or(global_j_step);
507
508 let off = i_step * di + j_step * dj;
509 sum_x += w * (n.position.x + off.x);
510 sum_y += w * (n.position.y + off.y);
511 sum_w += w;
512 }
513 Point2::new(sum_x / sum_w, sum_y / sum_w)
514}
515
516/// True when every labelled neighbour sits on the same side of `target`
517/// along at least one of the two grid axes — i.e., the target is being
518/// extrapolated outward from the labelled set rather than interpolated
519/// between two opposing sides.
520///
521/// This is the geometric signal that the search prediction is less
522/// reliable: extrapolation accumulates foreshortening error linearly,
523/// while interpolation has neighbours on both sides bracketing the
524/// truth.
525pub(super) fn is_extrapolating(target: (i32, i32), neighbours: &[LabelledNeighbour]) -> bool {
526 let mut has_neg_di = false;
527 let mut has_pos_di = false;
528 let mut has_neg_dj = false;
529 let mut has_pos_dj = false;
530 for n in neighbours {
531 let di = target.0 - n.at.0;
532 let dj = target.1 - n.at.1;
533 if di > 0 {
534 has_neg_di = true; // neighbour is on the −i side of target
535 } else if di < 0 {
536 has_pos_di = true;
537 }
538 if dj > 0 {
539 has_neg_dj = true;
540 } else if dj < 0 {
541 has_pos_dj = true;
542 }
543 }
544 !(has_neg_di && has_pos_di && has_neg_dj && has_pos_dj)
545}
546
547/// Estimate the local grid-step vector at labelled cell `at` along
548/// direction `step = (di, dj)` using a finite-difference of labelled
549/// neighbours. Returns `None` when neither the forward nor backward
550/// neighbour is labelled.
551pub(super) fn local_step_at(
552 at: (i32, i32),
553 step: (i32, i32),
554 labelled: &HashMap<(i32, i32), usize>,
555 positions: &[Point2<f32>],
556) -> Option<Vector2<f32>> {
557 let here = labelled.get(&at).map(|&i| positions[i])?;
558 let fwd = (at.0 + step.0, at.1 + step.1);
559 let bwd = (at.0 - step.0, at.1 - step.1);
560 let fwd_pos = labelled.get(&fwd).map(|&i| positions[i]);
561 let bwd_pos = labelled.get(&bwd).map(|&i| positions[i]);
562 match (fwd_pos, bwd_pos) {
563 (Some(f), Some(b)) => {
564 let v = (f - b) * 0.5;
565 Some(v)
566 }
567 (Some(f), None) => Some(f - here),
568 (None, Some(b)) => Some(here - b),
569 (None, None) => None,
570 }
571}
572
573pub(super) fn collect_candidates<V: GrowValidator>(
574 tree: &KdTree<f32, 2>,
575 slot_to_corner: &[usize],
576 prediction: Point2<f32>,
577 search_r: f32,
578 validator: &V,
579 required_label: Option<u8>,
580 by_corner: &HashMap<usize, (i32, i32)>,
581) -> Vec<(usize, f32)> {
582 let r2 = search_r * search_r;
583 let mut out: Vec<(usize, f32)> = Vec::new();
584 for nn in tree
585 .within_unsorted::<SquaredEuclidean>(&[prediction.x, prediction.y], r2)
586 .into_iter()
587 {
588 let idx = slot_to_corner[nn.item as usize];
589 if by_corner.contains_key(&idx) {
590 continue;
591 }
592 if let Some(req) = required_label {
593 let Some(got) = validator.label_of(idx) else {
594 continue;
595 };
596 if got != req {
597 continue;
598 }
599 }
600 let d = nn.distance.sqrt();
601 out.push((idx, d));
602 }
603 out.sort_by(|a, b| a.1.total_cmp(&b.1));
604 out
605}
606
607pub(super) enum CandidateChoice {
608 None,
609 Ambiguous,
610 Unique(usize),
611}
612
613pub(super) fn choose_unambiguous<V: GrowValidator>(
614 candidates: &[(usize, f32)],
615 ambiguity_factor: f32,
616 prediction: Point2<f32>,
617 positions: &[Point2<f32>],
618 validator: &V,
619 at: (i32, i32),
620 neighbours: &[LabelledNeighbour],
621) -> CandidateChoice {
622 // Filter by validator in distance order; pick the first Accept.
623 // Ambiguity check uses raw geometric ranks (two geometrically-close
624 // candidates, regardless of validator opinion).
625 if candidates.is_empty() {
626 return CandidateChoice::None;
627 }
628 if candidates.len() >= 2 {
629 let (_, d0) = candidates[0];
630 let (_, d1) = candidates[1];
631 if d0 <= f32::EPSILON {
632 return CandidateChoice::Ambiguous;
633 }
634 if d1 / d0 < ambiguity_factor {
635 return CandidateChoice::Ambiguous;
636 }
637 }
638 for &(idx, _dist) in candidates {
639 let pos = positions[idx];
640 let _ = pos; // reserved for future per-candidate metric
641 match validator.accept_candidate(idx, at, prediction, neighbours) {
642 Admit::Accept => return CandidateChoice::Unique(idx),
643 Admit::Reject => continue,
644 }
645 }
646 CandidateChoice::None
647}
648
649pub(super) fn any_cardinal_edge_ok<V: GrowValidator>(
650 c_idx: usize,
651 pos: (i32, i32),
652 labelled: &HashMap<(i32, i32), usize>,
653 validator: &V,
654) -> bool {
655 let mut found_any = false;
656 for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
657 let neigh = (pos.0 + di, pos.1 + dj);
658 if let Some(&n_idx) = labelled.get(&neigh) {
659 found_any = true;
660 if validator.edge_ok(c_idx, n_idx, pos, neigh) {
661 return true;
662 }
663 }
664 }
665 // No cardinal neighbours → defer (position reached via BFS from a
666 // labelled neighbour, so this is a safety net).
667 !found_any
668}
669
670/// Outcome of processing one boundary cell.
671pub(super) enum BoundaryDecision {
672 /// No eligible candidates in the search radius.
673 Hole,
674 /// Multiple near-equidistant candidates — cannot pick unambiguously.
675 Ambiguous,
676 /// The edge check blocked the unique candidate.
677 EdgeRejected,
678 /// Unique candidate accepted; caller should attach this corner index.
679 Attach(usize),
680}
681
682/// Shared context for one boundary-cell decision.
683///
684/// Bundles the references that all boundary-cell helpers thread
685/// through — positions / labelled state / KD-tree over eligible
686/// candidates / growth geometry / validator. Carrying them in one
687/// struct keeps [`process_boundary_cell`]'s signature compact and
688/// avoids re-stating the same nine arguments at every call site.
689pub(super) struct BoundaryCtx<'a, V: GrowValidator> {
690 pub positions: &'a [Point2<f32>],
691 pub labelled: &'a HashMap<(i32, i32), usize>,
692 pub by_corner: &'a HashMap<usize, (i32, i32)>,
693 pub tree: &'a KdTree<f32, 2>,
694 pub tree_slot_to_corner: &'a [usize],
695 pub axis_i: Vector2<f32>,
696 pub axis_j: Vector2<f32>,
697 pub cell_size: f32,
698 pub params: &'a GrowParams,
699 pub validator: &'a V,
700}
701
702/// Process one cell from the BFS boundary queue.
703///
704/// Collects labelled neighbours, predicts the target pixel position,
705/// searches candidates, resolves ambiguity, and checks `edge_ok`.
706/// Returns a [`BoundaryDecision`] that the caller applies to the mutable
707/// state. Keeping the decision logic in one place makes `bfs_grow` and
708/// `extend_from_labelled` share the same filter pipeline without
709/// duplicating code.
710pub(super) fn process_boundary_cell<V: GrowValidator>(
711 pos: (i32, i32),
712 ctx: &BoundaryCtx<'_, V>,
713) -> (BoundaryDecision, Vec<LabelledNeighbour>) {
714 let neighbours = collect_labelled_neighbours(pos, 1, ctx.labelled, ctx.positions);
715 if neighbours.is_empty() {
716 return (BoundaryDecision::Hole, neighbours);
717 }
718
719 let prediction = predict_from_neighbours(
720 pos,
721 &neighbours,
722 ctx.axis_i,
723 ctx.axis_j,
724 ctx.cell_size,
725 ctx.labelled,
726 ctx.positions,
727 );
728
729 let search_r = ctx.params.attach_search_rel * ctx.cell_size;
730 let extrapolating = is_extrapolating(pos, &neighbours);
731 let local_search_r = if extrapolating {
732 search_r * ctx.params.boundary_search_factor
733 } else {
734 search_r
735 };
736
737 let required_label = ctx.validator.required_label_at(pos.0, pos.1);
738 let candidates = collect_candidates(
739 ctx.tree,
740 ctx.tree_slot_to_corner,
741 prediction,
742 local_search_r,
743 ctx.validator,
744 required_label,
745 ctx.by_corner,
746 );
747
748 let choice = choose_unambiguous(
749 &candidates,
750 ctx.params.attach_ambiguity_factor,
751 prediction,
752 ctx.positions,
753 ctx.validator,
754 pos,
755 &neighbours,
756 );
757
758 let decision = match choice {
759 CandidateChoice::None => BoundaryDecision::Hole,
760 CandidateChoice::Ambiguous => BoundaryDecision::Ambiguous,
761 CandidateChoice::Unique(c_idx) => {
762 if !any_cardinal_edge_ok(c_idx, pos, ctx.labelled, ctx.validator) {
763 BoundaryDecision::EdgeRejected
764 } else {
765 BoundaryDecision::Attach(c_idx)
766 }
767 }
768 };
769 (decision, neighbours)
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775
776 /// Trivial validator: every corner eligible, no label constraint,
777 /// accept everything.
778 struct OpenValidator;
779
780 impl GrowValidator for OpenValidator {
781 fn is_eligible(&self, _idx: usize) -> bool {
782 true
783 }
784 fn required_label_at(&self, _i: i32, _j: i32) -> Option<u8> {
785 None
786 }
787 fn label_of(&self, _idx: usize) -> Option<u8> {
788 None
789 }
790 fn accept_candidate(
791 &self,
792 _idx: usize,
793 _at: (i32, i32),
794 _prediction: Point2<f32>,
795 _neighbours: &[LabelledNeighbour],
796 ) -> Admit {
797 Admit::Accept
798 }
799 }
800
801 #[test]
802 fn predict_weights_diagonal_less_than_cardinal() {
803 // Demonstrate the 1/(Δi² + Δj²) weighting on **isolated** labelled
804 // neighbours — placed far enough apart in (i, j) that the local-step
805 // lookup returns `None` for both, exercising the global (u, v,
806 // cell_size) fallback path.
807 //
808 // target = (5, 5)
809 // - cardinal at (5, 4), pos = (50, 40)
810 // - diagonal at (3, 3), pos = (30, 30 + 4) (4 px y-bias)
811 //
812 // Both neighbours' adjacent (i, j) cells are unlabelled, so each
813 // falls back to the global step `cell_size · u`, `cell_size · v`.
814 // Cardinal prediction at target: (50, 40) + (0, 10) = (50, 50).
815 // Diagonal prediction at target: (30, 34) + (20, 20) = (50, 54).
816 //
817 // Weights: cardinal Δd²=1 → w=1.0; diagonal Δd²=8 → w=0.125.
818 // Weighted y: (50 + 0.125·54) / 1.125 ≈ 50.444 px.
819 // Equal-weight average would be (50 + 54)/2 = 52, so the
820 // diagonal's bias has been suppressed by the d² down-weighting.
821 let s = 10.0_f32;
822 let u = Vector2::new(1.0, 0.0);
823 let v = Vector2::new(0.0, 1.0);
824 let target = (5, 5);
825 let cardinal = LabelledNeighbour {
826 idx: 0,
827 at: (5, 4),
828 position: Point2::new(50.0, 40.0),
829 };
830 let diagonal = LabelledNeighbour {
831 idx: 1,
832 at: (3, 3),
833 position: Point2::new(30.0, 34.0),
834 };
835 let positions = vec![cardinal.position, diagonal.position];
836 let mut labelled = HashMap::new();
837 labelled.insert(cardinal.at, 0usize);
838 labelled.insert(diagonal.at, 1usize);
839 let pred = predict_from_neighbours(
840 target,
841 &[cardinal, diagonal],
842 u,
843 v,
844 s,
845 &labelled,
846 &positions,
847 );
848 let expected_y = (50.0 + 0.125 * 54.0) / 1.125;
849 assert!(
850 (pred.x - 50.0).abs() < 1e-4,
851 "predicted x {} should equal 50",
852 pred.x
853 );
854 assert!(
855 (pred.y - expected_y).abs() < 1e-4,
856 "predicted y {} should equal {} (1/d² weighted)",
857 pred.y,
858 expected_y
859 );
860 let equal_weight_y = (50.0 + 54.0) * 0.5;
861 assert!(
862 (pred.y - 50.0) < (equal_weight_y - 50.0),
863 "weighted bias {} should be smaller than equal-weight bias {}",
864 pred.y - 50.0,
865 equal_weight_y - 50.0,
866 );
867 }
868
869 #[test]
870 fn predict_with_only_cardinal_recovers_exact_offset() {
871 let s = 12.0_f32;
872 let u = Vector2::new(1.0, 0.0);
873 let v = Vector2::new(0.0, 1.0);
874 let target = (2, 2);
875 let neighbour = LabelledNeighbour {
876 idx: 0,
877 at: (1, 2),
878 position: Point2::new(s, 2.0 * s),
879 };
880 let positions = vec![neighbour.position];
881 let mut labelled = HashMap::new();
882 labelled.insert(neighbour.at, 0usize);
883 let pred = predict_from_neighbours(target, &[neighbour], u, v, s, &labelled, &positions);
884 assert!((pred.x - 2.0 * s).abs() < 1e-4);
885 assert!((pred.y - 2.0 * s).abs() < 1e-4);
886 }
887
888 #[test]
889 fn predict_uses_local_step_when_neighbour_has_own_neighbours() {
890 // Foreshortened-grid scenario:
891 // labelled (i, j) | image position
892 // ---------------- | --------------
893 // (3, 0) | (300, 0) ← neighbour we extrapolate from
894 // (4, 0) | (310, 0) ← +1 step at (3,0) is only +10 px
895 // (5, 0) | (320, 0)
896 //
897 // The seed's global cell_size is 50 px (a far-region estimate). The
898 // global model would predict target (2, 0) at (300 - 50, 0) = (250, 0),
899 // missing the actual location at (290, 0) by 40 px.
900 //
901 // The local-step model uses the central-difference at (3, 0):
902 // i_step = (pos(4, 0) − pos(2, 0)) / 2 but (2, 0) is unlabelled
903 // so it falls back to one-sided: pos(3, 0) − pos(4, 0) = (−10, 0)
904 // wait — that's BACKWARD. Let me redo: forward (4, 0) is labelled,
905 // so i_step ← pos(4, 0) − pos(3, 0) = (+10, 0). For target (2, 0),
906 // prediction = pos(3, 0) + (2 − 3) · (+10, 0) = (290, 0). ✓
907 let u = Vector2::new(1.0, 0.0);
908 let v = Vector2::new(0.0, 1.0);
909 let global_cell_size = 50.0_f32;
910 let neighbour = LabelledNeighbour {
911 idx: 0,
912 at: (3, 0),
913 position: Point2::new(300.0, 0.0),
914 };
915 let mut positions = vec![neighbour.position];
916 let mut labelled: HashMap<(i32, i32), usize> = HashMap::new();
917 labelled.insert((3, 0), 0);
918 positions.push(Point2::new(310.0, 0.0));
919 labelled.insert((4, 0), 1);
920 positions.push(Point2::new(320.0, 0.0));
921 labelled.insert((5, 0), 2);
922
923 let pred = predict_from_neighbours(
924 (2, 0),
925 &[neighbour],
926 u,
927 v,
928 global_cell_size,
929 &labelled,
930 &positions,
931 );
932 // Adaptive prediction lands on the foreshortened position, not the
933 // 50-px global step.
934 assert!(
935 (pred.x - 290.0).abs() < 1e-3,
936 "expected adaptive prediction at x=290, got {}",
937 pred.x
938 );
939 assert!((pred.y - 0.0).abs() < 1e-3);
940 }
941
942 #[test]
943 fn predict_falls_back_to_global_when_no_local_steps() {
944 // Single isolated neighbour with no labelled +i / +j peers — the
945 // local-step lookup returns None for both directions and the global
946 // (u, v, cell_size) fallback produces the same answer as the
947 // pre-refactor implementation.
948 let u = Vector2::new(1.0, 0.0);
949 let v = Vector2::new(0.0, 1.0);
950 let s = 25.0_f32;
951 let neighbour = LabelledNeighbour {
952 idx: 0,
953 at: (4, 4),
954 position: Point2::new(100.0, 100.0),
955 };
956 let positions = vec![neighbour.position];
957 let mut labelled: HashMap<(i32, i32), usize> = HashMap::new();
958 labelled.insert((4, 4), 0);
959 let pred = predict_from_neighbours((5, 4), &[neighbour], u, v, s, &labelled, &positions);
960 assert!((pred.x - (100.0 + s)).abs() < 1e-3);
961 assert!((pred.y - 100.0).abs() < 1e-3);
962 }
963
964 #[test]
965 fn open_validator_grows_clean_grid() {
966 let s = 20.0_f32;
967 let rows = 6_i32;
968 let cols = 6_i32;
969 let mut positions = Vec::new();
970 let mut seed_idx = [0usize; 4];
971 for j in 0..rows {
972 for i in 0..cols {
973 let x = i as f32 * s + 50.0;
974 let y = j as f32 * s + 50.0;
975 let k = positions.len();
976 positions.push(Point2::new(x, y));
977 if (i, j) == (0, 0) {
978 seed_idx[0] = k;
979 }
980 if (i, j) == (1, 0) {
981 seed_idx[1] = k;
982 }
983 if (i, j) == (0, 1) {
984 seed_idx[2] = k;
985 }
986 if (i, j) == (1, 1) {
987 seed_idx[3] = k;
988 }
989 }
990 }
991
992 let seed = Seed {
993 a: seed_idx[0],
994 b: seed_idx[1],
995 c: seed_idx[2],
996 d: seed_idx[3],
997 };
998 let res = bfs_grow(&positions, seed, s, &GrowParams::default(), &OpenValidator);
999 assert_eq!(res.labelled.len(), (rows * cols) as usize);
1000 // Origin rebased to (0, 0).
1001 let (mi, mj) = res
1002 .labelled
1003 .keys()
1004 .fold((i32::MAX, i32::MAX), |(a, b), &(i, j)| (a.min(i), b.min(j)));
1005 assert_eq!((mi, mj), (0, 0));
1006 }
1007}