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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
//! Photo preparation: the board outline in each photo (robust to connectors hanging over an edge), the
//! homography that straightens it, the straightened image (bottom photos mirrored into the top view, the
//! KiCad board frame), a sharpness score, and the plated holes.
use crate::geom::{self, P};
use crate::raster::{self, Mask, Plane};
use image::{imageops, Rgb, RgbImage};
use serde_json::{json, Value};
/// The solder mask as one photo shows it, measured on that photo. Exposure and mask colour differ by a factor of three between
/// boards (value median 0.23 to 0.31 on the Driver photos, 0.61 to 0.73 on the CM5 photos, same hue near 165), so nothing that
/// depends on them can be a constant.
#[derive(Clone, Copy, Debug)]
pub struct MaskTone {
/// the peak of the hue histogram in the green family, degrees
pub hue: f32,
/// median value of the pixels around that hue
pub v50: f32,
/// the brightest value still mask: 1.1 x the 98th percentile of those pixels. It never falls under 0.62, where bright mint plastic
/// such as a terminal block begins on the dark masks measured so far, because plastic can hang over a board's edge.
pub v_hi: f32,
/// median luma and median G - R of the pixels around that hue, 8 bit
pub y50: f32, pub gr50: f32,
}
impl MaskTone {
/// How many times brighter than the darkest exposure the luma thresholds of `photomat` were read on (Driver photos, mask luma 44 and 64). 1 on those photos.
/// Within a quarter of that it is the same exposure: the Driver faces differ by more than that between themselves.
pub fn exposure(&self) -> f32 { let k = self.y50 / MASK_Y_READ_AT; if k < 1.25 { 1.0 } else { k } }
/// The same for how green the mask is: G - R over what the Driver photos read.
pub fn chroma(&self) -> f32 { let k = self.gr50 / MASK_GR_READ_AT; if k < 1.25 { 1.0 } else { k } }
/// The same for value: 0.31 is the brighter Driver face's median, and within a quarter of it nothing moves.
pub fn value_scale(&self) -> f32 { let k = self.v50 / 0.31; if k < 1.25 { 1.0 } else { k } }
/// A mask photographed well above the range the Driver's thresholds were read on.
pub fn bright(&self) -> bool { self.exposure() > 1.0 }
}
/// Median G - R of the mask on the Driver photos, 8 bit, the larger of the two faces (`MaskTone::gr50` on board/top.jpg and board/bottom.jpg, 2026-11-06).
pub const MASK_GR_READ_AT: f32 = 45.0;
/// The brighter of the two Driver faces the `photomat` thresholds were read on: median mask luma 64 (2026-11-06, `MaskTone::y50` on board/bottom.jpg).
pub const MASK_Y_READ_AT: f32 = 64.0;
/// Measure the mask in a photo. The green family is a wide bracket, 65 to 200 degrees, that only keeps a brown or grey table out of the histogram.
pub fn mask_tone(img: &RgbImage) -> MaskTone {
let mut hist = [0u32; 27];
let step = ((img.width() as usize * img.height() as usize) / 400_000).max(1);
let hsvs: Vec<(f32, f32, f32, &Rgb<u8>)> = img.pixels().step_by(step).map(|p| { let (h, s, v) = raster::hsv(p); (h, s, v, p) }).collect();
for (h, s, v, _) in &hsvs { if (65.0..200.0).contains(h) && *s > 0.18 && *v > 0.07 { hist[((h - 65.0) / 5.0) as usize] += 1; } }
let peak = hist.iter().enumerate().max_by_key(|x| *x.1).map(|x| x.0).unwrap_or(20);
let hue = 65.0 + 5.0 * peak as f32 + 2.5;
let (mut vs, mut ys, mut grs) = (vec![], vec![], vec![]);
for (h, s, v, p) in &hsvs { if (h - hue).abs() < 25.0 && *s > 0.18 && *v > 0.07 { vs.push(*v); ys.push(0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32); grs.push(p[1] as f32 - p[0] as f32); } }
let at = |v: &mut Vec<f32>, q: f64| -> f32 { if v.is_empty() { return 0.0; } v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[((v.len() - 1) as f64 * q) as usize] };
let v50 = at(&mut vs, 0.5);
let v_hi = (at(&mut vs, 0.98) * 1.1).clamp(0.62, 1.001);
MaskTone { hue, v50, v_hi, y50: at(&mut ys, 0.5), gr50: at(&mut grs, 0.5) }
}
/// Solder-mask green in this photo, as opposed to the surface the board lies on.
/// (Bright mint-green parts such as terminal blocks are excluded by the measured value limit: they can hang over an edge.)
pub fn is_mask_green(p: &Rgb<u8>, t: &MaskTone) -> bool { let (h, s, v) = raster::hsv(p); (65.0..200.0).contains(&h) && s > 0.18 && v > 0.07 && v < t.v_hi }
/// The four outer corners (TL, TR, BR, BL) of the board in photo pixels, from straight-line fits to the
/// four edges. Corner radii and anything hanging over an edge are left out of the fits.
pub fn board_corners(img: &RgbImage) -> Result<[P; 4], String> {
let k = 4u32;
let small = imageops::resize(img, img.width() / k, img.height() / k, imageops::FilterType::Triangle);
let tone = mask_tone(img);
corners_of_mask(&raster::mask_from(&small, |p| is_mask_green(p, &tone)).close(3.0), k)
}
/// The same fit on a board mask already made, at 1/`k` of the photo's size (`outline` makes a tighter mask and fits it here).
pub fn corners_of_mask(m: &Mask, k: u32) -> Result<[P; 4], String> {
let (lab, cs) = raster::components(m);
let big = cs.iter().max_by_key(|c| c.area).ok_or("no green board found in the photo")?;
if big.area < (m.w * m.h) / 10 { return Err("the green region is under 10 percent of the photo: is the board in frame?".into()); }
let mut b = Mask::new(m.w, m.h);
for i in 0..lab.len() { if lab[i] == big.label { b.d[i] = true; } }
let b = b.fill_holes();
let (x0, x1, y0, y1) = (big.x0 as f64, big.x1 as f64, big.y0 as f64, big.y1 as f64);
let (bw, bh) = (x1 - x0, y1 - y0);
let (mut left, mut right, mut top, mut bot) = (vec![], vec![], vec![], vec![]);
for y in (y0 + 0.15 * bh) as usize..(y1 - 0.15 * bh) as usize {
if let Some(x) = (0..b.w).find(|x| b.at(*x, y)) { left.push((y as f64, x as f64)); }
if let Some(x) = (0..b.w).rev().find(|x| b.at(*x, y)) { right.push((y as f64, x as f64 + 1.0)); }
}
for x in (x0 + 0.15 * bw) as usize..(x1 - 0.15 * bw) as usize {
if let Some(y) = (0..b.h).find(|y| b.at(x, *y)) { top.push((x as f64, y as f64)); }
if let Some(y) = (0..b.h).rev().find(|y| b.at(x, *y)) { bot.push((x as f64, y as f64 + 1.0)); }
}
let f = |v: &[(f64, f64)], n: &str| geom::robust_line(v, 1.5).ok_or(format!("could not fit the {n} edge"));
let (l, r, t, bo) = (f(&left, "left")?, f(&right, "right")?, f(&top, "top")?, f(&bot, "bottom")?);
let s = k as f64;
let c = [geom::cross_vh(l, t), geom::cross_vh(r, t), geom::cross_vh(r, bo), geom::cross_vh(l, bo)];
Ok(c.map(|p| (p.0 * s, p.1 * s)))
}
/// Straighten the board: output pixel (u, v) samples the photo through the inverse homography.
pub fn rectify(img: &RgbImage, corners: &[P; 4], w: u32, h: u32, mirror: bool) -> Result<(RgbImage, [f64; 9]), String> {
let dst = [(0.0, 0.0), (w as f64, 0.0), (w as f64, h as f64), (0.0, h as f64)];
let hm = geom::homography(corners, &dst).ok_or("degenerate board corners")?;
let inv = geom::invert(&hm).ok_or("homography not invertible")?;
let planes: Vec<Plane> = (0..3).map(|c| Plane { w: img.width() as usize, h: img.height() as usize, d: img.pixels().map(|p| p[c] as f32).collect() }).collect();
let out = RgbImage::from_fn(w, h, |u, v| {
let uu = if mirror { w - 1 - u } else { u };
let q = geom::apply(&inv, (uu as f64 + 0.5, v as f64 + 0.5));
Rgb([0, 1, 2].map(|c| planes[c].sample(q.0 as f32 - 0.5, q.1 as f32 - 0.5).clamp(0.0, 255.0) as u8))
});
Ok((out, hm))
}
/// Variance of the Laplacian of the luminance: higher is sharper. Compared between photos of one side.
pub fn sharpness(img: &RgbImage) -> f64 {
let l = raster::luma(img);
let (mut s, mut s2, mut n) = (0f64, 0f64, 0f64);
for y in 1..l.h - 1 { for x in 1..l.w - 1 {
let v = (4.0 * l.at(x, y) - l.at(x - 1, y) - l.at(x + 1, y) - l.at(x, y - 1) - l.at(x, y + 1)) as f64;
s += v; s2 += v * v; n += 1.0;
} }
s2 / n - (s / n).powi(2)
}
pub fn is_gold(p: &Rgb<u8>) -> bool { let (h, s, v) = raster::hsv(p); (25.0..72.0).contains(&h) && s > 0.2 && v > 0.38 }
pub fn is_metal(p: &Rgb<u8>) -> bool {
let (_, s, v) = raster::hsv(p);
is_gold(p) || (s < 0.2 && v > 0.62)
}
/// Plated holes in a straightened image: metal annuli (gold or solder) enclosing a round opening.
/// Returns (centre, hole diameter, outer size, square?) in that image's pixels.
pub fn holes(img: &RgbImage, min_d: f64, max_d: f64) -> Vec<Value> { holes_of_mask(&raster::mask_from(img, is_metal).close(1.5), min_d, max_d) }
/// The same with gold only. White print reads as metal above, so a silkscreen line that runs into a ring joins them and the ring is lost, as one mounting hole on the CM5 top is;
/// and a logo's counter reads as a hole. A mounting hole's ring is gold, so this list is what framing through mounting holes reads.
pub fn gold_holes(img: &RgbImage, min_d: f64, max_d: f64) -> Vec<Value> { holes_of_mask(&raster::mask_from(img, is_gold).close(1.5), min_d, max_d) }
fn holes_of_mask(m: &Mask, min_d: f64, max_d: f64) -> Vec<Value> {
let (lab, 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 * 3.5 || bh > max_d * 3.5 || bw / bh > 1.6 || bh / bw > 1.6 { continue; }
// the opening: background pixels inside the component's box that the component encloses
let (w, h) = (c.x1 - c.x0 + 3, c.y1 - c.y0 + 3);
let mut sub = Mask::new(w, h);
for y in c.y0..=c.y1 { for x in c.x0..=c.x1 { if lab[y * m.w + x] == c.label { sub.set(x - c.x0 + 1, y - c.y0 + 1, true); } } }
let inner = sub.fill_holes().andnot(&sub);
let (_, ic) = raster::components(&inner);
let Some(o) = ic.iter().max_by_key(|q| q.area) else { continue };
let (ow, oh) = ((o.x1 - o.x0 + 1) as f64, (o.y1 - o.y0 + 1) as f64);
let d = (ow + oh) / 2.0;
let roundness = o.area as f64 / (std::f64::consts::PI * (d / 2.0).powi(2));
if d < min_d || d > max_d || ow / oh > 1.35 || oh / ow > 1.35 || roundness < 0.6 { continue; }
// the ring around the opening must be substantial: a pad, not a letter's counter
let ring = c.area as f64 / (o.area as f64).max(1.0);
if ring < 0.8 { continue; }
let fill = c.area as f64 / (bw * bh);
let square = fill > 0.72 * (1.0 - o.area as f64 / (bw * bh)) && (bw - bh).abs() / bw < 0.15 && fill + o.area as f64 / (bw * bh) > 0.88;
out.push(json!({"x": c.x0 as f64 - 1.0 + o.cx + 0.5, "y": c.y0 as f64 - 1.0 + o.cy + 0.5, "hole": d, "outer": (bw + bh) / 2.0, "square": square}));
}
out.sort_by(|a, b| (a["y"].as_f64().unwrap(), a["x"].as_f64().unwrap()).partial_cmp(&(b["y"].as_f64().unwrap(), b["x"].as_f64().unwrap())).unwrap());
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_a_tilted_board() {
// brown background, a green quad, a yellow block hanging over the left edge
let mut img = RgbImage::from_pixel(800, 600, Rgb([110, 60, 30]));
let quad = [(100.0, 80.0), (700.0, 100.0), (690.0, 520.0), (90.0, 500.0)];
let h = geom::homography(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], &quad).unwrap();
let hi = geom::invert(&h).unwrap();
for y in 0..600 { for x in 0..800 {
let q = geom::apply(&hi, (x as f64 + 0.5, y as f64 + 0.5));
if (0.0..1.0).contains(&q.0) && (0.0..1.0).contains(&q.1) { img.put_pixel(x, y, Rgb([20, 90, 60])); }
} }
for y in 250..330 { for x in 60..160 { img.put_pixel(x, y, Rgb([230, 190, 40])); } }
let c = board_corners(&img).unwrap();
for i in 0..4 { assert!(geom::dist(c[i], quad[i]) < 6.0, "{i}: {:?} vs {:?}", c[i], quad[i]); }
}
#[test]
fn a_bright_mask_is_measured_not_refused() {
// the same picture with the mask the CM5 photos show: value 0.73, where a fixed limit of 0.62 refused every photo
let mut img = RgbImage::from_pixel(800, 600, Rgb([90, 58, 40]));
for y in 80..520 { for x in 100..700 { img.put_pixel(x, y, Rgb([50, 186, 140])); } }
let t = mask_tone(&img);
assert!((t.v50 - 0.73).abs() < 0.01 && t.v_hi > 0.75, "{t:?}");
let c = board_corners(&img).unwrap();
assert!(geom::dist(c[0], (100.0, 80.0)) < 6.0 && geom::dist(c[2], (700.0, 520.0)) < 6.0, "{c:?}");
assert_eq!(material(&Rgb([50, 186, 140]), &t), Mat::Mask);
}
#[test]
fn a_dark_mask_keeps_bright_plastic_out() {
let mut img = RgbImage::from_pixel(800, 600, Rgb([110, 60, 30]));
for y in 80..520 { for x in 100..700 { img.put_pixel(x, y, Rgb([20, 70, 50])); } }
for y in 200..220 { for x in 300..340 { img.put_pixel(x, y, Rgb([120, 230, 170])); } }
let t = mask_tone(&img);
assert!((t.v_hi - 0.62).abs() < 1e-3, "{t:?}");
assert_eq!(material(&Rgb([120, 230, 170]), &t), Mat::BrightGreen);
}
#[test]
fn a_ring_centre_ignores_what_touches_the_ring() {
// a gold ring round a dark opening; a gold blob touches it on one side and a wall shows in one side of the opening
let (cx, cy) = (200.0f64, 200.0f64);
let mut img = RgbImage::from_pixel(400, 400, Rgb([50, 186, 140]));
for y in 0..400u32 { for x in 0..400u32 { let d = ((x as f64 - cx).powi(2) + (y as f64 - cy).powi(2)).sqrt();
if d < 30.0 { img.put_pixel(x, y, if (x as f64) < cx - 15.0 { Rgb([180, 170, 120]) } else { Rgb([60, 40, 30]) }); }
else if d < 70.0 { img.put_pixel(x, y, Rgb([230, 210, 60])); }
else if x > 260 && x < 300 && (y as f64 - cy).abs() < 20.0 { img.put_pixel(x, y, Rgb([230, 210, 60])); } } }
let (q, _) = ring_centre(&img, (cx + 4.0, cy - 3.0), 30.0, 70.0);
assert!(geom::dist(q, (cx, cy)) < 1.2, "{q:?}");
}
}
/// The centre of a mounting hole's metal ring in photo pixels, from the ring's outer edge. A photo from an angle shows the far wall in the opening, so the opening's own
/// centre moves towards the camera's side; the ring lies flat on the board. Walks out from `from` in 72 directions to where metal ends, and fits a circle, dropping what touches the ring.
/// Returns the centre and its RMS fit error in pixels. Falls back on `from` when too little of the edge is seen.
pub fn ring_centre(img: &RgbImage, from: P, hole_r: f64, ring_r: f64) -> (P, f64) { ring_centre_by(img, from, hole_r, ring_r, is_metal) }
/// A mounting ring's own colour, tighter than `is_gold`: redder than green and well above blue, and lit. A ring fit needs this because solder and pale parts that touch a ring pass `is_gold` and pull the fit off centre. Green mask has green above red, so it never passes.
pub fn is_ring_gold(p: &Rgb<u8>) -> bool { let (r, g, b) = (p[0] as f32, p[1] as f32, p[2] as f32); r > 1.1 * g && g > 1.3 * b && r > 70.0 }
pub fn ring_centre_by(img: &RgbImage, from: P, hole_r: f64, ring_r: f64, pred: fn(&Rgb<u8>) -> bool) -> (P, f64) {
let (w, h) = (img.width() as i64, img.height() as i64);
let metal_at = |x: f64, y: f64| -> Option<bool> { let (u, v) = (x as i64, y as i64); if u < 0 || v < 0 || u >= w || v >= h { None } else { Some(pred(img.get_pixel(u as u32, v as u32))) } };
let mut pts: Vec<P> = vec![];
for k in 0..72 {
let a = k as f64 / 72.0 * std::f64::consts::TAU; let (co, si) = (a.cos(), a.sin());
let mut r = hole_r * 1.2; let mut last = None;
while r < ring_r * 1.4 { match metal_at(from.0 + co * r, from.1 + si * r) { Some(true) => { last = Some(r); } Some(false) => { if let Some(l) = last { if r - l > 3.0 { break; } } } None => break } r += 1.0; }
if let Some(l) = last { pts.push((from.0 + co * l, from.1 + si * l)); }
}
let circle = |p: &[P]| -> Option<(f64, f64, f64)> {
if p.len() < 8 { return None; }
let n = p.len() as f64; let (mx, my) = (p.iter().map(|q| q.0).sum::<f64>() / n, p.iter().map(|q| q.1).sum::<f64>() / n);
let (mut suu, mut svv, mut suv, mut suuu, mut svvv, mut suvv, mut svuu) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
for q in p { let (u, v) = (q.0 - mx, q.1 - my); suu += u * u; svv += v * v; suv += u * v; suuu += u * u * u; svvv += v * v * v; suvv += u * v * v; svuu += v * u * u; }
let det = suu * svv - suv * suv; if det.abs() < 1e-6 { return None; }
let (a, b) = (0.5 * (suuu + suvv), 0.5 * (svvv + svuu));
let (uc, vc) = ((a * svv - b * suv) / det, (b * suu - a * suv) / det);
Some((uc + mx, vc + my, (uc * uc + vc * vc + (suu + svv) / n).sqrt()))
};
let mut kept = pts.clone(); let mut fit = None;
for _ in 0..5 {
let Some(c) = circle(&kept) else { break };
fit = Some(c);
let mut res: Vec<f64> = pts.iter().map(|q| (geom::dist(*q, (c.0, c.1)) - c.2).abs()).collect(); res.sort_by(|a, b| a.partial_cmp(b).unwrap());
let lim = (res[res.len() / 2] * 2.5).max(1.0);
kept = pts.iter().copied().filter(|q| (geom::dist(*q, (c.0, c.1)) - c.2).abs() <= lim).collect();
}
match fit { Some(c) if kept.len() >= 24 => { let e = (kept.iter().map(|q| (geom::dist(*q, (c.0, c.1)) - c.2).powi(2)).sum::<f64>() / kept.len() as f64).sqrt(); ((c.0, c.1), e) } _ => (from, f64::NAN) }
}
fn hx(v: &Value) -> (f64, f64) { (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)) }
/// How the bottom photo maps into the top view: the board is turned over either left-right ("x") or
/// top-bottom ("y"). Decided by which mirroring makes the most bottom holes land on top holes.
/// Coordinates are fractions of the board, so the two photos' different pixel sizes do not matter.
pub fn flip_axis(top: &[Value], top_wh: (f64, f64), bot: &[Value], bot_wh: (f64, f64)) -> (String, usize, usize) {
let t: Vec<P> = top.iter().map(|h| { let p = hx(h); (p.0 / top_wh.0, p.1 / top_wh.1) }).collect();
let score = |f: &dyn Fn(P) -> P| bot.iter().filter(|h| { let p = hx(h); let q = f((p.0 / bot_wh.0, p.1 / bot_wh.1)); t.iter().any(|r| geom::dist(*r, q) < 0.012) }).count();
let sx = score(&|p: P| (1.0 - p.0, p.1));
let sy = score(&|p: P| (p.0, 1.0 - p.1));
if sy > sx { ("y".into(), sy, sx) } else { ("x".into(), sx, sy) }
}
/// Rows of three or more holes at an even pitch (headers, terminal blocks): scale candidates.
pub fn hole_rows(holes: &[Value]) -> Vec<Value> {
let pts: Vec<(usize, P, f64)> = holes.iter().enumerate().map(|(i, h)| (i, hx(h), h["outer"].as_f64().unwrap_or(20.0))).collect();
let mut rows = vec![];
for vertical in [false, true] {
let key = |p: P| if vertical { (p.0, p.1) } else { (p.1, p.0) }; // (cross, along)
let mut sorted = pts.clone();
sorted.sort_by(|a, b| key(a.1).partial_cmp(&key(b.1)).unwrap());
let mut used = vec![false; sorted.len()];
for i in 0..sorted.len() {
if used[i] { continue; }
let (c0, tol) = (key(sorted[i].1).0, sorted[i].2 * 0.35);
let mut line: Vec<&(usize, P, f64)> = sorted.iter().enumerate().filter(|(j, q)| !used[*j] && (key(q.1).0 - c0).abs() < tol).map(|(_, q)| q).collect();
line.sort_by(|a, b| key(a.1).1.partial_cmp(&key(b.1).1).unwrap());
for (j, q) in sorted.iter().enumerate() { if (key(q.1).0 - c0).abs() < tol { used[j] = true; } }
// split the line into runs of even spacing
let mut run: Vec<&(usize, P, f64)> = vec![];
let mut flush = |run: &mut Vec<&(usize, P, f64)>| {
if run.len() >= 3 {
let pitch = (key(run[run.len() - 1].1).1 - key(run[0].1).1) / (run.len() - 1) as f64;
rows.push(json!({"holes": run.iter().map(|q| q.0).collect::<Vec<_>>(), "pitch": pitch, "count": run.len(), "vertical": vertical}));
}
run.clear();
};
for q in line {
if run.len() >= 2 {
let last_gap = key(run[run.len() - 1].1).1 - key(run[run.len() - 2].1).1;
let gap = key(q.1).1 - key(run[run.len() - 1].1).1;
if (gap - last_gap).abs() > 0.06 * last_gap { flush(&mut run); }
} else if run.len() == 1 {
let gap = key(q.1).1 - key(run[0].1).1;
if gap > q.2 * 4.0 { flush(&mut run); }
}
run.push(q);
}
flush(&mut run);
}
}
rows
}
/// Material of one pixel. Bodies are found from body materials only, so shadows between parts and the
/// silkscreen never join two parts; metal (terminations, pads) is attached to the body it touches.
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Mat { Mask, Silk, Metal, Gold, Black, Tan, Yellow, BrightGreen, White, Other }
/// Thresholds measured on the Driver photos (10th/50th/90th percentiles, 2026-11-03): solder mask
/// s 0.57/0.78/1.00 v 0.13/0.20/0.29; black moulding s 0.10/0.26/0.60 v 0.06/0.13/0.22 (QFN s 0.18,
/// v 0.16); shadows between parts s 0.58 (mask-like); tan ceramic h 20-32 s 0.64-0.93 v 0.33-0.46;
/// terminations s 0.06-0.35 v 0.17-0.56. The line between mask and bright plastic is the photo's own (`MaskTone::v_hi`), and so is the top of tan ceramic, which photographs at value 0.85 to 0.95 under the CM5's exposure.
pub fn material(p: &Rgb<u8>, tone: &MaskTone) -> Mat {
let (h, s, v) = raster::hsv(p);
if v < 0.30 && s < 0.45 { return Mat::Black; }
if (110.0..200.0).contains(&h) && s >= 0.45 && v < tone.v_hi { return Mat::Mask; }
if (80.0..170.0).contains(&h) && s > 0.3 && v >= tone.v_hi { return Mat::BrightGreen; }
if (40.0..65.0).contains(&h) && s > 0.55 && v > 0.55 { return Mat::Yellow; }
if (25.0..60.0).contains(&h) && (0.25..0.55).contains(&s) && v > 0.45 { return Mat::Gold; }
if (8.0..45.0).contains(&h) && s > 0.45 && v >= 0.25 && v < (0.8 * tone.value_scale()).min(1.001) { return Mat::Tan; }
if s < 0.2 && v > 0.75 { return Mat::Silk; }
if s < 0.35 && v >= 0.3 { return Mat::Metal; }
Mat::Other
}
/// Part bodies on a straightened photo: connected regions of body material (black moulding, tan
/// ceramic, yellow or bright-green plastic, large white plastic) after an opening of `silk_px` that
/// removes silkscreen strokes and noise. Each body is grown by the metal it touches (terminations,
/// leads) and measured as an oriented rectangle, with and without that metal.
pub fn bodies(img: &RgbImage, silk_px: f32, min_px: f64) -> Vec<Value> {
let tone = mask_tone(img);
let mats: Vec<Mat> = img.pixels().map(|p| material(p, &tone)).collect();
let (w, h) = (img.width() as usize, img.height() as usize);
let mk = |f: &dyn Fn(Mat) -> bool| crate::raster::Mask { w, h, d: mats.iter().map(|m| f(*m)).collect() };
// black moulding carries laser marking and highlights: close small gaps and fill what it encloses
// a hole is filled only when it is marking, highlight or metal: never ceramic, plastic or mask,
// so a ring of shadows round a capacitor array cannot turn the array into one body
let black0 = mk(&|m| m == Mat::Black).close(silk_px * 1.5);
let holes = black0.fill_holes().andnot(&black0);
let (hl, hc) = raster::components(&holes);
let mut ok = vec![false; hc.len() + 1];
{ let mut tally = vec![(0usize, 0usize); hc.len() + 1];
for i in 0..hl.len() { let l = hl[i] as usize; if l == 0 { continue; } tally[l].0 += 1;
if matches!(mats[i], Mat::Mask | Mat::Tan | Mat::Yellow | Mat::BrightGreen) { tally[l].1 += 1; } }
for l in 1..=hc.len() { ok[l] = tally[l].1 * 10 < tally[l].0 * 3; } }
let mut black = black0.clone();
for i in 0..hl.len() { if hl[i] != 0 && ok[hl[i] as usize] { black.d[i] = true; } }
// one material at a time: a black body never joins a ceramic one through the shadows between them
let sets: Vec<(&str, crate::raster::Mask)> = vec![
("black", black.open(silk_px)),
("tan", mk(&|m| m == Mat::Tan).open(silk_px)),
("yellow", mk(&|m| m == Mat::Yellow).open(silk_px)),
("bright-green", mk(&|m| m == Mat::BrightGreen).open(silk_px)),
// only large white plastic survives; a metal can's brushed face reads in flecks, so on a bright mask it is closed first or it breaks into dozens of bodies
("white", { let w = mk(&|m| matches!(m, Mat::Silk | Mat::White)); if tone.bright() { w.close(silk_px * 2.0) } else { w } }.open(silk_px * 2.5)),
];
let metal = mk(&|m| matches!(m, Mat::Metal | Mat::Gold | Mat::Silk));
let mut lab = vec![0u32; w * h];
let mut cs: Vec<(raster::Component, &str)> = vec![];
for (name, m) in &sets {
let (l, c) = split_components(m, silk_px * 2.0);
let base = cs.len() as u32;
for i in 0..l.len() { if l[i] != 0 { lab[i] = l[i] + base; } }
for mut k in c { k.label += base; cs.push((k, name)); }
}
let solid = crate::raster::Mask { w, h, d: lab.iter().map(|l| *l != 0).collect() };
// metal pixels within reach of a body join it (terminations); measured separately
let near = solid.dilate(silk_px * 1.5);
let reach = metal.and(&near);
let dist_lab = { // nearest-body label for each reach pixel: grow labels by BFS
let mut l = lab.clone(); let mut frontier: Vec<usize> = (0..w * h).filter(|i| l[*i] != 0).collect();
for _ in 0..(silk_px * 2.0) as usize {
let mut next = vec![];
for &i in &frontier { let (x, y) = (i % w, i / w);
for (dx, dy) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] {
let (nx, ny) = (x as i64 + dx, y as i64 + dy); if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { continue; }
let j = ny as usize * w + nx as usize; if l[j] == 0 && reach.d[j] { l[j] = l[i]; next.push(j); }
} }
frontier = next;
}
l
};
let mut out = vec![];
for (c, material_name) in &cs {
if ((c.x1 - c.x0 + 1) as f64) < min_px && ((c.y1 - c.y0 + 1) as f64) < min_px { continue; }
if c.area as f64 > 0.2 * (w * h) as f64 { continue; }
let pad = (silk_px * 3.0) as usize;
let (x0, y0, x1, y1) = (c.x0.saturating_sub(pad), c.y0.saturating_sub(pad), (c.x1 + pad).min(w - 1), (c.y1 + pad).min(h - 1));
let (mut core, mut full) = (vec![], vec![]);
for y in y0..=y1 { for x in x0..=x1 {
let i = y * w + x;
if lab[i] == c.label { core.push((x as f64 + 0.5, y as f64 + 0.5)); full.push((x as f64 + 0.5, y as f64 + 0.5)); }
else if dist_lab[i] == c.label { full.push((x as f64 + 0.5, y as f64 + 0.5)); }
} }
let (ctr, long, short, ang) = geom::min_rect(&core);
let (fctr, flong, fshort, fang) = geom::min_rect(&full);
let colour = *material_name;
// a body fills most of its rectangle; a shadow network or a silkscreen frame does not
if long * short > 0.0 && (c.area as f64) / (long * short) < 0.5 { continue; }
out.push(json!({"x": fctr.0, "y": fctr.1, "long": flong, "short": fshort, "angle": fang,
"body": {"x": ctr.0, "y": ctr.1, "long": long, "short": short, "angle": ang},
"area": c.area, "colour": colour, "metal": full.len() - core.len()}));
}
out
}
/// Components of a mask, with bodies joined by bridges thinner than 2 x `r` split apart: label the
/// cores left after an erosion by `r`, then grow each label back over its own mask (breadth first, so
/// two bodies meet halfway across the bridge). Pieces too small to leave a core keep their own label.
pub fn split_components(m: &crate::raster::Mask, r: f32) -> (Vec<u32>, Vec<raster::Component>) {
let (w, h) = (m.w, m.h);
let core = m.erode(r);
let (mut lab, _) = raster::components(&core);
let mut frontier: Vec<usize> = (0..w * h).filter(|i| lab[*i] != 0).collect();
while !frontier.is_empty() {
let mut next = vec![];
for &i in &frontier { let (x, y) = (i % w, i / w);
for (dx, dy) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] {
let (nx, ny) = (x as i64 + dx, y as i64 + dy); if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { continue; }
let j = ny as usize * w + nx as usize; if m.d[j] && lab[j] == 0 { lab[j] = lab[i]; next.push(j); }
} }
frontier = next;
}
// pieces with no core (thin or small): label them on their own
let rest = crate::raster::Mask { w, h, d: (0..w * h).map(|i| m.d[i] && lab[i] == 0).collect() };
let (rl, _) = raster::components(&rest);
let base = lab.iter().copied().max().unwrap_or(0);
for i in 0..w * h { if rl[i] != 0 { lab[i] = base + rl[i]; } }
// stats per label
let n = lab.iter().copied().max().unwrap_or(0) as usize;
let mut st: Vec<(usize, usize, usize, usize, usize, f64, f64)> = vec![(0, usize::MAX, usize::MAX, 0, 0, 0.0, 0.0); n + 1];
for i in 0..w * h { let l = lab[i] as usize; if l == 0 { continue; } let (x, y) = (i % w, i / w); let s = &mut st[l];
s.0 += 1; s.1 = s.1.min(x); s.2 = s.2.min(y); s.3 = s.3.max(x); s.4 = s.4.max(y); s.5 += x as f64; s.6 += y as f64; }
let comps = (1..=n).filter(|l| st[*l].0 > 0).map(|l| { let s = st[l]; raster::Component { label: l as u32, area: s.0, x0: s.1, y0: s.2, x1: s.3, y1: s.4, cx: s.5 / s.0 as f64, cy: s.6 / s.0 as f64 } }).collect();
(lab, comps)
}