1use crate::Float;
35use kiddo::{KdTree, SquaredEuclidean};
36use nalgebra::{Point2, RealField, Vector2};
37
38#[derive(Clone, Copy, Debug, PartialEq)]
40pub struct LocalStep<F: Float = f32> {
41 pub step_u: F,
44 pub step_v: F,
46 pub confidence: F,
49 pub supporters_u: u32,
51 pub supporters_v: u32,
53}
54
55impl<F: Float> Default for LocalStep<F> {
56 fn default() -> Self {
57 Self {
58 step_u: F::zero(),
59 step_v: F::zero(),
60 confidence: F::zero(),
61 supporters_u: 0,
62 supporters_v: 0,
63 }
64 }
65}
66
67#[derive(Clone, Copy, Debug)]
75pub struct LocalStepPointData<F: Float = f32> {
76 pub position: Point2<F>,
77 pub axis_u: F,
78 pub axis_v: F,
79}
80
81#[derive(Clone, Copy, Debug)]
83pub struct LocalStepParams<F: Float = f32> {
84 pub k_neighbors: usize,
88 pub max_step_factor: F,
91 pub sector_half_width_rad: F,
97 pub bandwidth_rel: F,
100 pub mean_shift_max_iters: u32,
103 pub mean_shift_convergence_rel: F,
106 pub confidence_denominator: F,
111}
112
113impl<F: Float> Default for LocalStepParams<F> {
114 fn default() -> Self {
115 Self {
116 k_neighbors: 8,
117 max_step_factor: F::from_subset(&3.0),
118 sector_half_width_rad: F::pi() / F::from_subset(&6.0),
119 bandwidth_rel: F::from_subset(&0.15),
120 mean_shift_max_iters: 20,
121 mean_shift_convergence_rel: F::from_subset(&1e-3),
122 confidence_denominator: F::from_subset(&4.0),
123 }
124 }
125}
126
127pub fn estimate_local_steps<F: Float + kiddo::float::kdtree::Axis>(
133 points: &[LocalStepPointData<F>],
134 params: &LocalStepParams<F>,
135) -> Vec<LocalStep<F>> {
136 if points.is_empty() {
137 return Vec::new();
138 }
139
140 let coords: Vec<[F; 2]> = points
142 .iter()
143 .map(|p| [p.position.x, p.position.y])
144 .collect();
145 let tree: KdTree<F, 2> = (&coords).into();
146
147 let mut out = Vec::with_capacity(points.len());
148 for (i, p) in points.iter().enumerate() {
149 out.push(estimate_one(i, p, &tree, points, params));
150 }
151 out
152}
153
154fn estimate_one<F: Float + kiddo::float::kdtree::Axis>(
155 source_index: usize,
156 source: &LocalStepPointData<F>,
157 tree: &KdTree<F, 2>,
158 points: &[LocalStepPointData<F>],
159 params: &LocalStepParams<F>,
160) -> LocalStep<F> {
161 let k = params.k_neighbors.saturating_add(1); let results =
163 tree.nearest_n::<SquaredEuclidean>(&[source.position.x, source.position.y], k.max(2));
164
165 let mut offsets: Vec<Vector2<F>> = Vec::with_capacity(k);
167 for nn in results {
168 let j = nn.item as usize;
169 if j == source_index {
170 continue;
171 }
172 let other = &points[j];
173 let offset = other.position - source.position;
174 if offset.norm_squared().is_zero() {
175 continue;
176 }
177 offsets.push(offset);
178 }
179
180 if offsets.is_empty() {
181 return LocalStep::default();
182 }
183
184 let distances: Vec<F> = offsets.iter().map(|o| o.norm()).collect();
186 let median_dist = median_f(&mut distances.clone());
187 let cutoff = median_dist * params.max_step_factor;
188 let mut kept: Vec<Vector2<F>> = offsets
189 .into_iter()
190 .zip(distances.iter())
191 .filter_map(|(o, d)| if *d <= cutoff { Some(o) } else { None })
192 .collect();
193
194 if kept.is_empty() {
195 return LocalStep::default();
196 }
197
198 let line_u = fold_to_line(source.axis_u);
200 let line_v = fold_to_line(source.axis_v);
201 let mut u_steps: Vec<F> = Vec::new();
202 let mut v_steps: Vec<F> = Vec::new();
203
204 while let Some(offset) = kept.pop() {
205 let edge_line = fold_to_line(offset.y.atan2(offset.x));
206 let diff_u = line_diff(edge_line, line_u);
207 let diff_v = line_diff(edge_line, line_v);
208 if RealField::min(diff_u, diff_v) > params.sector_half_width_rad {
209 continue;
210 }
211 let d = offset.norm();
212 if diff_u <= diff_v {
213 u_steps.push(d);
214 } else {
215 v_steps.push(d);
216 }
217 }
218
219 let (step_u, sup_u) = sector_mode(&mut u_steps, params);
220 let (step_v, sup_v) = sector_mode(&mut v_steps, params);
221
222 let total_sup = F::from_subset(&((sup_u + sup_v) as f64));
223 let confidence = RealField::max(
224 RealField::min(total_sup / params.confidence_denominator, F::one()),
225 F::zero(),
226 );
227
228 LocalStep {
229 step_u,
230 step_v,
231 confidence,
232 supporters_u: sup_u,
233 supporters_v: sup_v,
234 }
235}
236
237fn sector_mode<F: Float>(values: &mut [F], params: &LocalStepParams<F>) -> (F, u32) {
240 if values.is_empty() {
241 return (F::zero(), 0);
242 }
243 values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
244 let med = median_sorted(values);
245 let sup = values.len() as u32;
246
247 if values.len() < 2 {
248 return (med, sup);
249 }
250 let bandwidth = med * params.bandwidth_rel;
251 if bandwidth.is_zero() {
252 return (med, sup);
253 }
254
255 let mut center = med;
256 let convergence = bandwidth * params.mean_shift_convergence_rel;
257 for _ in 0..params.mean_shift_max_iters {
258 let mut sum = F::zero();
259 let mut weight = F::zero();
260 for &v in values.iter() {
261 let diff = v - center;
262 if diff.abs() > bandwidth {
263 continue;
264 }
265 let t = diff / bandwidth;
267 let w = F::one() - t * t;
268 let w = if w < F::zero() { F::zero() } else { w };
269 sum += v * w;
270 weight += w;
271 }
272 if weight.is_zero() {
273 return (med, sup);
274 }
275 let next = sum / weight;
276 if (next - center).abs() <= convergence {
277 return (next, sup);
278 }
279 center = next;
280 }
281 (med, sup)
283}
284
285#[inline]
287fn fold_to_line<F: Float>(theta: F) -> F {
288 let pi = F::pi();
289 let two_pi = pi + pi;
290 let mut t = theta - two_pi * (theta / two_pi).floor();
291 if t >= pi {
292 t -= pi;
293 }
294 if t < F::zero() {
295 t += pi;
296 }
297 t
298}
299
300#[inline]
303fn line_diff<F: Float>(a: F, b: F) -> F {
304 let pi = F::pi();
305 let frac_pi_2 = F::frac_pi_2();
306 let mut diff = (a - b).abs();
307 if diff > frac_pi_2 {
308 diff = pi - diff;
309 }
310 diff
311}
312
313fn median_f<F: Float>(values: &mut [F]) -> F {
314 if values.is_empty() {
315 return F::zero();
316 }
317 values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
318 median_sorted(values)
319}
320
321fn median_sorted<F: Float>(sorted: &[F]) -> F {
322 let n = sorted.len();
323 if n == 0 {
324 return F::zero();
325 }
326 if n % 2 == 1 {
327 sorted[n / 2]
328 } else {
329 (sorted[n / 2 - 1] + sorted[n / 2]) * F::from_subset(&0.5)
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use nalgebra::Point2;
337
338 fn lspd(x: f32, y: f32, axis_u: f32) -> LocalStepPointData<f32> {
339 LocalStepPointData {
340 position: Point2::new(x, y),
341 axis_u,
342 axis_v: axis_u + std::f32::consts::FRAC_PI_2,
343 }
344 }
345
346 fn regular_grid(
347 rows: u32,
348 cols: u32,
349 spacing: f32,
350 angle: f32,
351 ) -> Vec<LocalStepPointData<f32>> {
352 let (cx, sx) = (angle.cos(), angle.sin());
353 let mut out = Vec::new();
354 for j in 0..rows {
355 for i in 0..cols {
356 let i_f = i as f32 * spacing;
357 let j_f = j as f32 * spacing;
358 let x = i_f * cx - j_f * sx;
359 let y = i_f * sx + j_f * cx;
360 out.push(lspd(x, y, angle));
361 }
362 }
363 out
364 }
365
366 #[test]
367 fn regular_grid_recovers_spacing_at_multiple_scales() {
368 let params = LocalStepParams::<f32>::default();
369 for &spacing in &[10.0_f32, 20.0, 40.0] {
370 let pts = regular_grid(5, 5, spacing, 0.0);
371 let steps = estimate_local_steps(&pts, ¶ms);
372 let s = &steps[12];
374 assert!(
375 (s.step_u - spacing).abs() / spacing < 0.05,
376 "spacing {spacing}: step_u {} off >5%",
377 s.step_u
378 );
379 assert!((s.step_v - spacing).abs() / spacing < 0.05);
380 assert!(s.supporters_u >= 2 && s.supporters_v >= 2);
381 assert!(s.confidence > 0.8);
382 }
383 }
384
385 #[test]
386 fn rotated_grid_is_sector_invariant() {
387 let params = LocalStepParams::<f32>::default();
388 for ° in &[0.0_f32, 15.0, 30.0, 45.0] {
389 let angle = deg.to_radians();
390 let pts = regular_grid(5, 5, 20.0, angle);
391 let steps = estimate_local_steps(&pts, ¶ms);
392 let s = &steps[12];
393 assert!(
394 (s.step_u - 20.0).abs() < 1.0,
395 "angle {deg}°: step_u {} deviates",
396 s.step_u
397 );
398 assert!((s.step_v - 20.0).abs() < 1.0);
399 }
400 }
401
402 #[test]
403 fn mild_barrel_distortion_is_tolerated() {
404 let spacing = 25.0;
408 let mut pts = regular_grid(7, 7, spacing, 0.0);
409 for p in &mut pts {
410 let cx = 3.0 * spacing;
411 let cy = 3.0 * spacing;
412 let dx = p.position.x - cx;
413 let dy = p.position.y - cy;
414 let r2 = dx * dx + dy * dy;
415 let scale = 1.0 + 1e-5 * r2;
416 p.position = Point2::new(cx + dx * scale, cy + dy * scale);
417 }
418 let steps = estimate_local_steps(&pts, &LocalStepParams::<f32>::default());
419 let interior = 24usize; let s = &steps[interior];
421 assert!(
422 (s.step_u - spacing).abs() / spacing < 0.1,
423 "step_u {} far from spacing {spacing}",
424 s.step_u
425 );
426 }
427
428 #[test]
429 fn dual_scale_grid_picks_dominant_mode() {
430 let mut pts = regular_grid(5, 5, 20.0, 0.0);
432 let marker_angle = 20.0_f32.to_radians();
439 let interior_pts: Vec<usize> = (1..4)
440 .flat_map(|j| (1..4).map(move |i| j * 5 + i))
441 .collect();
442 for &idx in &interior_pts {
443 let c = pts[idx].position;
444 pts.push(LocalStepPointData {
445 position: Point2::new(c.x + 3.0, c.y + 3.0),
446 axis_u: marker_angle,
447 axis_v: marker_angle + std::f32::consts::FRAC_PI_2,
448 });
449 }
450 let steps = estimate_local_steps(&pts, &LocalStepParams::<f32>::default());
451 let s = &steps[12]; assert!(
454 (s.step_u - 20.0).abs() < 2.0,
455 "expected board step ~20 for u, got {}",
456 s.step_u
457 );
458 assert!(
459 (s.step_v - 20.0).abs() < 2.0,
460 "expected board step ~20 for v, got {}",
461 s.step_v
462 );
463 }
464
465 #[test]
466 fn isolated_point_reports_zero_confidence() {
467 let pts = vec![lspd(0.0, 0.0, 0.0)];
468 let steps = estimate_local_steps(&pts, &LocalStepParams::<f32>::default());
469 assert_eq!(steps.len(), 1);
470 assert_eq!(steps[0].confidence, 0.0);
471 assert_eq!(steps[0].step_u, 0.0);
472 assert_eq!(steps[0].step_v, 0.0);
473 }
474
475 #[test]
476 fn fold_and_line_diff_roundtrip() {
477 let pi = std::f32::consts::PI;
478 for &theta in &[-pi, -0.5, 0.0, 0.5, pi - 1e-3, pi, 1.5 * pi, 2.5 * pi] {
479 let folded = fold_to_line(theta);
480 assert!(
481 (0.0..pi).contains(&folded),
482 "fold({theta}) = {folded} escaped [0, π)"
483 );
484 }
485 assert!(
487 (line_diff(0.0, std::f32::consts::FRAC_PI_2) - std::f32::consts::FRAC_PI_2).abs()
488 < 1e-5
489 );
490 assert!(line_diff(0.0, pi - 1e-3) < 1e-2);
492 }
493}