main
Caleb Publish 0.5.0 1984b40
//! 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)
}