#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::similar_names)]
#![allow(clippy::too_many_arguments)]
use std::f64::consts::PI;
use bumpalo::Bump;
use bumpalo::collections::Vec as BumpVec;
use crate::Point;
use crate::gwlf::MomentAccumulator;
use crate::image::ImageView;
use crate::segmentation::ComponentStats;
pub(crate) struct EdLinesConfig {
pub huber_delta: f64,
pub irls_iters: usize,
pub sp_huber_delta: f64,
pub sp_irls_iters: usize,
pub sample_step: f64,
pub grad_min_mag: f64,
pub min_edge_pts: usize,
pub gn_iters: usize,
}
impl EdLinesConfig {
#[must_use]
pub fn from_detector_config(_cfg: &crate::config::DetectorConfig) -> Self {
Self {
huber_delta: 1.5,
irls_iters: 3,
sp_huber_delta: 0.5,
sp_irls_iters: 2,
sample_step: 1.5,
grad_min_mag: 8.0,
min_edge_pts: 5,
gn_iters: 3,
}
}
}
#[derive(Clone, Copy)]
struct Line {
nx: f64,
ny: f64,
d: f64,
cx: f64,
cy: f64,
}
fn fit_line_weighted(points: &[(f64, f64)], weights: &[f64]) -> Option<Line> {
debug_assert_eq!(points.len(), weights.len());
let mut acc = MomentAccumulator::new();
for (&(x, y), &w) in points.iter().zip(weights.iter()) {
if w > 1e-12 {
acc.add(x, y, w);
}
}
let cov = acc.covariance()?;
let centroid = acc.centroid()?;
let a = cov[(0, 0)];
let b = cov[(0, 1)];
let c = cov[(1, 1)];
let trace = a + c;
let disc = ((a - c) * (a - c) + 4.0 * b * b).sqrt();
let lambda_min = (trace - disc) / 2.0;
let (nx, ny) = if b.abs() > 1e-9 {
let vx = b;
let vy = lambda_min - a;
let len = (vx * vx + vy * vy).sqrt();
if len < 1e-12 {
return None;
}
(vx / len, vy / len)
} else if a < c {
(1.0_f64, 0.0_f64)
} else {
(0.0_f64, 1.0_f64)
};
let d = -(nx * centroid.x + ny * centroid.y);
Some(Line {
nx,
ny,
d,
cx: centroid.x,
cy: centroid.y,
})
}
fn fit_line_irls(
arena: &Bump,
points: &[(f64, f64)],
huber_delta: f64,
iters: usize,
) -> Option<Line> {
if points.len() < 2 {
return None;
}
let n = points.len();
let weights: &mut [f64] = arena.alloc_slice_fill_copy(n, 1.0_f64);
let mut line: Option<Line> = None;
for _ in 0..iters {
let l = fit_line_weighted(points, weights)?;
for (i, &(x, y)) in points.iter().enumerate() {
let r = (l.nx * x + l.ny * y + l.d).abs();
weights[i] = if r < huber_delta {
1.0
} else {
huber_delta / r.max(1e-9)
};
}
line = Some(l);
}
line
}
fn intersect_lines(l1: &Line, l2: &Line) -> Option<(f64, f64)> {
let ww = l1.nx * l2.ny - l1.ny * l2.nx;
if ww.abs() < 1e-9 {
return None;
}
let wx = l1.ny * l2.d - l1.d * l2.ny;
let wy = l1.d * l2.nx - l1.nx * l2.d;
Some((wx / ww, wy / ww))
}
#[inline]
fn shoelace_area(pts: &[(f64, f64); 4]) -> f64 {
let mut area = 0.0_f64;
for i in 0..4 {
let j = (i + 1) % 4;
area += pts[i].0 * pts[j].1;
area -= pts[j].0 * pts[i].1;
}
area * 0.5
}
#[must_use]
#[allow(clippy::many_single_char_names)]
fn cholesky_factor_8x8(h_in: &[f64; 64]) -> Option<[f64; 64]> {
let mut l = *h_in;
for i in 0..8_usize {
let mut s = l[i * 8 + i];
for k in 0..i {
s -= l[i * 8 + k] * l[i * 8 + k];
}
if s < 1e-12 {
return None; }
let l_ii = s.sqrt();
l[i * 8 + i] = l_ii;
let inv_lii = 1.0 / l_ii;
for j in (i + 1)..8 {
let mut sum = l[j * 8 + i]; for k in 0..i {
sum -= l[j * 8 + k] * l[i * 8 + k];
}
l[j * 8 + i] = sum * inv_lii;
}
}
Some(l)
}
#[allow(clippy::many_single_char_names)]
fn cholesky_solve_with_factor(l: &[f64; 64], b: &[f64; 8]) -> [f64; 8] {
let mut y = [0.0_f64; 8];
for i in 0..8 {
let mut s = b[i];
for k in 0..i {
s -= l[i * 8 + k] * y[k];
}
y[i] = s / l[i * 8 + i];
}
let mut x = [0.0_f64; 8];
for i in (0..8).rev() {
let mut s = y[i];
for k in (i + 1)..8 {
s -= l[k * 8 + i] * x[k]; }
x[i] = s / l[i * 8 + i];
}
x
}
fn cholesky_solve_8x8(h_in: &[f64; 64], b: &[f64; 8]) -> Option<[f64; 8]> {
let l = cholesky_factor_8x8(h_in)?;
Some(cholesky_solve_with_factor(&l, b))
}
fn cholesky_inverse_8x8(l: &[f64; 64]) -> [f64; 64] {
let mut inv = [0.0_f64; 64];
let mut e = [0.0_f64; 8];
for col in 0..8_usize {
e.fill(0.0);
e[col] = 1.0;
let x = cholesky_solve_with_factor(l, &e);
for i in 0..8 {
inv[i * 8 + col] = x[i];
}
}
inv
}
#[allow(clippy::too_many_lines, clippy::type_complexity)]
fn refine_corners_gauss_newton(
mut corners: [(f64, f64); 4],
sp: [&[(f64, f64)]; 4],
max_iters: usize,
) -> ([(f64, f64); 4], Option<[[f64; 4]; 4]>) {
const TOL: f64 = 0.005;
let original = corners;
let mut last_h = [0.0_f64; 64];
let mut total_residual_sq = 0.0_f64;
let mut n_obs: usize = 0;
for _iter in 0..max_iters {
let mut h = [0.0_f64; 64]; let mut g = [0.0_f64; 8]; let mut iter_residual_sq = 0.0_f64;
let mut iter_n_obs: usize = 0;
for k in 0..4_usize {
let ck = corners[k];
let ck1 = corners[(k + 1) % 4];
let ex = ck1.0 - ck.0;
let ey = ck1.1 - ck.1;
let len = (ex * ex + ey * ey).sqrt();
if len < 1e-6 {
continue;
}
let tx = ex / len;
let ty = ey / len;
let nx = -ty; let ny = tx;
let ik_x = 2 * k;
let ik_y = 2 * k + 1;
let ik1_x = 2 * ((k + 1) % 4);
let ik1_y = 2 * ((k + 1) % 4) + 1;
for &(qx, qy) in sp[k] {
let alpha = ((qx - ck.0) * tx + (qy - ck.1) * ty) / len;
if !(0.05_f64..=0.95_f64).contains(&alpha) {
continue;
}
let r = (qx - ck.0) * nx + (qy - ck.1) * ny;
let a_k = 1.0 - alpha;
let js = [
(ik_x, -a_k * nx),
(ik_y, -a_k * ny),
(ik1_x, -alpha * nx),
(ik1_y, -alpha * ny),
];
for &(a, ja) in &js {
g[a] += ja * r;
for &(b, jb) in &js {
h[a * 8 + b] += ja * jb;
}
}
iter_residual_sq += r * r;
iter_n_obs += 1;
}
}
total_residual_sq = iter_residual_sq;
n_obs = iter_n_obs;
last_h = h;
for i in 0..8 {
h[i * 8 + i] += 1e-6;
}
let neg_g = g.map(|x| -x);
let Some(delta) = cholesky_solve_8x8(&h, &neg_g) else {
break; };
let mut max_step = 0.0_f64;
for i in 0..4 {
corners[i].0 += delta[2 * i];
corners[i].1 += delta[2 * i + 1];
max_step = max_step.max(delta[2 * i].abs()).max(delta[2 * i + 1].abs());
}
if max_step < TOL {
break;
}
}
for i in 0..4 {
let dx = corners[i].0 - original[i].0;
let dy = corners[i].1 - original[i].1;
if dx * dx + dy * dy > 25.0 {
return (original, None);
}
}
let covs = if n_obs > 8 {
for i in 0..8 {
last_h[i * 8 + i] += 1e-6;
}
cholesky_factor_8x8(&last_h).map(|l| {
let h_inv = cholesky_inverse_8x8(&l);
let sigma_sq = total_residual_sq / (n_obs - 8) as f64;
std::array::from_fn(|k| {
let r = 2 * k;
[
sigma_sq * h_inv[r * 8 + r], sigma_sq * h_inv[r * 8 + (r + 1)], sigma_sq * h_inv[(r + 1) * 8 + r], sigma_sq * h_inv[(r + 1) * 8 + (r + 1)], ]
})
})
} else {
None
};
(corners, covs)
}
#[allow(clippy::too_many_lines)]
fn extract_boundary_segments<'a>(
arena: &'a Bump,
labels: &[u32],
img_width: usize,
comp_label: u32,
stat: &ComponentStats,
) -> [BumpVec<'a, (f64, f64)>; 4] {
let m00 = f64::from(stat.pixel_count).max(1.0);
let cx = stat.m10 as f64 / m00 + 0.5;
let cy = stat.m01 as f64 / m00 + 0.5;
let min_x = stat.min_x as usize;
let max_x = stat.max_x as usize;
let min_y = stat.min_y as usize;
let max_y = stat.max_y as usize;
let mut all_bnd: BumpVec<(f64, f64, f64)> = BumpVec::new_in(arena);
let mut t_y = usize::MAX;
let mut t_x = 0usize; let mut r_x = 0usize;
let mut r_y = usize::MAX; let mut b_y = 0usize;
let mut b_x = 0usize; let mut l_x = usize::MAX;
let mut l_y = 0usize;
let mut nw_sum = isize::MAX;
let mut nw_x = 0usize;
let mut nw_y = 0usize;
let mut ne_dif = isize::MAX;
let mut ne_x = 0usize;
let mut ne_y = 0usize;
let mut se_sum = isize::MIN;
let mut se_x = 0usize;
let mut se_y = 0usize;
let mut sw_dif = isize::MIN;
let mut sw_x = 0usize;
let mut sw_y = 0usize;
let push_pt = |x: usize,
y: usize,
t_y: &mut usize,
t_x: &mut usize,
r_x: &mut usize,
r_y: &mut usize,
b_y: &mut usize,
b_x: &mut usize,
l_x: &mut usize,
l_y: &mut usize,
nw_sum: &mut isize,
nw_x: &mut usize,
nw_y: &mut usize,
ne_dif: &mut isize,
ne_x: &mut usize,
ne_y: &mut usize,
se_sum: &mut isize,
se_x: &mut usize,
se_y: &mut usize,
sw_dif: &mut isize,
sw_x: &mut usize,
sw_y: &mut usize,
all_bnd: &mut BumpVec<(f64, f64, f64)>| {
if y < *t_y || (y == *t_y && x < *t_x) {
*t_y = y;
*t_x = x;
}
if x > *r_x || (x == *r_x && y < *r_y) {
*r_x = x;
*r_y = y;
}
if y > *b_y || (y == *b_y && x > *b_x) {
*b_y = y;
*b_x = x;
}
if x < *l_x || (x == *l_x && y > *l_y) {
*l_x = x;
*l_y = y;
}
let ix = x as isize;
let iy = y as isize;
let sum = ix + iy;
let dif = iy - ix;
if sum < *nw_sum {
*nw_sum = sum;
*nw_x = x;
*nw_y = y;
}
if dif < *ne_dif {
*ne_dif = dif;
*ne_x = x;
*ne_y = y;
}
if sum > *se_sum {
*se_sum = sum;
*se_x = x;
*se_y = y;
}
if dif > *sw_dif {
*sw_dif = dif;
*sw_x = x;
*sw_y = y;
}
let px = x as f64 + 0.5;
let py = y as f64 + 0.5;
let angle = (py - cy).atan2(px - cx);
all_bnd.push((px, py, angle));
};
for x in min_x..=max_x {
let mut top_y: Option<usize> = None;
let mut bot_y: Option<usize> = None;
for y in min_y..=max_y {
if labels[y * img_width + x] == comp_label {
if top_y.is_none() {
top_y = Some(y);
}
bot_y = Some(y);
}
}
if let Some(ty) = top_y {
push_pt(
x,
ty,
&mut t_y,
&mut t_x,
&mut r_x,
&mut r_y,
&mut b_y,
&mut b_x,
&mut l_x,
&mut l_y,
&mut nw_sum,
&mut nw_x,
&mut nw_y,
&mut ne_dif,
&mut ne_x,
&mut ne_y,
&mut se_sum,
&mut se_x,
&mut se_y,
&mut sw_dif,
&mut sw_x,
&mut sw_y,
&mut all_bnd,
);
}
if let Some(by) = bot_y
&& top_y != bot_y
{
push_pt(
x,
by,
&mut t_y,
&mut t_x,
&mut r_x,
&mut r_y,
&mut b_y,
&mut b_x,
&mut l_x,
&mut l_y,
&mut nw_sum,
&mut nw_x,
&mut nw_y,
&mut ne_dif,
&mut ne_x,
&mut ne_y,
&mut se_sum,
&mut se_x,
&mut se_y,
&mut sw_dif,
&mut sw_x,
&mut sw_y,
&mut all_bnd,
);
}
}
for y in min_y..=max_y {
let row_off = y * img_width;
let mut lft_x: Option<usize> = None;
let mut rgt_x: Option<usize> = None;
for x in min_x..=max_x {
if labels[row_off + x] == comp_label {
if lft_x.is_none() {
lft_x = Some(x);
}
rgt_x = Some(x);
}
}
if let Some(lx) = lft_x {
push_pt(
lx,
y,
&mut t_y,
&mut t_x,
&mut r_x,
&mut r_y,
&mut b_y,
&mut b_x,
&mut l_x,
&mut l_y,
&mut nw_sum,
&mut nw_x,
&mut nw_y,
&mut ne_dif,
&mut ne_x,
&mut ne_y,
&mut se_sum,
&mut se_x,
&mut se_y,
&mut sw_dif,
&mut sw_x,
&mut sw_y,
&mut all_bnd,
);
}
if let Some(rx) = rgt_x
&& lft_x != rgt_x
{
push_pt(
rx,
y,
&mut t_y,
&mut t_x,
&mut r_x,
&mut r_y,
&mut b_y,
&mut b_x,
&mut l_x,
&mut l_y,
&mut nw_sum,
&mut nw_x,
&mut nw_y,
&mut ne_dif,
&mut ne_x,
&mut ne_y,
&mut se_sum,
&mut se_x,
&mut se_y,
&mut sw_dif,
&mut sw_x,
&mut sw_y,
&mut all_bnd,
);
}
}
let empty: [BumpVec<(f64, f64)>; 4] = [
BumpVec::new_in(arena),
BumpVec::new_in(arena),
BumpVec::new_in(arena),
BumpVec::new_in(arena),
];
if t_y == usize::MAX || all_bnd.len() < 8 {
return empty;
}
#[allow(clippy::items_after_statements)]
const MIN_ARC: f64 = 5.0 * PI / 180.0;
let t_angle = ((t_y as f64 + 0.5) - cy).atan2((t_x as f64 + 0.5) - cx);
let r_angle = ((r_y as f64 + 0.5) - cy).atan2((r_x as f64 + 0.5) - cx);
let b_angle = ((b_y as f64 + 0.5) - cy).atan2((b_x as f64 + 0.5) - cx);
let l_angle = ((l_y as f64 + 0.5) - cy).atan2((l_x as f64 + 0.5) - cx);
let r_norm = (r_angle - t_angle).rem_euclid(2.0 * PI);
let b_norm = (b_angle - t_angle).rem_euclid(2.0 * PI);
let l_norm = (l_angle - t_angle).rem_euclid(2.0 * PI);
let trbl_ok = r_norm >= MIN_ARC && (b_norm - r_norm) >= MIN_ARC && (l_norm - b_norm) >= MIN_ARC;
let nw_angle = ((nw_y as f64 + 0.5) - cy).atan2((nw_x as f64 + 0.5) - cx);
let ne_angle = ((ne_y as f64 + 0.5) - cy).atan2((ne_x as f64 + 0.5) - cx);
let se_angle = ((se_y as f64 + 0.5) - cy).atan2((se_x as f64 + 0.5) - cx);
let sw_angle = ((sw_y as f64 + 0.5) - cy).atan2((sw_x as f64 + 0.5) - cx);
let ne_norm = (ne_angle - nw_angle).rem_euclid(2.0 * PI);
let se_norm = (se_angle - nw_angle).rem_euclid(2.0 * PI);
let sw_norm = (sw_angle - nw_angle).rem_euclid(2.0 * PI);
let diag_ok =
ne_norm >= MIN_ARC && (se_norm - ne_norm) >= MIN_ARC && (sw_norm - se_norm) >= MIN_ARC;
if !trbl_ok && !diag_ok {
return empty;
}
let (base_angle, a1, a2, a3) = if trbl_ok {
(t_angle, r_norm, b_norm, l_norm)
} else {
(nw_angle, ne_norm, se_norm, sw_norm)
};
let mut edges: [BumpVec<(f64, f64)>; 4] = [
BumpVec::new_in(arena),
BumpVec::new_in(arena),
BumpVec::new_in(arena),
BumpVec::new_in(arena),
];
for &(px, py, angle) in &all_bnd {
let a_norm = (angle - base_angle).rem_euclid(2.0 * PI);
let group = if a_norm < a1 {
0
} else if a_norm < a2 {
1
} else if a_norm < a3 {
2
} else {
3
};
edges[group].push((px, py));
}
edges
}
fn refine_edge_subpixel<'a>(
arena: &'a Bump,
gray: &ImageView,
line: &Line,
dec: f64,
min_x_bin: f64,
max_x_bin: f64,
min_y_bin: f64,
max_y_bin: f64,
sample_step: f64,
grad_min_mag: f64,
) -> BumpVec<'a, (f64, f64)> {
let mut result: BumpVec<(f64, f64)> = BumpVec::new_in(arena);
let tx = -line.ny;
let ty = line.nx;
let corners = [
(min_x_bin, min_y_bin),
(max_x_bin, min_y_bin),
(max_x_bin, max_y_bin),
(min_x_bin, max_y_bin),
];
let t_vals: [f64; 4] = corners.map(|(bx, by)| (bx - line.cx) * tx + (by - line.cy) * ty);
let t_min = t_vals.iter().copied().fold(f64::INFINITY, f64::min);
let t_max = t_vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let gray_w = gray.width as f64;
let gray_h = gray.height as f64;
let nx = line.nx;
let ny = line.ny;
let mut t = t_min;
while t <= t_max {
let px_bin = line.cx + t * tx;
let py_bin = line.cy + t * ty;
let px = px_bin * dec;
let py = py_bin * dec;
let mut intensities = [0.0_f64; 5];
let mut all_in_bounds = true;
for (ki, slot) in intensities.iter_mut().enumerate() {
let k = ki as f64 - 2.0; let sx = px + k * nx;
let sy = py + k * ny;
if sx < 0.5 || sy < 0.5 || sx >= gray_w - 0.5 || sy >= gray_h - 0.5 {
all_in_bounds = false;
break;
}
*slot = gray.sample_bilinear(sx, sy);
}
if all_in_bounds {
let g_neg1 = (intensities[2] - intensities[0]) * 0.5; let g_0 = (intensities[3] - intensities[1]) * 0.5; let g_pos1 = (intensities[4] - intensities[2]) * 0.5;
if g_0.abs() >= grad_min_mag {
let denom = g_pos1 + g_neg1 - 2.0 * g_0;
let k_star = if denom.abs() > 1e-6 {
(-(g_pos1 - g_neg1) / denom).clamp(-1.5, 1.5)
} else {
0.0 };
result.push((px + k_star * nx, py + k_star * ny));
}
}
t += sample_step;
}
result
}
#[allow(clippy::too_many_lines)]
pub(crate) fn extract_quad_edlines(
arena: &Bump,
binary: &ImageView,
gray: &ImageView,
labels: &[u32],
comp_label: u32,
stat: &ComponentStats,
cfg: &EdLinesConfig,
) -> Option<([Point; 4], crate::quad::CornerCovariances)> {
let bbox_w = (stat.max_x - stat.min_x) as usize + 1;
let bbox_h = (stat.max_y - stat.min_y) as usize + 1;
if bbox_w < 5 || bbox_h < 5 {
return None;
}
let edges = extract_boundary_segments(arena, labels, binary.width, comp_label, stat);
for e in &edges {
if e.len() < 2 {
return None;
}
}
let line0 = fit_line_irls(arena, edges[0].as_slice(), cfg.huber_delta, cfg.irls_iters)?;
let line1 = fit_line_irls(arena, edges[1].as_slice(), cfg.huber_delta, cfg.irls_iters)?;
let line2 = fit_line_irls(arena, edges[2].as_slice(), cfg.huber_delta, cfg.irls_iters)?;
let line3 = fit_line_irls(arena, edges[3].as_slice(), cfg.huber_delta, cfg.irls_iters)?;
let bin_lines = [line0, line1, line2, line3];
let dec = gray.width as f64 / binary.width as f64;
let min_x_bin = f64::from(stat.min_x) + 0.5;
let max_x_bin = f64::from(stat.max_x) + 0.5;
let min_y_bin = f64::from(stat.min_y) + 0.5;
let max_y_bin = f64::from(stat.max_y) + 0.5;
let sp: [BumpVec<(f64, f64)>; 4] = [
refine_edge_subpixel(
arena,
gray,
&bin_lines[0],
dec,
min_x_bin,
max_x_bin,
min_y_bin,
max_y_bin,
cfg.sample_step,
cfg.grad_min_mag,
),
refine_edge_subpixel(
arena,
gray,
&bin_lines[1],
dec,
min_x_bin,
max_x_bin,
min_y_bin,
max_y_bin,
cfg.sample_step,
cfg.grad_min_mag,
),
refine_edge_subpixel(
arena,
gray,
&bin_lines[2],
dec,
min_x_bin,
max_x_bin,
min_y_bin,
max_y_bin,
cfg.sample_step,
cfg.grad_min_mag,
),
refine_edge_subpixel(
arena,
gray,
&bin_lines[3],
dec,
min_x_bin,
max_x_bin,
min_y_bin,
max_y_bin,
cfg.sample_step,
cfg.grad_min_mag,
),
];
let to_gray = |l: &Line| -> Line {
let cx_g = l.cx * dec;
let cy_g = l.cy * dec;
Line {
nx: l.nx,
ny: l.ny,
d: -(l.nx * cx_g + l.ny * cy_g),
cx: cx_g,
cy: cy_g,
}
};
let try_refit = |pts: &[(f64, f64)], fallback: Line| -> Line {
if pts.len() >= cfg.min_edge_pts {
fit_line_irls(arena, pts, cfg.sp_huber_delta, cfg.sp_irls_iters).unwrap_or(fallback)
} else {
fallback
}
};
let fl: [Line; 4] = [
try_refit(sp[0].as_slice(), to_gray(&bin_lines[0])),
try_refit(sp[1].as_slice(), to_gray(&bin_lines[1])),
try_refit(sp[2].as_slice(), to_gray(&bin_lines[2])),
try_refit(sp[3].as_slice(), to_gray(&bin_lines[3])),
];
let ct = intersect_lines(&fl[3], &fl[0])?;
let cr = intersect_lines(&fl[0], &fl[1])?;
let cb = intersect_lines(&fl[1], &fl[2])?;
let cl = intersect_lines(&fl[2], &fl[3])?;
let sp_slices = [
sp[0].as_slice(),
sp[1].as_slice(),
sp[2].as_slice(),
sp[3].as_slice(),
];
let (refined, gn_covs) = refine_corners_gauss_newton([ct, cr, cb, cl], sp_slices, cfg.gn_iters);
let [ct, cr, cb, cl] = refined;
let margin_x = (max_x_bin - min_x_bin) * dec * 0.25;
let margin_y = (max_y_bin - min_y_bin) * dec * 0.25;
let g_min_x = min_x_bin * dec - margin_x;
let g_max_x = max_x_bin * dec + margin_x;
let g_min_y = min_y_bin * dec - margin_y;
let g_max_y = max_y_bin * dec + margin_y;
for &(qx, qy) in &[ct, cr, cb, cl] {
if qx < g_min_x || qx > g_max_x || qy < g_min_y || qy > g_max_y {
return None;
}
}
let pts4 = [ct, cr, cb, cl];
let area = shoelace_area(&pts4);
if area.abs() < f64::from(stat.pixel_count) * dec * dec * 0.1 {
return None;
}
let inv_dec = 1.0 / dec;
let to_pt = |(x, y): (f64, f64)| Point {
x: x * inv_dec,
y: y * inv_dec,
};
let covs_f32: [[f32; 4]; 4] = match gn_covs {
Some(c) => std::array::from_fn(|i| c[i].map(|v| v as f32)),
None => [[0.0; 4]; 4],
};
Some(if area >= 0.0 {
([to_pt(ct), to_pt(cr), to_pt(cb), to_pt(cl)], covs_f32)
} else {
(
[to_pt(ct), to_pt(cl), to_pt(cb), to_pt(cr)],
[covs_f32[0], covs_f32[3], covs_f32[2], covs_f32[1]],
)
})
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::image::ImageView;
use crate::segmentation::ComponentStats;
fn make_square_image(canvas: usize, sq_x0: usize, sq_y0: usize, sq_size: usize) -> Vec<u8> {
let mut img = vec![200u8; canvas * canvas];
for y in sq_y0..sq_y0 + sq_size {
for x in sq_x0..sq_x0 + sq_size {
img[y * canvas + x] = 30;
}
}
img
}
fn make_labels(canvas: usize, sq_x0: usize, sq_y0: usize, sq_size: usize) -> Vec<u32> {
let mut labels = vec![0u32; canvas * canvas];
for y in sq_y0..sq_y0 + sq_size {
for x in sq_x0..sq_x0 + sq_size {
labels[y * canvas + x] = 1;
}
}
labels
}
fn make_stats_square(sq_x0: u16, sq_y0: u16, sq_size: u16) -> ComponentStats {
let x1 = u64::from(sq_x0);
let y1 = u64::from(sq_y0);
let n = u64::from(sq_size);
let m10 = n * (2 * x1 + n - 1) / 2 * n; let m01 = n * (2 * y1 + n - 1) / 2 * n; ComponentStats {
min_x: sq_x0,
min_y: sq_y0,
max_x: sq_x0 + sq_size - 1,
max_y: sq_y0 + sq_size - 1,
pixel_count: u32::from(sq_size) * u32::from(sq_size),
first_pixel_x: sq_x0,
first_pixel_y: sq_y0,
m10,
m01,
m20: 0,
m02: 0,
m11: 0,
}
}
#[test]
fn test_edlines_square_returns_corners() {
let canvas = 100usize;
let sq_x0 = 30u16;
let sq_y0 = 30u16;
let sq_size = 40u16;
let pixels = make_square_image(canvas, sq_x0 as usize, sq_y0 as usize, sq_size as usize);
let labels = make_labels(canvas, sq_x0 as usize, sq_y0 as usize, sq_size as usize);
let img = ImageView::new(&pixels, canvas, canvas, canvas).unwrap();
let stats = make_stats_square(sq_x0, sq_y0, sq_size);
let cfg = EdLinesConfig {
huber_delta: 1.5,
irls_iters: 3,
sp_huber_delta: 0.5,
sp_irls_iters: 2,
sample_step: 1.5,
grad_min_mag: 4.0, min_edge_pts: 5,
gn_iters: 3,
};
let arena = Bump::new();
let result = extract_quad_edlines(&arena, &img, &img, &labels, 1, &stats, &cfg);
assert!(
result.is_some(),
"EDLines should detect a quad in a clean synthetic square"
);
let true_corners = [
[f64::from(sq_x0), f64::from(sq_y0)],
[f64::from(sq_x0 + sq_size), f64::from(sq_y0)],
[f64::from(sq_x0 + sq_size), f64::from(sq_y0 + sq_size)],
[f64::from(sq_x0), f64::from(sq_y0 + sq_size)],
];
let (corners, _covs) = result.unwrap();
let mut matched = [false; 4];
for corner in &corners {
let mut found = false;
for (i, tc) in true_corners.iter().enumerate() {
if !matched[i] {
let dist = ((corner.x - tc[0]).powi(2) + (corner.y - tc[1]).powi(2)).sqrt();
if dist < 4.0 {
matched[i] = true;
found = true;
break;
}
}
}
assert!(
found,
"Corner ({:.1}, {:.1}) is not within 4 px of any true corner",
corner.x, corner.y
);
}
}
#[test]
fn test_edlines_tiny_roi_returns_none() {
let pixels = vec![100u8; 10 * 10];
let img = ImageView::new(&pixels, 10, 10, 10).unwrap();
let stats = ComponentStats {
min_x: 1,
min_y: 1,
max_x: 3,
max_y: 3,
pixel_count: 9,
first_pixel_x: 1,
first_pixel_y: 1,
m10: 0,
m01: 0,
m20: 0,
m02: 0,
m11: 0,
};
let cfg = EdLinesConfig {
huber_delta: 1.5,
irls_iters: 3,
sp_huber_delta: 0.5,
sp_irls_iters: 2,
sample_step: 1.5,
grad_min_mag: 8.0,
min_edge_pts: 5,
gn_iters: 3,
};
let arena = Bump::new();
let result = extract_quad_edlines(&arena, &img, &img, &[], 0, &stats, &cfg);
assert!(result.is_none(), "Tiny ROI must return None");
}
#[test]
fn test_gn_covariance_nonzero() {
let corners: [(f64, f64); 4] = [(30.0, 30.0), (70.0, 30.0), (70.0, 70.0), (30.0, 70.0)];
let mut sp: [Vec<(f64, f64)>; 4] = [vec![], vec![], vec![], vec![]];
let n = 20_usize;
for k in 0..4 {
let (ax, ay) = corners[k];
let (bx, by) = corners[(k + 1) % 4];
let ex = bx - ax;
let ey = by - ay;
let len = (ex * ex + ey * ey).sqrt();
let nx = -ey / len;
let ny = ex / len;
for i in 0..n {
let t = (i as f64 + 0.5) / n as f64;
let noise = 0.1 * ((i * 7 + k * 13) as f64).sin();
sp[k].push((
ax + t * (bx - ax) + noise * nx,
ay + t * (by - ay) + noise * ny,
));
}
}
let sp_refs: [&[(f64, f64)]; 4] = [&sp[0], &sp[1], &sp[2], &sp[3]];
let (refined, cov_opt) = refine_corners_gauss_newton(corners, sp_refs, 5);
for (i, &rc) in refined.iter().enumerate() {
let dx = rc.0 - corners[i].0;
let dy = rc.1 - corners[i].1;
assert!(
dx.abs() < 0.5 && dy.abs() < 0.5,
"Corner {i} moved too far: ({dx:.4}, {dy:.4})"
);
}
let covs = cov_opt.expect("GN should return covariances for a converged solution");
for (k, cov) in covs.iter().enumerate() {
let sigma_xx = cov[0];
let sigma_yy = cov[3];
assert!(
sigma_xx > 0.0 && sigma_yy > 0.0,
"Corner {k}: diagonal covariances must be positive, got σ_xx={sigma_xx}, σ_yy={sigma_yy}"
);
let sigma_xy = cov[1];
assert!(
sigma_xx.abs() >= sigma_xy.abs(),
"Corner {k}: expected diagonal dominance"
);
}
}
#[test]
fn test_gn_covariance_scales_with_noise() {
let corners: [(f64, f64); 4] = [(30.0, 30.0), (70.0, 30.0), (70.0, 70.0), (30.0, 70.0)];
let n = 30_usize;
let mut sp_clean: [Vec<(f64, f64)>; 4] = [vec![], vec![], vec![], vec![]];
let mut sp_noisy: [Vec<(f64, f64)>; 4] = [vec![], vec![], vec![], vec![]];
for k in 0..4 {
let (ax, ay) = corners[k];
let (bx, by) = corners[(k + 1) % 4];
let ex = bx - ax;
let ey = by - ay;
let len = (ex * ex + ey * ey).sqrt();
let nx = -ey / len;
let ny = ex / len;
for i in 0..n {
let t = (i as f64 + 0.5) / n as f64;
let px = ax + t * (bx - ax);
let py = ay + t * (by - ay);
sp_clean[k].push((px, py));
let noise = 0.5 * ((i * 7 + k * 13) as f64).sin();
sp_noisy[k].push((px + noise * nx, py + noise * ny));
}
}
let refs_clean: [&[(f64, f64)]; 4] =
[&sp_clean[0], &sp_clean[1], &sp_clean[2], &sp_clean[3]];
let refs_noisy: [&[(f64, f64)]; 4] =
[&sp_noisy[0], &sp_noisy[1], &sp_noisy[2], &sp_noisy[3]];
let (_, cov_clean) = refine_corners_gauss_newton(corners, refs_clean, 5);
let (_, cov_noisy) = refine_corners_gauss_newton(corners, refs_noisy, 5);
let covs_c = cov_clean.expect("clean should converge");
let covs_n = cov_noisy.expect("noisy should converge");
let trace_clean: f64 = covs_c.iter().map(|c| c[0] + c[3]).sum();
let trace_noisy: f64 = covs_n.iter().map(|c| c[0] + c[3]).sum();
assert!(
trace_noisy > trace_clean,
"Noisy observations should yield larger covariances: clean={trace_clean:.6}, noisy={trace_noisy:.6}"
);
}
}