app
AI Flow: Reverse Engineering
Public Made by Adomby adom
Photos of a PCB to a tagged KiCad copy: parts placed, copper and nets with their evidence, a schematic, a measured confidence score, bench tests and a video of the run.
main
Caleb
Publish 0.5.0
1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
//! Laying the bottom face on the top face through what goes through the board. A plated hole, a test point, a through-hole pad and its solder
//! joint sit at one place on both faces, whatever each photo's outline came out as. The old way assumed the two outlines coincide and looked
//! for holes that already agreed: with a bottom outline 2.3 mm too tall (MOSFET board, 2026-11-10) nothing agreed, either way round.
//! Here both faces give a point set, both ways of turning the board over are tried, and the bottom-to-top map is the similarity most point
//! pairs agree on (consensus over every pair of pairs, then least squares, then an affine fit when the pairs span the board).
//! Nothing here uses the outlines, so a bad outline cannot move the answer; the outlines are then compared through the map.
use crate::geom::{self, P};
use crate::raster;
use image::RgbImage;
use serde_json::{json, Value};
/// How close a bottom point must land to a top point to count, in photo pixels, before the scale is known: about 0.2 mm at the 65 to 77 px/mm of the
/// 2026-11-10 close-up sets. A solder joint's middle and its pad's middle on the other face differ by up to 0.15 mm on those photos.
pub const TOL_PX: f64 = 14.0;
/// Fewer matched points than this is not a registration: two are matched by construction and three or four more turn up by chance among a hundred points.
pub const MIN_MATCH: usize = 6;
/// The faces' outlines agree when each edge lies within this of the other face's, mm. A board's edge reads to about 0.2 mm; shadow errors seen were 0.9 and 2.3 mm.
pub const OUTLINE_TOL_MM: f64 = 0.5;
/// A pixel is mask, for finding pads, when its saturation is at least this share of the 10th percentile of the mask's own. Mask saturation at its 10th percentile
/// was 0.45 to 0.78 on the 2026-11-10 photos; pale tinned rings read 0.2 to 0.3.
pub const PAD_SAT_SHARE: f32 = 0.6;
fn f(v: &Value, k: &str) -> f64 { v[k].as_f64().unwrap_or(0.0) }
/// Round things that are not solder mask and stand alone in it: pad rings, test points, solder joints, via rings. A ring whose opening is full of solder
/// or holds a pin is not a "hole" to `prep::holes`, which found 12 of some 90 through features on the MOSFET board's bottom. Returns centre and diameter in the image's pixels.
pub fn pads(img: &RgbImage, min_d: f64, max_d: f64) -> Vec<Value> {
let tone = crate::prep::mask_tone(img);
// mask here is this photo's own: near its hue and at least `PAD_SAT_SHARE` of its own 10th-percentile saturation. A tinned ring under a green cast reads
// as pale green (saturation 0.25 where the mask round it has 0.8), which the wide green test takes for mask.
let near = |h: f32| (h - tone.hue).abs() < crate::outline::HUE_REACH;
let step = ((img.width() as usize * img.height() as usize) / 400_000).max(1);
let mut sats: Vec<f32> = img.pixels().step_by(step).filter_map(|p| { let (h, s, v) = raster::hsv(p); (near(h) && s > 0.18 && v > 0.07 && v < tone.v_hi).then_some(s) }).collect();
sats.sort_by(|a, b| a.partial_cmp(b).unwrap());
let s_floor = if sats.is_empty() { 0.18 } else { (PAD_SAT_SHARE * sats[(sats.len() - 1) / 10]).max(0.18) };
let m = raster::mask_from(img, |p| { let (h, s, v) = raster::hsv(p); !(near(h) && s > s_floor && v > 0.07 && v < tone.v_hi) });
let (_, cs) = raster::components(&m);
let mut out = vec![];
for c in &cs {
let (bw, bh) = ((c.x1 - c.x0 + 1) as f64, (c.y1 - c.y0 + 1) as f64);
if bw < min_d || bh < min_d || bw > max_d || bh > max_d || bw / bh > 1.3 || bh / bw > 1.3 { continue; }
// touching the picture's edge it is not enclosed by mask
if c.x0 == 0 || c.y0 == 0 || c.x1 + 1 >= m.w || c.y1 + 1 >= m.h { continue; }
let fill = c.area as f64 / (std::f64::consts::FRAC_PI_4 * bw * bh);
if !(0.72..1.12).contains(&fill) { continue; }
out.push(json!({"x": (c.x0 + c.x1) as f64 / 2.0 + 0.5, "y": (c.y0 + c.y1) as f64 / 2.0 + 0.5, "d": (bw + bh) / 2.0}));
}
out
}
/// What one photo offers for registration, in the photo's own pixels: (x, y, diameter). Pads found on the whole photo, and the ringed holes `prep` found on the
/// straightened picture taken back through `to_photo`. The photo and not the straightened picture, because a bad outline shears the straightened one.
pub fn through(pads: &[Value], holes: &[Value], to_photo: &[f64; 9]) -> Vec<Value> {
let mut out: Vec<(P, f64)> = vec![];
for h in holes { let p = geom::apply(to_photo, (f(h, "x"), f(h, "y"))); if !out.iter().any(|q| geom::dist(q.0, p) < TOL_PX) { out.push((p, f(h, "outer"))); } }
for h in pads { let p = (f(h, "x"), f(h, "y")); if !out.iter().any(|q| geom::dist(q.0, p) < TOL_PX) { out.push((p, f(h, "d"))); } }
out.sort_by(|a, b| (a.0 .1, a.0 .0).partial_cmp(&(b.0 .1, b.0 .0)).unwrap());
out.iter().map(|(p, d)| json!([r3(p.0), r3(p.1), r3(*d)])).collect()
}
/// A record's registration points and their diameters.
pub fn points(rec: &Value) -> (Vec<P>, Vec<f64>) {
rec["through"].as_array().into_iter().flatten().map(|t| ((t[0].as_f64().unwrap_or(0.0), t[1].as_f64().unwrap_or(0.0)), t[2].as_f64().unwrap_or(0.0))).unzip()
}
/// The bottom photo's point as the top view would show it, before any fit: turned over left-right ("x") or top-bottom ("y"). Its own inverse.
pub fn mirror(axis: &str, p: P, wh: P) -> P { if axis == "y" { (p.0, wh.1 - p.1) } else { (wh.0 - p.0, p.1) } }
fn to9(m: &[f64; 6]) -> [f64; 9] { [m[0], m[1], m[2], m[3], m[4], m[5], 0.0, 0.0, 1.0] }
fn apply6(m: &[f64; 6], p: P) -> P { (m[0] * p.0 + m[1] * p.1 + m[2], m[3] * p.0 + m[4] * p.1 + m[5]) }
fn mul(a: &[f64; 9], b: &[f64; 9]) -> [f64; 9] { let mut o = [0.0; 9]; for i in 0..3 { for j in 0..3 { for k in 0..3 { o[i * 3 + j] += a[i * 3 + k] * b[k * 3 + j]; } } } o }
#[derive(Clone, Debug)]
pub struct Fit {
pub axis: String,
/// pairs within the tolerance, each point used once
pub matched: usize,
/// RMS and largest distance between the paired points after the fit, in the top photo's pixels
pub rms: f64, pub max: f64,
/// mirrored bottom photo pixels to top photo pixels, a homography (row-major)
pub m: [f64; 9],
pub kind: &'static str,
/// (top point, mirrored bottom point)
pub pairs: Vec<(P, P)>,
}
impl Fit {
/// The scale and the turn at the middle of the matched points.
pub fn scale_turn(&self) -> (f64, f64) {
let n = self.pairs.len().max(1) as f64;
let c = self.pairs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.1 .0 / n, s.1 + p.1 .1 / n));
let (o, x) = (geom::apply(&self.m, c), geom::apply(&self.m, (c.0 + 100.0, c.1)));
(geom::dist(o, x) / 100.0, (x.1 - o.1).atan2(x.0 - o.0).to_degrees())
}
}
/// Every k-th point, so that at most `n` are left, spread as the list is (it is sorted down the picture).
fn thin(p: &[P], n: usize) -> Vec<P> { if p.len() <= n { return p.to_vec(); } let step = (p.len() + n - 1) / n; p.iter().step_by(step).copied().collect() }
/// Where the top points are, as a grid of cells half the tolerance wide: a cell is set when a top point lies within the tolerance of it.
struct Near { w: usize, h: usize, cell: f64, x0: f64, y0: f64, d: Vec<bool> }
impl Near {
fn new(a: &[P], tol: f64) -> Near {
let cell = tol / 2.0;
let (x0, y0) = (a.iter().map(|p| p.0).fold(f64::MAX, f64::min) - 2.0 * tol, a.iter().map(|p| p.1).fold(f64::MAX, f64::min) - 2.0 * tol);
let (x1, y1) = (a.iter().map(|p| p.0).fold(f64::MIN, f64::max) + 2.0 * tol, a.iter().map(|p| p.1).fold(f64::MIN, f64::max) + 2.0 * tol);
let (w, h) = (((x1 - x0) / cell).ceil() as usize + 1, ((y1 - y0) / cell).ceil() as usize + 1);
let mut d = vec![false; w * h];
for p in a { let (cx, cy) = (((p.0 - x0) / cell) as i64, ((p.1 - y0) / cell) as i64);
for dy in -2i64..=2 { for dx in -2i64..=2 { let (u, v) = (cx + dx, cy + dy); if u >= 0 && v >= 0 && (u as usize) < w && (v as usize) < h { d[v as usize * w + u as usize] = true; } } } }
Near { w, h, cell, x0, y0, d }
}
#[inline] fn has(&self, p: P) -> bool { let (u, v) = ((p.0 - self.x0) / self.cell, (p.1 - self.y0) / self.cell); u >= 0.0 && v >= 0.0 && (u as usize) < self.w && (v as usize) < self.h && self.d[v as usize * self.w + u as usize] }
}
/// Pairs under a map: each bottom point with its nearest top point within `within`, each top point kept for its closest bottom point only.
fn pair_up(a: &[P], b: &[P], m: &[f64; 9], within: f64) -> Vec<(P, P)> {
let mut best: Vec<Option<(f64, usize)>> = vec![None; a.len()];
for (j, q) in b.iter().enumerate() {
let t = geom::apply(m, *q);
let Some((i, d)) = a.iter().enumerate().map(|(i, p)| (i, geom::dist(*p, t))).min_by(|x, y| x.1.partial_cmp(&y.1).unwrap()) else { continue };
if d < within && best[i].map_or(true, |o| d < o.0) { best[i] = Some((d, j)); }
}
best.iter().enumerate().filter_map(|(i, o)| o.map(|(_, j)| (a[i], b[j]))).collect()
}
/// Least-squares similarity (scale, turn, shift) taking each pair's second point to its first.
fn similarity(prs: &[(P, P)]) -> Option<[f64; 9]> {
if prs.len() < 2 { return None; }
let n = prs.len() as f64;
let (ma, mb) = (prs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.0 .0 / n, s.1 + p.0 .1 / n)), prs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.1 .0 / n, s.1 + p.1 .1 / n)));
let (mut dot, mut cross, mut bb) = (0.0, 0.0, 0.0);
for (a, b) in prs { let (ax, ay, bx, by) = (a.0 - ma.0, a.1 - ma.1, b.0 - mb.0, b.1 - mb.1); dot += bx * ax + by * ay; cross += bx * ay - by * ax; bb += bx * bx + by * by; }
if bb < 1e-9 { return None; }
let (c, s) = (dot / bb, cross / bb);
Some(to9(&[c, -s, ma.0 - (c * mb.0 - s * mb.1), s, c, ma.1 - (s * mb.0 + c * mb.1)]))
}
fn solve_n<const N: usize>(mut a: [[f64; N]; N], mut b: [f64; N]) -> Option<[f64; N]> {
for c in 0..N {
let p = (c..N).max_by(|i, j| a[*i][c].abs().partial_cmp(&a[*j][c].abs()).unwrap())?;
if a[p][c].abs() < 1e-9 { return None; }
a.swap(c, p); b.swap(c, p);
for r in 0..N { if r != c { let k = a[r][c] / a[c][c]; for q in c..N { a[r][q] -= k * a[c][q]; } b[r] -= k * b[c]; } }
}
let mut x = [0.0; N]; for i in 0..N { x[i] = b[i] / a[i][i]; } Some(x)
}
/// Least-squares affine map, the same way round. None when the pairs do not span two directions.
fn affine(prs: &[(P, P)]) -> Option<[f64; 9]> {
if prs.len() < 3 { return None; }
let mut ata = [[0f64; 3]; 3]; let (mut atx, mut aty) = ([0f64; 3], [0f64; 3]);
let n = prs.len() as f64;
let mb = prs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.1 .0 / n, s.1 + p.1 .1 / n));
for (a, b) in prs { let r = [(b.0 - mb.0) / 1000.0, (b.1 - mb.1) / 1000.0, 1.0]; for i in 0..3 { for j in 0..3 { ata[i][j] += r[i] * r[j]; } atx[i] += r[i] * a.0; aty[i] += r[i] * a.1; } }
let (x, y) = (solve_n(ata, atx)?, solve_n(ata, aty)?);
let (x0, x1, y0, y1) = (x[0] / 1000.0, x[1] / 1000.0, y[0] / 1000.0, y[1] / 1000.0);
Some(to9(&[x0, x1, x[2] - x0 * mb.0 - x1 * mb.1, y0, y1, y[2] - y0 * mb.0 - y1 * mb.1]))
}
/// Least-squares homography, the same way round, on coordinates centred and scaled so that the sums stay well conditioned.
fn homography(prs: &[(P, P)]) -> Option<[f64; 9]> {
if prs.len() < 4 { return None; }
let n = prs.len() as f64;
let (ma, mb) = (prs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.0 .0 / n, s.1 + p.0 .1 / n)), prs.iter().fold((0.0, 0.0), |s, p| (s.0 + p.1 .0 / n, s.1 + p.1 .1 / n)));
let sc = prs.iter().map(|p| geom::dist(p.1, mb)).fold(1.0, f64::max);
let mut ata = [[0f64; 8]; 8]; let mut atb = [0f64; 8];
for (a, b) in prs {
let (x, y, u, v) = ((b.0 - mb.0) / sc, (b.1 - mb.1) / sc, (a.0 - ma.0) / sc, (a.1 - ma.1) / sc);
for (row, rhs) in [([x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y], u), ([0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y], v)] {
for i in 0..8 { for j in 0..8 { ata[i][j] += row[i] * row[j]; } atb[i] += row[i] * rhs; }
}
}
let h = solve_n(ata, atb)?;
let hn = [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0];
let tb = [1.0 / sc, 0.0, -mb.0 / sc, 0.0, 1.0 / sc, -mb.1 / sc, 0.0, 0.0, 1.0];
let ta_inv = [sc, 0.0, ma.0, 0.0, sc, ma.1, 0.0, 0.0, 1.0];
let m = mul(&ta_inv, &mul(&hn, &tb));
if m[8].abs() < 1e-12 { return None; }
Some(m.map(|v| v / m[8]))
}
/// The map from the mirrored bottom points `b` to the top points `a` that most points agree on. Both photos show the board square to the picture, so the turn
/// between them is small (6 degrees allowed) and the scale between them is near one (0.75 to 1.33 allowed: the two faces need not be photographed from one height).
/// Consensus is counted at twice the tolerance, because two hand-held photos differ by a little perspective that a similarity cannot take up; the affine fit and the
/// homography after it can, and the count reported is at the tolerance.
pub fn consensus(a: &[P], b: &[P], tol: f64) -> Option<Fit> {
if a.len() < 2 || b.len() < 2 { return None; }
let near = Near::new(a, 2.0 * tol);
let (ah, bh) = (thin(a, 60), thin(b, 220));
let span = { let (x0, x1) = a.iter().fold((f64::MAX, f64::MIN), |s, p| (s.0.min(p.0), s.1.max(p.0))); let (y0, y1) = a.iter().fold((f64::MAX, f64::MIN), |s, p| (s.0.min(p.1), s.1.max(p.1))); (x1 - x0).max(y1 - y0) };
let min_len = 0.2 * span;
let sin_max = 6f64.to_radians().sin();
let mut best: (usize, [f64; 6]) = (0, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]);
for i in 0..ah.len() { for j in i + 1..ah.len() {
let va = (ah[j].0 - ah[i].0, ah[j].1 - ah[i].1); let la = (va.0 * va.0 + va.1 * va.1).sqrt();
if la < min_len { continue; }
for k in 0..bh.len() { for l in 0..bh.len() {
if k == l { continue; }
let vb = (bh[l].0 - bh[k].0, bh[l].1 - bh[k].1); let lb2 = vb.0 * vb.0 + vb.1 * vb.1;
let s2 = la * la / lb2.max(1e-9);
if !(0.5625..1.7689).contains(&s2) { continue; }
let lb = lb2.sqrt();
let (co, si) = ((va.0 * vb.0 + va.1 * vb.1) / (la * lb), (vb.0 * va.1 - vb.1 * va.0) / (la * lb));
if co <= 0.0 || si.abs() > sin_max { continue; }
let s = la / lb; let (c, sn) = (s * co, s * si);
let m = [c, -sn, ah[i].0 - (c * bh[k].0 - sn * bh[k].1), sn, c, ah[i].1 - (sn * bh[k].0 + c * bh[k].1)];
let n = b.iter().filter(|q| near.has(apply6(&m, **q))).count();
if n > best.0 { best = (n, m); }
} }
} }
if best.0 < 2 { return None; }
let mut m = to9(&best.1); let mut kind = "similarity";
for reach in [2.0, 2.0, 1.5] { match similarity(&pair_up(a, b, &m, reach * tol)) { Some(n) => m = n, None => break } }
let spans = |prs: &[(P, P)]| { let (x0, x1) = prs.iter().fold((f64::MAX, f64::MIN), |s, p| (s.0.min(p.0 .0), s.1.max(p.0 .0))); let (y0, y1) = prs.iter().fold((f64::MAX, f64::MIN), |s, p| (s.0.min(p.0 .1), s.1.max(p.0 .1))); (x1 - x0).min(y1 - y0) };
// a richer map only when the pairs span the board both ways and it keeps at least as many
let wide = pair_up(a, b, &m, 2.0 * tol);
if wide.len() >= 6 && spans(&wide) > 0.3 * span {
let mut ma = m; let mut ok = true;
for reach in [2.0, 1.5, 1.0] { match affine(&pair_up(a, b, &ma, reach * tol)) { Some(n) => ma = n, None => { ok = false; break } } }
if ok && pair_up(a, b, &ma, tol).len() >= pair_up(a, b, &m, tol).len() { m = ma; kind = "affine"; }
let wide = pair_up(a, b, &m, 1.5 * tol);
if kind == "affine" && wide.len() >= 12 && spans(&wide) > 0.4 * span {
let mut mh = m; let mut ok = true;
for reach in [1.5, 1.0, 1.0] { match homography(&pair_up(a, b, &mh, reach * tol)) { Some(n) => mh = n, None => { ok = false; break } } }
if ok && pair_up(a, b, &mh, tol).len() >= pair_up(a, b, &m, tol).len() { m = mh; kind = "homography"; }
}
} else { for _ in 0..2 { if let Some(n) = similarity(&pair_up(a, b, &m, tol)) { m = n; } } }
let prs = pair_up(a, b, &m, tol);
let res: Vec<f64> = prs.iter().map(|(t, o)| geom::dist(*t, geom::apply(&m, *o))).collect();
let rms = if res.is_empty() { f64::NAN } else { (res.iter().map(|x| x * x).sum::<f64>() / res.len() as f64).sqrt() };
Some(Fit { axis: String::new(), matched: prs.len(), rms, max: res.iter().cloned().fold(0.0, f64::max), m, kind, pairs: prs })
}
/// The fit for one way of turning the board over. `bot_wh` is the bottom photo's size.
pub fn fit_axis(top: &[P], bot: &[P], bot_wh: P, axis: &str, tol: f64) -> Option<Fit> {
let b: Vec<P> = bot.iter().map(|p| mirror(axis, *p, bot_wh)).collect();
consensus(top, &b, tol).map(|mut x| { x.axis = axis.to_string(); x })
}
/// The few largest round openings of a face (mounting holes), for a board that shares little else: at least `BIG_SHARE` of the largest diameter, eight at most.
fn big(p: &[P], d: &[f64]) -> Vec<(P, f64)> {
let top = d.iter().cloned().fold(0.0, f64::max);
let mut v: Vec<(P, f64)> = p.iter().cloned().zip(d.iter().cloned()).filter(|x| x.1 >= BIG_SHARE * top).collect();
v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); v.truncate(10);
v
}
/// Two openings are of one size when their diameters, the map's scale taken out, agree this closely (natural log of the ratio): 8 percent. A ring reads to a few
/// pixels in 340, and the test is what ties the fit's scale to something measured: a fit that scales one face by 1.2 fails it on every opening.
pub const SIZE_AGREE: f64 = 0.08;
/// Large openings paired under a map: within `tol`, and of one size once the map's scale is taken out (within a fifth), each used once.
fn big_pairs(la: &[(P, f64)], lb: &[(P, f64)], m: &[f64; 9], tol: f64) -> Vec<(P, P)> {
let s = (m[0] * m[0] + m[3] * m[3]).sqrt();
let mut used = vec![false; la.len()]; let mut out = vec![];
for (q, dq) in lb { let t = geom::apply(m, *q);
if let Some((i, _)) = la.iter().enumerate().filter(|(i, a)| !used[*i] && geom::dist(a.0, t) < tol && (a.1 / (dq * s)).ln().abs() < SIZE_AGREE).min_by(|x, y| geom::dist(x.1 .0, t).partial_cmp(&geom::dist(y.1 .0, t)).unwrap()) { used[i] = true; out.push((la[i].0, *q)); } }
out
}
/// A feature counts as large from this share of the face's largest. Mounting holes are 2.5 to 4 times a pin pad across on the 2026-11-10 boards.
pub const BIG_SHARE: f64 = 0.6;
/// Large openings of one size matched, at least: two fix a similarity, the third must then land within the tolerance and be of that size too.
pub const MIN_BIG: usize = 3;
/// An edge of one face's own outline agrees with the other's when both ends of the other's, brought over, lie within this of its line, photo pixels: about 0.6 mm
/// at the 65 to 77 px/mm of the 2026-11-10 sets. A tight outline reads to about 0.2 mm; an edge hidden under parts is off by many millimetres.
pub const EDGE_TOL_PX: f64 = 40.0;
/// From this many matched points the outlines are not asked to confirm the fit: rows of holes at one pitch reached 10 the wrong way round on the MOSFET board.
pub const SURE_MATCH: usize = 12;
/// How many of the four edges of the top's own outline the bottom's own outline lies on, under a map.
fn edges_agree(tc: &[P; 4], bc: &[P; 4], m: &[f64; 9], axis: &str, bwh: P) -> usize {
let pm = perm(axis); let mut bo = [(0.0, 0.0); 4]; for i in 0..4 { bo[pm[i]] = geom::apply(m, mirror(axis, bc[i], bwh)); }
let off = |p: P, a: P, b: P| ((b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0)).abs() / geom::dist(a, b).max(1e-9);
(0..4).filter(|i| { let j = (i + 1) % 4; off(bo[*i], tc[*i], tc[j]) < EDGE_TOL_PX && off(bo[j], tc[*i], tc[j]) < EDGE_TOL_PX }).count()
}
/// Every similarity that lays two large openings of one face on two of the other, far apart (0.4 of the photo), of one size, within the scale and turn two photos of
/// one board can differ by. Fits that put the photo's corners within four tolerances of each other count once.
fn two_hole_fits(la: &[(P, f64)], lb: &[(P, f64)], bwh: P) -> Vec<[f64; 9]> {
let tol = 2.0 * TOL_PX; let mut out: Vec<[f64; 9]> = vec![];
for i in 0..la.len() { for j in i + 1..la.len() { if geom::dist(la[i].0, la[j].0) <= 0.4 * bwh.0.max(bwh.1) { continue; } for k in 0..lb.len() { for l in 0..lb.len() {
if k == l { continue; }
let Some(m) = similarity(&[(la[i].0, lb[k].0), (la[j].0, lb[l].0)]) else { continue };
let s2 = m[0] * m[0] + m[3] * m[3];
if !(0.5625..1.7689).contains(&s2) || m[0] <= 0.0 || (m[3] / s2.sqrt()).abs() > 6f64.to_radians().sin() { continue; }
if big_pairs(la, lb, &m, tol).len() < 2 { continue; }
if !out.iter().any(|q| geom::dist(geom::apply(q, (0.0, 0.0)), geom::apply(&m, (0.0, 0.0))) < 4.0 * tol && geom::dist(geom::apply(q, bwh), geom::apply(&m, bwh)) < 4.0 * tol) { out.push(m); }
} } } }
out
}
/// The tiers for one axis. Every through feature first: `MIN_MATCH` of them with two outline edges agreeing, or `SURE_MATCH` on their own. Failing that the large
/// openings (mounting holes): every similarity two pairs of them give, judged by how many large openings and how many outline edges agree: four openings, or three
/// and two edges, or two and three edges. Returns the fit and whether it registers the faces.
pub fn register(top: &Value, bot: &Value, axis: &str) -> (Option<Fit>, bool) {
let ((a, da), (b, db)) = (points(top), points(bot));
let bwh = photo_wh(bot);
let outl = own_corners(top).zip(own_corners(bot));
let edges = |m: &[f64; 9]| outl.as_ref().map_or(0, |(t, o)| edges_agree(t, o, m, axis, bwh));
let all = fit_axis(&a, &b, bwh, axis, TOL_PX);
let la = big(&a, &da);
let lb: Vec<(P, f64)> = big(&b, &db).into_iter().map(|(p, d)| (mirror(axis, p, bwh), d)).collect();
let tol = 2.0 * TOL_PX;
let bm: Vec<P> = b.iter().map(|p| mirror(axis, *p, bwh)).collect();
// six to eleven matches want a second witness: two outline edges, or two large openings of one size that the same fit lays on each other
// (a board with terminal blocks over its edges has no outline edge to offer: test board 6, 2026-11-11)
if all.as_ref().map_or(false, |x| x.matched >= SURE_MATCH || (x.matched >= MIN_MATCH && (edges(&x.m) >= 2 || big_pairs(&la, &lb, &x.m, tol).len() >= 2))) { return (all, true); }
let mut best: Option<(usize, usize, [f64; 9])> = None;
let mut twos: Vec<[f64; 9]> = vec![];
for i in 0..la.len() { for j in i + 1..la.len() { for k in 0..lb.len() { for l in 0..lb.len() {
if k == l { continue; }
let Some(m) = similarity(&[(la[i].0, lb[k].0), (la[j].0, lb[l].0)]) else { continue };
let s2 = m[0] * m[0] + m[3] * m[3];
if !(0.5625..1.7689).contains(&s2) || m[0] <= 0.0 || (m[3] / s2.sqrt()).abs() > 6f64.to_radians().sin() { continue; }
// how many of all the through features the same fit lays on each other counts with the openings: rows of pins at one pitch can mislead a
// consensus of small features alone, and two mounting holes alone prove nothing
let (n, e) = (big_pairs(&la, &lb, &m, tol).len(), edges(&m) + pair_up(&a, &bm, &m, tol).len());
if best.as_ref().map_or(true, |q| (n, e) > (q.0, q.1)) { best = Some((n, e, m)); }
// two openings far apart: kept to see whether exactly one such fit exists
if n == 2 && geom::dist(la[i].0, la[j].0) > 0.4 * bwh.0.max(bwh.1) && !twos.iter().any(|q: &[f64; 9]| geom::dist(geom::apply(q, (0.0, 0.0)), geom::apply(&m, (0.0, 0.0))) < 4.0 * tol && geom::dist(geom::apply(q, bwh), geom::apply(&m, bwh)) < 4.0 * tol) { twos.push(m); }
} } } }
// two mounting holes of one size, far apart, and no other pair of openings that fits another way: a similarity, weak, said as such
let two_alone = best.as_ref().map_or(false, |q| q.0 == 2) && twos.len() == 1;
if two_alone { best = best.map(|q| (q.0, q.1, twos[0])); }
// three openings of one size at one triangle are two more agreements than the two that made the fit; two need the outlines to confirm
if let Some((n, _, m0)) = best { if n >= 3 || (n >= 2 && (edges(&m0) >= 3 || two_alone)) {
let prs = big_pairs(&la, &lb, &m0, tol);
let mut m = similarity(&prs).unwrap_or(m0);
// every other through feature that lands on one joins the fit
let mut all_prs = prs.clone();
// other through features join only where three or more openings already hold the fit
if n >= 3 { for reach in [1.5, 1.0] { let more = pair_up(&a, &bm, &m, reach * TOL_PX); if more.len() > all_prs.len() { if let Some(n2) = similarity(&more) { m = n2; all_prs = more; } } } }
let res: Vec<f64> = all_prs.iter().map(|(t, o)| geom::dist(*t, geom::apply(&m, *o))).collect();
let rms = (res.iter().map(|x| x * x).sum::<f64>() / res.len().max(1) as f64).sqrt();
let kind = if n >= 3 { "similarity through three or more large openings of one size" } else { "similarity through two large openings of one size" };
return (Some(Fit { axis: axis.to_string(), matched: all_prs.len(), rms, max: res.iter().cloned().fold(0.0, f64::max), m, kind, pairs: all_prs }), true);
} }
(all, false)
}
fn photo_wh(rec: &Value) -> P { (rec["photoPx"][0].as_f64().unwrap_or(0.0), rec["photoPx"][1].as_f64().unwrap_or(0.0)) }
fn r3(x: f64) -> f64 { (x * 1000.0).round() / 1000.0 }
fn corners_of(rec: &Value, key: &str) -> Option<[P; 4]> { let c = rec[key].as_array()?; if c.len() != 4 { return None; } Some([0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap_or(0.0), c[i][1].as_f64().unwrap_or(0.0)))) }
/// The outline fitted to this photo alone: kept as `cornersOwn` when the photo was straightened through the shared outline, as `cornersFitted` when `scale` has since moved the corners.
fn own_corners(rec: &Value) -> Option<[P; 4]> { corners_of(rec, "cornersOwn").or(corners_of(rec, "cornersFitted")).or(corners_of(rec, "corners")) }
/// Which corner of the top photo each corner of the bottom photo (TL, TR, BR, BL in its own view) is, for a flip: the order `board::frame_corners` lays the bottom by.
fn perm(axis: &str) -> [usize; 4] { if axis == "y" { [3, 2, 1, 0] } else { [1, 0, 3, 2] } }
/// The top photo's corners seen in the bottom photo.
fn to_bottom(top_c: &[P; 4], fit: &Fit, bwh: P) -> Option<[P; 4]> { let inv = geom::invert(&fit.m)?; let pm = perm(&fit.axis); Some([0, 1, 2, 3].map(|i| mirror(&fit.axis, geom::apply(&inv, top_c[pm[i]]), bwh))) }
/// The bottom photo's corners seen in the top photo, in the top's order.
fn to_top(bot_c: &[P; 4], fit: &Fit, bwh: P) -> [P; 4] { let pm = perm(&fit.axis); let mut o = [(0.0, 0.0); 4]; for i in 0..4 { o[pm[i]] = geom::apply(&fit.m, mirror(&fit.axis, bot_c[i], bwh)); } o }
fn quad_size(c: &[P; 4]) -> P { ((geom::dist(c[0], c[1]) + geom::dist(c[3], c[2])) / 2.0, (geom::dist(c[0], c[3]) + geom::dist(c[1], c[2])) / 2.0) }
fn pick(d: &Value, side: &str) -> Option<Value> { let name = d["primary"][side].as_str()?.to_string(); d["photos"].as_array()?.iter().find(|p| p["file"].as_str() == Some(name.as_str())).cloned() }
/// The top edge (corner e to e + 1, the top's order) that each edge of the bottom photo's outline is, for a flip.
fn top_edge(axis: &str, i: usize) -> usize { let pm = perm(axis); let (a, b) = (pm[i], pm[(i + 1) % 4]); if (a + 1) % 4 == b { a } else { b } }
/// The faces laid together through their own outlines, for a board that shares no through feature the pads test finds. An edge is the board's when no solder mask
/// lies beyond it (`outline::mask_beyond`). All four true on both faces: the four corners, a homography. One edge hidden on either face (parts over it): a similarity
/// through the opposite edge, which that edge and its two neighbours fix. More hidden: None.
pub fn outline_fit(top: &Value, bot: &Value, axis: &str) -> Option<Fit> {
let (tc, bc) = (own_corners(top)?, own_corners(bot)?);
let bwh = photo_wh(bot);
let open = |rec: &Value, i: usize| rec["maskBeyondOwnEdges"][i].as_f64().map_or(true, |v| v > crate::outline::OPEN_EDGE_SHARE);
let mut bad = [false; 4];
for i in 0..4 { if open(top, i) { bad[i] = true; } if open(bot, i) { bad[top_edge(axis, i)] = true; } }
let pm = perm(axis); let mut src = [(0.0, 0.0); 4]; for i in 0..4 { src[pm[i]] = mirror(axis, bc[i], bwh); }
let pairs: Vec<(P, P)> = match bad.iter().filter(|b| **b).count() {
0 => (0..4).map(|i| (tc[i], src[i])).collect(),
1 => { let e = (bad.iter().position(|b| *b)? + 2) % 4; vec![(tc[e], src[e]), (tc[(e + 1) % 4], src[(e + 1) % 4])] }
_ => return None };
let (m, kind) = if pairs.len() == 4 { (homography(&pairs)?, "the outlines: all four edges of both faces are the board's") } else { (similarity(&pairs)?, "the outlines: a similarity through the edge opposite the one parts hide") };
let fit = Fit { axis: axis.to_string(), matched: 0, rms: f64::NAN, max: f64::NAN, m, kind, pairs };
// both faces show large openings (mounting holes): an outline fit that lays fewer than two of them on each other is wrong, whatever the outlines say
let ((a, da), (b, db)) = (points(top), points(bot));
let (la, lb) = (big(&a, &da), big(&b, &db).into_iter().map(|(p, d)| (mirror(axis, p, bwh), d)).collect::<Vec<_>>());
let holes = two_hole_fits(&la, &lb, bwh);
let same = |q: &[f64; 9]| geom::dist(geom::apply(q, (0.0, 0.0)), geom::apply(&fit.m, (0.0, 0.0))) < 8.0 * TOL_PX && geom::dist(geom::apply(q, bwh), geom::apply(&fit.m, bwh)) < 8.0 * TOL_PX;
if !holes.is_empty() && !holes.iter().any(same) { return None; }
let (s, turn) = fit.scale_turn();
((0.75..1.33).contains(&s) && turn.abs() < 6.0).then_some(fit)
}
/// The largest piece of a board mask with what it encloses.
fn solid(m: &raster::Mask) -> raster::Mask {
let (lab, cs) = raster::components(m);
let Some(big) = cs.iter().max_by_key(|c| c.area) else { return m.clone() };
raster::Mask { w: m.w, h: m.h, d: lab.iter().map(|l| *l == big.label).collect() }.fill_holes()
}
/// How well the two faces' boards coincide under a fit: intersection over union of the solid masks (both at 1/`k` of their photos), on every third pixel.
fn overlap(mt: &raster::Mask, mb: &raster::Mask, fit: &Fit, bwh: P, k: f64) -> f64 {
let Some(inv) = geom::invert(&fit.m) else { return 0.0 };
let (mut i, mut u) = (0usize, 0usize);
for y in (0..mt.h + mb.h).step_by(3) { for x in (0..mt.w + mb.w).step_by(3) {
let t = x < mt.w && y < mt.h && mt.d[y * mt.w + x];
let q = mirror(&fit.axis, geom::apply(&inv, ((x as f64 + 0.5) * k, (y as f64 + 0.5) * k)), bwh);
let (a, b) = ((q.0 / k).floor() as i64, (q.1 / k).floor() as i64);
let o = a >= 0 && b >= 0 && (a as usize) < mb.w && (b as usize) < mb.h && mb.d[b as usize * mb.w + a as usize];
if t && o { i += 1; } if t || o { u += 1; }
} }
if u == 0 { 0.0 } else { i as f64 / u as f64 }
}
/// The boards of the two faces must coincide at least this well for the outlines alone to register them. A top face with a quarter hidden under terminal blocks
/// and a connector over one edge coincided 0.57 with its bare bottom (2026-11-10). A sanity gate only: which edge to trust is decided by the mask beyond each edge.
pub const OUTLINE_IOU: f64 = 0.5;
/// `prep`, once both faces are measured: which way the board was turned over, and one outline for both faces.
/// The board is wherever either face shows solder mask: a face with parts over an edge loses that edge (six terminal blocks hid a quarter of one top face, 2026-11-10)
/// and the bare face shows it. So the two tight masks are laid together through the fit and the outline is fitted to their union. Returns the flip record for prep.json
/// and, when the faces register, the corners each photo should be straightened through (top, bottom) with a note of where the outline came from.
pub fn unify(doc: &Value, top_img: &RgbImage, bot_img: &RgbImage) -> (Option<Value>, Option<([P; 4], [P; 4], String)>, Value) {
let none = json!(null);
let (Some(top), Some(bot)) = (pick(doc, "top"), pick(doc, "bottom")) else { return (None, None, none) };
let (fx, rx) = register(&top, &bot, "x");
let (fy, ry) = register(&top, &bot, "y");
let n = |o: &Option<Fit>| o.as_ref().map_or(0, |x| x.matched);
let k = 4u32;
let (mt, _) = crate::outline::board_mask(top_img, k);
let (mb, _) = crate::outline::board_mask(bot_img, k);
let bwh = (bot_img.width() as f64, bot_img.height() as f64);
let ((win, wr), lose) = if (ry, n(&fy)) > (rx, n(&fx)) { ((fy, ry), fx) } else { ((fx, rx), fy) };
let (w, flip, how, tier) = match (win, wr) {
(Some(w), true) => {
let (s, turn) = w.scale_turn();
// the fit itself settles the flip when the other way does not register, or registers on clearly fewer
let clear = !(if w.axis == "y" { rx } else { ry }) || (w.matched >= n(&lose) + 4 && 2 * w.matched >= 3 * n(&lose));
let mut flip = json!({"axis": w.axis, "matches": w.matched, "other": n(&lose), "by": format!("through features matched between the faces ({} fit over {} top and {} bottom points)", w.kind, points(&top).0.len(), points(&bot).0.len()),
"residualPx": r3(w.rms), "bottomToTopScale": r3(s), "turnDeg": r3(turn)});
if clear { flip["how"] = json!(format!("through features matched between the faces ({} this way, {} the other)", w.matched, n(&lose))); }
let how = format!("the solder mask of both faces laid together through {} through features", w.matched);
let t = json!({"tier": "through features", "axis": w.axis, "m": w.m, "kind": w.kind, "matched": w.matched, "rms": w.rms, "max": w.max, "pairs": w.pairs.iter().map(|(a, b)| json!([a.0, a.1, b.0, b.1])).collect::<Vec<_>>()});
(w, Some(flip), how, t)
}
_ => {
// nothing through the board is shared that the pads test finds: the outlines themselves, where the boards then coincide
let (st, sb) = (solid(&mt), solid(&mb));
let mut best: Option<(f64, Fit)> = None;
for axis in ["x", "y"] { if let Some(fit) = outline_fit(&top, &bot, axis) {
let v = overlap(&st, &sb, &fit, bwh, k as f64);
if best.as_ref().map_or(true, |q| v > q.0) { best = Some((v, fit)); }
} }
match best { Some((v, fit)) if v >= OUTLINE_IOU => { let how = format!("the solder mask of both faces laid together through their outlines ({}; the boards coincide {:.2})", fit.kind, v); (fit, None, how, json!({"tier": "outline", "overlap": r3(v)})) }
_ => return (None, None, none) }
}
};
// the union of the two masks in the top photo, at a quarter of its size
let Some(inv) = geom::invert(&w.m) else { return (flip, None, none) };
let mut un = mt.clone();
for y in 0..un.h { for x in 0..un.w { if un.d[y * un.w + x] { continue; }
let q = mirror(&w.axis, geom::apply(&inv, ((x as f64 + 0.5) * k as f64, (y as f64 + 0.5) * k as f64)), bwh);
let (u, v) = ((q.0 / k as f64).floor() as i64, (q.1 / k as f64).floor() as i64);
if u >= 0 && v >= 0 && (u as usize) < mb.w && (v as usize) < mb.h && mb.d[v as usize * mb.w + u as usize] { un.d[y * un.w + x] = true; }
} }
let Ok(tc) = crate::prep::corners_of_mask(&un, k) else { return (flip, None, none) };
let Some(bc) = to_bottom(&tc, &w, bwh) else { return (flip, None, none) };
(flip, Some((tc, bc, how)), tier)
}
/// `scale`: lay the bottom photo on the top's frame for the flip `axis`. On a registration the bottom photo's corners in prep.json become the places in that photo
/// where the top's corners lie, so every later step that warps the bottom through its corners gets the registered picture; otherwise they are left as they are.
/// Returns the report for scale.json: `registered`, the matches each way, the residual before (each face straightened through the outline fitted to itself, the outlines
/// taken to coincide) and after, and the outlines compared.
pub fn lay(prep_doc: &mut Value, axis: &str, px_per_mm: f64) -> Value {
let (Some(top), Some(bot)) = (pick(prep_doc, "top"), pick(prep_doc, "bottom")) else { return json!({"registered": false, "why": "one face only"}) };
let other_axis = if axis == "y" { "x" } else { "y" };
let (mut fit, mut ok) = register(&top, &bot, axis);
// the fit `prep` made the shared outline with stands for the same flip: measuring the photos again through that outline must not change the answer
let kept = &prep_doc["faceFit"];
if kept["axis"].as_str() == Some(axis) { if let Some(a) = kept["m"].as_array().filter(|a| a.len() == 9) {
let mut m = [0.0; 9]; for i in 0..9 { m[i] = a[i].as_f64().unwrap_or(0.0); }
let kind: &'static str = match kept["kind"].as_str().unwrap_or("") { "similarity" => "similarity", "affine" => "affine", "homography" => "homography", k if k.contains("large openings") => "similarity through large openings of one size", _ => "the fit made by prep" };
let pairs: Vec<(P, P)> = kept["pairs"].as_array().into_iter().flatten().map(|q| ((q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0)), (q[2].as_f64().unwrap_or(0.0), q[3].as_f64().unwrap_or(0.0)))).collect();
fit = Some(Fit { axis: axis.to_string(), matched: kept["matched"].as_u64().unwrap_or(0) as usize, rms: kept["rms"].as_f64().unwrap_or(f64::NAN), max: kept["max"].as_f64().unwrap_or(f64::NAN), m, kind, pairs }); ok = true;
} }
let other = register(&top, &bot, other_axis).0.map_or(0, |x| x.matched);
let mut weak = Value::Null;
if ok && fit.as_ref().map_or(false, |x| x.matched <= 4 && x.kind.contains("large openings")) { weak = json!(format!("registered through {} mounting holes only ({}): nothing else that goes through the board was found on both faces, so these few points carry the whole fit and nothing checks it between them; with two, the faces can be off by a millimetre at the far side of the board", fit.as_ref().map_or(0, |x| x.matched), fit.as_ref().map_or("", |x| x.kind))); }
if !ok { if let Some(o) = outline_fit(&top, &bot, axis) {
fit = Some(o); ok = true;
weak = json!("registered through the outlines only: no through feature that both faces show confirms it, and an outline cannot say which way the board was turned over. The faces can be off by what an outline reads to (about 0.3 mm) until vias pair them");
} }
let k = px_per_mm.max(1e-9);
let mut rep = json!({"registered": false, "axis": axis, "points": {"top": points(&top).0.len(), "bottom": points(&bot).0.len()}, "matched": fit.as_ref().map_or(0, |x| x.matched), "otherAxisMatched": other, "tolerance_mm": r3(TOL_PX / k)});
let (Some(x), true, Some(tc), Some(tf), Some(bf)) = (fit.as_ref(), ok, corners_of(&top, "corners"), own_corners(&top), own_corners(&bot)) else {
rep["why"] = json!(format!("too few through features agree between the faces ({} this way, {} the other; {} needed, or {} large openings): the bottom rests on its own outline, which a shadow, the table or parts over an edge can move by millimetres", fit.as_ref().map_or(0, |x| x.matched), other, MIN_MATCH, MIN_BIG));
return rep;
};
let bwh = photo_wh(&bot);
let Some(bc) = to_bottom(&tc, x, bwh) else { rep["why"] = json!("the fit could not be inverted"); return rep; };
// before: the same pairs when each face is straightened through its own fitted outline and the two are taken to coincide
let unit = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
let pm = perm(axis); let mut tdst = [(0.0, 0.0); 4]; for i in 0..4 { tdst[i] = tf[pm[i]]; }
let before: Vec<f64> = match geom::homography(&bf, &unit).and_then(|a| geom::homography(&unit, &tdst).map(|b| (a, b))) {
Some((a, b)) => x.pairs.iter().map(|(t, o)| geom::dist(*t, geom::apply(&b, geom::apply(&a, mirror(axis, *o, bwh)))) / k).collect(),
None => vec![] };
let rms = |v: &[f64]| (v.iter().map(|q| q * q).sum::<f64>() / v.len().max(1) as f64).sqrt();
// the outlines: each face's own, and the one both now use, as sizes in the top photo's scale; and how far each face's own corners lie from the shared ones
let bo = to_top(&bf, x, bwh);
let off = |c: &[P; 4]| (0..4).map(|i| geom::dist(c[i], tc[i]) / k).fold(0.0, f64::max);
let (st, sb, su) = (quad_size(&tf), quad_size(&bo), quad_size(&tc));
let (ot, ob) = (off(&tf), off(&bo));
let agree = ot <= OUTLINE_TOL_MM && ob <= OUTLINE_TOL_MM;
let trusted = if agree { format!("each face's own outline lies within {OUTLINE_TOL_MM} mm of the shared one at every corner") }
else { format!("the shared outline, fitted to the solder mask of both faces laid together: board is wherever either face shows mask, and parts over an edge hide it on one face only. The {} face's own outline is the nearer ({:.2} mm at its worst corner against {:.2} mm)", if ot <= ob { "top" } else { "bottom" }, ot.min(ob), ot.max(ob)) };
rep["outline"] = json!({"shared_mm": [r3(su.0 / k), r3(su.1 / k)], "topOwn_mm": [r3(st.0 / k), r3(st.1 / k)], "bottomOwn_mm": [r3(sb.0 / k), r3(sb.1 / k)], "topOwnCornerOff_mm": r3(ot), "bottomOwnCornerOff_mm": r3(ob),
"tolerance_mm": OUTLINE_TOL_MM, "agree": agree, "trusted": trusted, "notBoardEdge": {"top": top["notBoardEdge"], "bottom": bot["notBoardEdge"]}});
for ph in prep_doc["photos"].as_array_mut().into_iter().flatten() { if ph["file"] == bot["file"] {
if ph["cornersFitted"].is_null() { ph["cornersFitted"] = ph["corners"].clone(); }
ph["corners"] = json!(bc.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>());
} }
let (s, turn) = x.scale_turn();
rep["registered"] = json!(true);
if !weak.is_null() { rep["weak"] = weak; }
rep["fit"] = json!(x.kind);
rep["residual_mm"] = json!(r3(x.rms / k)); rep["residualMax_mm"] = json!(r3(x.max / k));
rep["ownOutlinesAssumedEqual_mm"] = json!({"rms": r3(rms(&before)), "max": r3(before.iter().cloned().fold(0.0, f64::max))});
rep["bottomToTopScale"] = json!(r3(s)); rep["turnDeg"] = json!(r3(turn));
rep
}
/// One line for the hints of `scale`.
pub fn say(rep: &Value) -> String {
let cut: Vec<String> = ["top", "bottom"].iter().filter_map(|s| { let v: Vec<&str> = rep["outline"]["notBoardEdge"][*s].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect(); (!v.is_empty()).then(|| format!("{s} photo: {}", v.join(", "))) }).collect();
let cut = if cut.is_empty() { String::new() } else { format!(". NOT the board's edge, the photo stops there ({}): the frame is the photographed window on those sides", cut.join("; ")) };
say_fit(rep) + &cut
}
fn say_fit(rep: &Value) -> String {
let way = |a: &str| if a == "y" { "top-to-bottom" } else { "left-to-right" };
if rep["registered"] == true && rep["weak"].is_string() {
format!("WARNING, faces {} Outline {} x {} mm (top's own {} x {}, bottom's own {} x {}); trusted: {}", rep["weak"].as_str().unwrap_or(""),
rep["outline"]["shared_mm"][0], rep["outline"]["shared_mm"][1], rep["outline"]["topOwn_mm"][0], rep["outline"]["topOwn_mm"][1], rep["outline"]["bottomOwn_mm"][0], rep["outline"]["bottomOwn_mm"][1], rep["outline"]["trusted"].as_str().unwrap_or(""))
} else if rep["registered"] == true {
format!("faces registered through {} through features (turned over {}; {} the other way; {}): residual {} mm RMS, {} mm worst; with each face on its own outline the same pairs were {} mm RMS, {} mm worst apart. Outline {} x {} mm (top's own {} x {}, bottom's own {} x {}); trusted: {}",
rep["matched"], way(rep["axis"].as_str().unwrap_or("x")), rep["otherAxisMatched"], rep["fit"].as_str().unwrap_or(""), rep["residual_mm"], rep["residualMax_mm"], rep["ownOutlinesAssumedEqual_mm"]["rms"], rep["ownOutlinesAssumedEqual_mm"]["max"],
rep["outline"]["shared_mm"][0], rep["outline"]["shared_mm"][1], rep["outline"]["topOwn_mm"][0], rep["outline"]["topOwn_mm"][1], rep["outline"]["bottomOwn_mm"][0], rep["outline"]["bottomOwn_mm"][1], rep["outline"]["trusted"].as_str().unwrap_or(""))
} else { format!("WARNING, the faces are NOT registered: {}", rep["why"].as_str().unwrap_or("no fit")) }
}
/// For `check prep` once scale.json exists: a sentence when nothing holds the bottom face to the top, None when holes or vias do.
/// Vias count when the relay paired 40 or more and holds 0.15 mm on pairs left out of its fit.
pub fn unregistered(scale: &Value) -> Option<String> {
let al = &scale["alignment"];
let faces = &al["faces"];
if !faces.is_object() || faces["registered"] == true || faces["why"] == "one face only" { return None; }
let reg = &al["registration"];
if reg["pairs"].as_u64().unwrap_or(0) >= 40 && reg["leaveOneOutRmsMm"].as_f64().map_or(false, |x| x <= 0.15) { return None; }
Some(format!("the bottom face is not registered to the top: {} through features match one way and {} the other ({} needed), {} hole pairs after the outline fit (residual {} mm), and no via relay holds them",
faces["matched"], faces["otherAxisMatched"], MIN_MATCH, al["pairs"], al["residual_mm"]))
}
#[cfg(test)]
mod tests {
use super::*;
fn scatter(n: usize, seed0: u64, w: f64, h: f64) -> Vec<P> {
let mut seed = seed0; let mut next = move || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); (seed >> 33) as f64 };
(0..n).map(|_| (50.0 + next() % (w - 100.0), 50.0 + next() % (h - 100.0))).collect()
}
/// The bottom photo's points for a board turned over about `axis`: 3 percent larger, a little turned and shifted, with strays on both faces.
fn faces(axis: &str) -> (Vec<P>, Vec<P>, P, [f64; 9]) {
let (w, h) = (6000.0, 4000.0);
let through = scatter(40, 7, w, h);
let mut top = through.clone(); top.extend(scatter(60, 99, w, h));
// truth: top = m * mirrored bottom
let m = to9(&[0.97, 0.004, -35.0, -0.004, 0.97, 61.0]);
let inv = geom::invert(&m).unwrap();
let bwh = (6200.0, 4300.0);
let mut bot: Vec<P> = through.iter().map(|p| mirror(axis, geom::apply(&inv, *p), bwh)).collect();
bot.extend(scatter(70, 1234, bwh.0, bwh.1));
(top, bot, bwh, m)
}
fn rec(p: &[P], file: &str, wh: P, corners: [P; 4]) -> Value {
json!({"file": file, "photoPx": [wh.0, wh.1], "corners": corners.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>(), "through": p.iter().map(|q| json!([q.0, q.1, 60.0])).collect::<Vec<_>>()})
}
#[test]
fn the_flip_and_the_map_come_from_the_points_not_the_outline() {
for axis in ["x", "y"] {
let (top, bot, bwh, m) = faces(axis);
let right = fit_axis(&top, &bot, bwh, axis, TOL_PX).unwrap();
let wrong = fit_axis(&top, &bot, bwh, if axis == "x" { "y" } else { "x" }, TOL_PX).map_or(0, |x| x.matched);
assert!(right.matched >= 38 && wrong * 4 < right.matched, "{axis}: {} right, {wrong} wrong", right.matched);
assert!(right.rms < 1.0, "{}", right.rms);
for p in [(0.0, 0.0), (6200.0, 4300.0)] { assert!(geom::dist(geom::apply(&right.m, p), geom::apply(&m, p)) < 2.0, "{:?} vs {:?}", right.m, m); }
}
}
#[test]
fn a_tilted_photo_is_taken_up_by_the_homography() {
// the bottom photographed at a tilt: 2 percent of perspective across the board, which no similarity fits within the tolerance
let top = scatter(60, 21, 6000.0, 4000.0);
let tilt = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 4e-6, 2e-6, 1.0];
let bot: Vec<P> = top.iter().map(|p| geom::apply(&tilt, *p)).collect();
let fit = consensus(&top, &bot, TOL_PX).unwrap();
assert_eq!(fit.kind, "homography");
assert!(fit.matched >= 58 && fit.rms < 1.0, "{} matched, rms {}", fit.matched, fit.rms);
}
#[test]
fn two_faces_that_share_nothing_do_not_register() {
let (top, bot) = (scatter(90, 5, 6000.0, 4000.0), scatter(90, 6, 6000.0, 4000.0));
let c = [(0.0, 0.0), (6000.0, 0.0), (6000.0, 4000.0), (0.0, 4000.0)];
let mut doc = json!({"photos": [rec(&top, "top.jpg", (6000.0, 4000.0), c), rec(&bot, "bottom.jpg", (6000.0, 4000.0), c)], "primary": {"top": "top.jpg", "bottom": "bottom.jpg"}});
let rep = lay(&mut doc, "x", 60.0);
assert_eq!(rep["registered"], false, "{rep}");
assert!(say(&rep).starts_with("WARNING"));
assert!(doc["photos"][1]["cornersFitted"].is_null());
let scale = json!({"alignment": {"pairs": 1, "residual_mm": 0.0, "faces": rep}});
assert!(unregistered(&scale).is_some());
}
#[test]
fn a_registered_bottom_gets_the_tops_outline_as_its_corners() {
let (top, bot, bwh, m) = faces("y");
let tc = [(100.0, 100.0), (5900.0, 100.0), (5900.0, 3900.0), (100.0, 3900.0)];
// the bottom's own outline is 200 px too tall
let bc = [(0.0, 0.0), (bwh.0, 0.0), (bwh.0, bwh.1), (0.0, bwh.1)];
let mut doc = json!({"photos": [rec(&top, "top.jpg", (6000.0, 4000.0), tc), rec(&bot, "bottom.jpg", bwh, bc)], "primary": {"top": "top.jpg", "bottom": "bottom.jpg"}});
let rep = lay(&mut doc, "y", 60.0);
assert_eq!(rep["registered"], true, "{rep}");
assert_eq!(rep["outline"]["agree"], false, "{rep}");
// turned over top-to-bottom, the bottom photo's first corner is the top's lower left
let c = corners_of(&doc["photos"][1], "corners").unwrap();
let want = mirror("y", geom::apply(&geom::invert(&m).unwrap(), tc[3]), bwh);
assert!(geom::dist(c[0], want) < 3.0, "{c:?} vs {want:?}");
assert!(doc["photos"][1]["cornersFitted"].is_array());
// run again: the answer does not drift
let again = lay(&mut doc, "y", 60.0);
assert_eq!(again["matched"], rep["matched"]);
assert!(geom::dist(corners_of(&doc["photos"][1], "corners").unwrap()[0], c[0]) < 1e-6);
assert!(unregistered(&json!({"alignment": {"faces": rep}})).is_none());
}
#[test]
fn four_mounting_holes_register_a_board_that_shares_nothing_else() {
// four large openings at the corners, small strays on both faces that share nothing
let holes = [(300.0, 300.0), (5700.0, 320.0), (5690.0, 3700.0), (310.0, 3690.0)];
let m = to9(&[1.02, 0.0, 40.0, 0.0, 1.02, -25.0]);
let inv = geom::invert(&m).unwrap();
let bwh = (6000.0, 4000.0);
let mk = |big: Vec<P>, small: Vec<P>, file: &str| { let mut v = rec(&small, file, bwh, [(0.0, 0.0), (6000.0, 0.0), (6000.0, 4000.0), (0.0, 4000.0)]); for q in big { v["through"].as_array_mut().unwrap().push(json!([q.0, q.1, 220.0])); } v };
let top = mk(holes.to_vec(), scatter(30, 3, 6000.0, 4000.0), "top.jpg");
let bot = mk(holes.iter().map(|p| mirror("x", geom::apply(&inv, *p), bwh)).collect(), scatter(30, 4, 6000.0, 4000.0), "bottom.jpg");
let (fit, ok) = register(&top, &bot, "x");
assert!(ok && fit.unwrap().matched >= 4);
}
}