main
Caleb Publish 0.5.0 1984b40
//! What each pixel of a board photo shows: solder mask, silkscreen, exposed metal (gold plating or solder), a
//! hole, or a part standing in the way. Fidelity scoring and layer tracing share this reading, so the rules
//! are written down here once. Every threshold below was read off the Driver photos (top and bottom,
//! 40 px/mm board frame, tile set A only) and is marked "from these photos"; a second board may need its own.
use crate::raster::{self, Mask, Plane};
use image::RgbImage;

pub const MASK: u8 = 0; pub const SILK: u8 = 1; pub const GOLD: u8 = 2; pub const SILVER: u8 = 3; pub const OBJECT: u8 = 4; pub const HOLE: u8 = 5;

/// How wide a pixel at 40 px/mm is.
pub const MM2: f64 = 1600.0;

pub struct Face {
    pub w: usize, pub h: usize,
    pub cls: Vec<u8>,
    /// The luminance after a 3 x 3 median: JPEG chroma is blurred, luminance holds the edges.
    pub y: Plane,
    /// Local mask brightness and the photo's silk brightness: edges are found half way between them.
    pub ym: Plane, pub silk_level: f32,
    /// Labels of the kept silk components (0 = none), for tracing.
    pub silk_lab: Vec<u32>,
    pub stats: Vec<(String, f64)>,
}

fn median3(img: &RgbImage, c: usize) -> Plane {
    let (w, h) = (img.width() as usize, img.height() as usize);
    let raw = img.as_raw();
    let v = |x: usize, y: usize| raw[(y * w + x) * 3 + c];
    let mut out = vec![0f32; w * h];
    let mut n = [0u8; 9];
    for y in 0..h { for x in 0..w {
        let (x0, x1, y0, y1) = (x.saturating_sub(1), (x + 1).min(w - 1), y.saturating_sub(1), (y + 1).min(h - 1));
        let mut k = 0;
        for yy in y0..=y1 { for xx in x0..=x1 { n[k] = v(xx, yy); k += 1; } }
        let s = &mut n[..k]; s.sort_unstable();
        out[y * w + x] = s[k / 2] as f32;
    } }
    Plane { w, h, d: out }
}

fn diff(a: &Plane, b: &Plane) -> Plane { Plane { w: a.w, h: a.h, d: a.d.iter().zip(&b.d).map(|(p, q)| p - q).collect() } }

/// The plane the rules call G - R: how far the mask's colour stands out, for a mask of any colour (`maskcolour::mask_over_red`). On a green mask it is G - R itself.
fn mask_over_red(mc: &crate::maskcolour::MaskColour, r: &Plane, g: &Plane, b: &Plane) -> Plane {
    if mc.green { return diff(g, r); }
    Plane { w: r.w, h: r.h, d: (0..r.d.len()).map(|i| crate::maskcolour::mask_over_red(mc, r.d[i], g.d[i], b.d[i])).collect() }
}

/// Normalised box mean of `v` over the pixels `m` sets (radius `r`).
fn masked_mean(v: &Plane, m: &Mask, r: usize) -> Plane {
    let num = raster::blur(&Plane { w: v.w, h: v.h, d: v.d.iter().zip(&m.d).map(|(x, s)| if *s { *x } else { 0.0 }).collect() }, r, 1);
    let den = raster::blur(&Plane { w: v.w, h: v.h, d: m.d.iter().map(|s| if *s { 1.0 } else { 0.0 }).collect() }, r, 1);
    Plane { w: v.w, h: v.h, d: num.d.iter().zip(&den.d).map(|(n, d)| if *d > 1e-3 { n / d } else { 60.0 }).collect() }
}

fn percentile(mut v: Vec<f32>, p: f64) -> f32 { if v.is_empty() { return 0.0; } let k = ((v.len() - 1) as f64 * p) as usize; let (_, x, _) = v.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap()); *x }

/// Keep only the components a test accepts; returns the mask and the labels.
fn keep_comps(m: &Mask, f: impl Fn(&raster::Component) -> bool) -> Mask {
    let (lab, cs) = raster::components(m);
    let ok: Vec<bool> = std::iter::once(false).chain(cs.iter().map(|c| f(c))).collect();
    Mask { w: m.w, h: m.h, d: lab.iter().map(|l| ok[*l as usize]).collect() }
}

/// Whether a face carries parts, measured: how much of it is black moulding or tan ceramic bodies, on the sizes `classify` calls a body. Measured: Driver top 14 percent, Driver bottom 0.16 (its dark
/// joints and specks), CM5 top 10 and 14, CM5 bottom 3.7 and 1.4 (everypin and CNX). A face cannot be told by which side it is: the CM5 bottom has hundreds of parts and the Driver bottom none. The line is 1 percent.
pub fn carries_parts(img: &RgbImage) -> bool {
    let (w, h) = (img.width() as usize, img.height() as usize);
    let (r, g, b) = (median3(img, 0), median3(img, 1), median3(img, 2));
    let y = Plane { w, h, d: (0..w * h).map(|i| 0.299 * r.d[i] + 0.587 * g.d[i] + 0.114 * b.d[i]).collect() };
    let mc = crate::maskcolour::of(img);
    let (br, gr) = (raster::blur(&diff(&b, &r), 2, 1), raster::blur(&mask_over_red(&mc, &r, &g, &b), 2, 1));
    let black = Mask { w, h, d: (0..w * h).map(|i| gr.d[i] < 12.0 && gr.d[i] > -14.0 && br.d[i].abs() < 26.0 && y.d[i] < 62.0).collect() }.open(2.0).close(4.0);
    // (a red mask is warm and under 33 degrees itself: on any mask but green the body must also be off the mask's hue)
    let tan = Mask { w, h, d: (0..w * h).map(|i| -br.d[i] > 45.0 && y.d[i] > 85.0 && raster::hsv(&image::Rgb([r.d[i] as u8, g.d[i] as u8, b.d[i] as u8])).0 < 33.0 && (mc.green || !mc.is_mask(r.d[i], g.d[i], b.d[i]))).collect() }.close(2.0);
    let (_, bc) = raster::components(&black); let (_, tc) = raster::components(&tan);
    let bodies: f64 = bc.iter().filter(|c| c.area as f64 > 0.5 * MM2).map(|c| c.area as f64).sum::<f64>() + tc.iter().filter(|c| c.area as f64 > 0.12 * MM2).map(|c| c.area as f64).sum::<f64>();
    if std::env::var("TRACE_DEBUG").is_ok() { eprintln!("bodies: black {:.4}, tan {:.4} of the face", bc.iter().filter(|c| c.area as f64 > 0.5 * MM2).map(|c| c.area as f64).sum::<f64>() / (w * h) as f64, tc.iter().filter(|c| c.area as f64 > 0.12 * MM2).map(|c| c.area as f64).sum::<f64>() / (w * h) as f64); }
    bodies >= 0.01 * (w * h) as f64
}

/// Whether a face of a run carries parts. Once the run has placed parts (`parts.json`), that list says which faces carry them, and the photo is only asked when there
/// is no list yet. The measured test counts dark and tan blobs, and brass standoffs with their shadows, or a strip of table at the frame's edge, pass it: the USB hub
/// board's bare bottom measured 2.4 percent "black bodies" and was read as a populated face, so the frame of its printed table read as solder and joined its pours.
pub fn face_carries_parts(flow: &std::path::Path, side: &str, img: &RgbImage) -> bool {
    if let Ok(v) = std::env::var("TRACE_BODIES") { return v == "1"; }
    let listed: Option<Vec<String>> = std::fs::read_to_string(flow.join("parts.json")).ok().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
        .and_then(|d| d["parts"].as_array().map(|a| a.iter().map(|p| p["side"].as_str().unwrap_or("top").to_string()).collect()));
    match listed { Some(sides) if !sides.is_empty() => sides.iter().any(|s| s == side), _ => carries_parts(img) }
}

/// Whether the rules below can read a photo with this mask colour. They were written for a green mask and read any other through `maskcolour::mask_over_red`, which was tried on a red and a blue board only.
pub fn reads(_mc: &crate::maskcolour::MaskColour) -> bool { true }

/// `bodies`: this face carries parts. A face with none (the Driver bottom) has no plastic to find, and its lit mask
/// reads near neutral, so the body rules would only do harm there.
///
/// Thresholds that depend on how bright the mask photographs scale with `k` (luma) and `kg` (G - R), measured on this photo and 1 on the Driver photos they were read from: the CM5 mask reads luma 130
/// where the Driver's reads 44 and 64. Thresholds on black, white and gold do not scale: those colours are the same on any board.
pub fn classify(img: &RgbImage, bodies: bool) -> Face {
    let (w, h) = (img.width() as usize, img.height() as usize);
    let mc = crate::maskcolour::of(img);
    let tone = crate::maskcolour::tone(img, &mc);
    let (k, kg) = (tone.exposure(), tone.chroma());
    let (r, g, b) = (median3(img, 0), median3(img, 1), median3(img, 2));
    // on a mask that is not green the mask itself can be warm (red) and is never "blue over red" in the way a green one is
    let off_mask = |i: usize| mc.green || !mc.is_mask(r.d[i], g.d[i], b.d[i]);
    let y = Plane { w, h, d: (0..w * h).map(|i| 0.299 * r.d[i] + 0.587 * g.d[i] + 0.114 * b.d[i]).collect() };
    let raw_br = diff(&b, &r);
    let (br, gr) = (raster::blur(&raw_br, 2, 1), raster::blur(&mask_over_red(&mc, &r, &g, &b), 2, 1));
    let idx = |pred: &dyn Fn(usize) -> bool| Mask { w, h, d: (0..w * h).map(|i| pred(i)).collect() };
    // mask: green over red, not bright. From these photos: G - R > 10.
    let masky = idx(&|i| gr.d[i] > 10.0 * kg && y.d[i] < 140.0 * k && (br.d[i] > -5.0 || !mc.green));
    let ym = masked_mean(&y, &masky, 60);
    // the silk's brightness: pale, bluish, not green. 60th percentile, so glints do not raise it. Silk is white under any exposure, but a bright mask must not count as pale.
    let silk_level = percentile((0..w * h).filter(|i| y.d[*i] > (150.0f32).max(1.25 * tone.y50) && br.d[*i] > if tone.bright() { -14.0 } else { 6.0 } && gr.d[*i] < 20.0 * kg.min(1.5)).map(|i| y.d[i]).collect(), 0.6).max(120.0);
    let bright = idx(&|i| y.d[i] > ym.d[i] + 0.5 * (silk_level - ym.d[i]));
    let hi_pre = 0.85 * silk_level;
    // gold plating and anything yellow or tan: R far over B. From these photos: R - B > 45, Y > 85.
    let warm = idx(&|i| -br.d[i] > 45.0 && y.d[i] > 85.0 && off_mask(i));
    let hue_of = |i: usize| raster::hsv(&image::Rgb([r.d[i] as u8, g.d[i] as u8, b.d[i] as u8]));
    // tan ceramic: hue under 33 degrees; gold plating sits at 40 to 55.
    let tan = idx(&|i| warm.d[i] && hue_of(i).0 < 33.0 && y.d[i] < 175.0 * k);
    // the bottom's plating reads pale in this light: bright and warmer than any silk is still metal
    let pale_warm = idx(&|i| bright.d[i] && -br.d[i] > 14.0 && gr.d[i] <= 26.0 * kg);
    // (only blobs big enough to be plating: a fringe on a silk letter and a glint on a dome are also warm)
    let gold_any = if bodies { warm.andnot(&tan) } else { warm.andnot(&tan).or(&keep_comps(&pale_warm, |c| c.area as f64 > 0.4 * MM2)) };
    // black moulding: neutral and dark. Laminate under mask stays green here (G - R about 27).
    let neutral = idx(&|i| gr.d[i] < 12.0 && gr.d[i] > -14.0 && br.d[i].abs() < 26.0);
    let black = idx(&|i| neutral.d[i] && y.d[i] < 62.0);
    // bright green plastic: far brighter than the mask around it and still green.
    let mint = idx(&|i| gr.d[i] > 30.0 * kg && y.d[i] > ym.d[i] * 1.7 && y.d[i] > 80.0 * k);
    // silk candidates: bright, not warm, not strongly green; loose on chroma because lateral colour fringes thin lines
    let cand = idx(&|i| bright.d[i] && !warm.d[i] && gr.d[i] <= 26.0 * kg && br.d[i] >= if bodies { -30.0 } else { -14.0 });
    // not green: everything that is not mask has G close to R, whatever its brightness
    let nongreen = idx(&|i| gr.d[i] / (g.d[i] + r.d[i] + 20.0) * 1.0 < 0.08);
    // On a bright mask a thin print's lit core does not lean blue as the Driver's does (median B - R -9 to +7 on the CM5 photos, against 6 to 30), and its glints have no bluer tint to tell them by: the print's own
    // median is the bar, less a margin of 10 for lateral colour.
    let silk_chroma_bar = if tone.bright() { percentile((0..w * h).step_by(3).filter(|i| y.d[*i] > hi_pre && gr.d[*i] < 20.0 * kg.min(1.5)).map(|i| raw_br.d[i]).collect(), 0.5) - 10.0 } else { 0.0 };
    let (lab, cs) = raster::components(&cand);
    let n = cs.len() + 1;
    // per component: share over 85% of the silk level, chroma of the bright core, thickest point, and the ring
    let hi = 0.85 * silk_level;
    let mut nhi = vec![0usize; n]; let mut core_br: Vec<Vec<f32>> = vec![vec![]; n];
    for i in 0..w * h { let l = lab[i] as usize; if l != 0 && y.d[i] > hi { nhi[l] += 1; core_br[l].push(raw_br.d[i]); } }
    let dt = cand.not().distance_to_set();
    let mut dmax = vec![0f32; n];
    for i in 0..w * h { let l = lab[i] as usize; if l != 0 { dmax[l] = dmax[l].max(dt.d[i]); } }
    let ysm = raster::blur(&y, 1, 1);
    let mut silk_ok = vec![false; n];
    let mut metal_ok = vec![false; n];
    for c in &cs {
        let l = c.label as usize;
        let a = c.area as f64;
        let med = if core_br[l].is_empty() { -99.0 } else { percentile(std::mem::take(&mut core_br[l]), 0.5) };
        // the ring 3 to 7 px out: how bright the mask beside it is
        let (x0, x1, y0, y1) = (c.x0.saturating_sub(7), (c.x1 + 7).min(w - 1), c.y0.saturating_sub(7), (c.y1 + 7).min(h - 1));
        let mut sub = Mask::new(x1 - x0 + 1, y1 - y0 + 1);
        for yy in c.y0..=c.y1 { for xx in c.x0..=c.x1 { if lab[yy * w + xx] == c.label { sub.set(xx - x0, yy - y0, true); } } }
        let d = sub.distance_to_set();
        let mut rv = vec![];
        for yy in y0..=y1 { for xx in x0..=x1 { let q = d.at(xx - x0, yy - y0); if (3.0..=7.0).contains(&q) && !cand.d[yy * w + xx] { rv.push(ysm.d[yy * w + xx]); } } }
        let ring = percentile(rv, 0.5);
        // how much of the ring is not mask: silk stands on mask, the bright end of a part stands beside its body
        let (mut rn, mut rt) = (0usize, 0usize);
        for yy in y0..=y1 { for xx in x0..=x1 { let q = d.at(xx - x0, yy - y0); let j = yy * w + xx; if (3.0..=7.0).contains(&q) && !cand.d[j] { rt += 1; if nongreen.d[j] { rn += 1; } } } }
        let ring_off = rn as f64 / rt.max(1) as f64;
        let (bw, bh) = ((c.x1 - c.x0 + 1) as f64, (c.y1 - c.y0 + 1) as f64);
        let asp = bw.max(bh) / bw.min(bh); let fill = a / (bw * bh);
        let roundish = a < 150.0 && asp < 1.8 && fill > 0.72;
        let hf = nhi[l] as f64 / a;
        let solid = dmax[l] >= 6.0 && a > 500.0;
        // Silk is bluish against neutral glints: a specular copper edge reads B - R about 1, silk 6 to 30 (from these
        // photos). Where the mask around is bright the glints turn bluer too, so the bar rises with it.
        // On a bare face a thin line needs more: real silk lines read about 24 there and copper-edge glints stay under 10 (tile
        // set A). Letters are saved beside it by the solid-print rule below.
        let bar = if tone.bright() { silk_chroma_bar } else if !bodies && dmax[l] < 6.0 { 10.0f32 } else { 4.0 } + 0.2 * (ring - 60.0).max(0.0);
        let bluish = med >= bar;
        // a part's bright end beside its body fails this; from these photos 15% for solid blobs and 40% for thin lines
        let on_mask = !bodies || ring_off < if dmax[l] >= 6.0 { 0.15 } else { 0.4 };
        silk_ok[l] = a >= 30.0 && hf >= 0.2 && (bluish || solid) && !(roundish && med < 14.0) && on_mask;
        // a small neutral mark with bare mask all round on a populated face is print, not solder: solder always stands beside a part
        if bodies && !silk_ok[l] && ring_off < 0.1 && a >= 30.0 && a < 800.0 && hf >= 0.2 && med >= 0.0 && !roundish { silk_ok[l] = true; }
        // a solid blob with letters cut out of it is a printed label, even one that touches a gold lug: part ends have no letters
        if !silk_ok[l] && dmax[l] >= 6.0 && a > 500.0 && med >= 0.0 && hf >= 0.2 { let filled = sub.fill_holes().count() as f64; if (filled - a) / a >= 0.08 { silk_ok[l] = true; } }
        // solder on a face with parts is everywhere and small; on a face without, only a big blob is solder, the rest glints
        metal_ok[l] = !silk_ok[l] && if bodies { dmax[l] >= 3.5 && a >= 40.0 } else { dmax[l] >= 6.0 && a >= 100.0 };
    }
    let provisional = Mask { w, h, d: lab.iter().map(|l| silk_ok[*l as usize]).collect() };
    let silver0 = Mask { w, h, d: lab.iter().map(|l| metal_ok[*l as usize]).collect() };
    // Objects. Blobs of plastic, moulding and ceramic; holes in a blob (markings) belong to it.
    let yellow_big = if bodies { keep_comps(&gold_any.close(2.0), |c| c.area as f64 > 60.0 * MM2) } else { Mask::new(w, h) };
    let objects = if !bodies { Mask::new(w, h) } else {
        let on_board = rounded_board(w, h, 2.6 * 40.0);
        let mint_big = keep_comps(&mint.close(3.0), |c| c.area as f64 > 5.0 * MM2);
        let tan_big = keep_comps(&tan.close(2.0), |c| c.area as f64 > 0.12 * MM2);
        let black_big = keep_comps(&black.open(2.0).close(4.0), |c| c.area as f64 > 0.5 * MM2);
        let grey_big = keep_comps(&idx(&|i| neutral.d[i] && y.d[i] >= 62.0).close(3.0), |c| c.area as f64 > 15.0 * MM2);
        // anything else that is plainly not mask and not bright: dull ceramics in the dark corner of the photo
        let dull_body = keep_comps(&idx(&|i| nongreen.d[i] && y.d[i] < 110.0 && !cand.d[i] && !warm.d[i]).open(3.0), |c| c.area as f64 > 0.5 * MM2);
        hull_big(&mint_big).or(&tan_big).or(&black_big).or(&yellow_big).or(&grey_big).or(&dull_body).close(2.0).and(&on_board).fill_holes_upto((6.0 * MM2) as usize)
    };
    // gold that hangs off a yellow connector is its pins
    let near_yellow = yellow_big.dilate(12.0);
    let pins = { let (gl, gc) = raster::components(&gold_any); let mut hit = vec![false; gc.len() + 1]; for i in 0..w * h { if gl[i] != 0 && near_yellow.d[i] { hit[gl[i] as usize] = true; } } Mask { w, h, d: gl.iter().map(|l| hit[*l as usize]).collect() } };
    let objects = objects.or(&pins);
    // silk that is really a part's marking or a bright edge of a part: most of its ring is object
    let obj_near = objects.dilate(5.0);
    let (sl, sc) = raster::components(&provisional);
    let mut on = vec![0usize; sc.len() + 1];
    for i in 0..w * h { if sl[i] != 0 && obj_near.d[i] { on[sl[i] as usize] += 1; } }
    let silk_keep: Vec<bool> = std::iter::once(false).chain(sc.iter().map(|c| (on[c.label as usize] as f64) < 0.5 * c.area as f64)).collect();
    let silk = Mask { w, h, d: sl.iter().map(|l| silk_keep[*l as usize]).collect() };
    let rejected_silk = provisional.andnot(&silk);
    // a rejected speck beside solid silk is the same print: chroma noise, not metal
    let solid_silk = keep_comps(&silk, |c| c.area as f64 > 500.0).dilate(10.0);
    let (rl, _) = raster::components(&cand);
    let mut rescued = vec![false; n];
    for i in 0..w * h { let l = rl[i] as usize; if l != 0 && solid_silk.d[i] && !silk.d[i] && (dmax[l] < 6.0) { rescued[l] = true; } }
    let silk = Mask { w, h, d: (0..w * h).map(|i| silk.d[i] || (rl[i] != 0 && rescued[rl[i] as usize] && !objects.d[i])).collect() };
    // on a bare face a lone speck far from any printing is a dome's glint
    let silk = if bodies { silk } else { let near = keep_comps(&silk, |c| c.area as f64 > 300.0).dilate(60.0); let (l2, c2) = raster::components(&silk); let mut ok = vec![false; c2.len() + 1]; for c in &c2 { if c.area as f64 >= 150.0 { ok[c.label as usize] = true; } } for i in 0..w * h { if l2[i] != 0 && near.d[i] { ok[l2[i] as usize] = true; } } Mask { w, h, d: l2.iter().map(|l| ok[*l as usize]).collect() } };
    let (sl2, _) = raster::components(&silk);
    let silk_lab: Vec<u32> = sl2;
    // metal: gold, and bright neutral blobs that are not silk. Duller solder between bright glints closes in.
    let gold = gold_any.andnot(&objects);
    let dull = idx(&|i| neutral.d[i] && y.d[i] >= 62.0);
    let silver_seed = if bodies { silver0.or(&rejected_silk).andnot(&objects) } else { silver0.andnot(&objects) };
    let mut silver = if bodies { reconstruct(&silver_seed, &dull.andnot(&objects)).close(1.5).andnot(&silk).andnot(&gold) } else { silver_seed.andnot(&silk).andnot(&gold) };
    let holes = find_holes(&y, &br, &gr, &gold.or(&silver), &objects);
    // a solder joint on a face without parts: a round dome that mirrors its surroundings, so only its colour gives it away.
    // From these photos: (G - R) / (G + R + 20) under 0.14 where bare mask reads 0.2 and up.
    if !bodies {
        let off = silk.or(&gold).or(&holes).dilate(2.0);
        let low = idx(&|i| gr.d[i] / (g.d[i] + r.d[i] + 20.0) < 0.14);
        let dome = keep_comps(&low.andnot(&off).open(3.0).close(5.0).fill_holes_upto((3.0 * MM2) as usize), |c| { let (bw, bh) = ((c.x1 - c.x0 + 1) as f64, (c.y1 - c.y0 + 1) as f64); (1.0 * MM2..16.0 * MM2).contains(&(c.area as f64)) && bw / bh < 1.35 && bh / bw < 1.35 && c.area as f64 / (bw * bh) > 0.62 });
        silver = silver.or(&dome.andnot(&silk).andnot(&gold).andnot(&holes));
    }
    let mut cls = vec![MASK; w * h];
    for i in 0..w * h {
        cls[i] = if holes.d[i] { HOLE } else if silk.d[i] { SILK } else if gold.d[i] { GOLD } else if silver.d[i] { SILVER } else if objects.d[i] { OBJECT } else { MASK };
    }
    let stats = vec![("silkLevel".into(), silk_level as f64)];
    Face { w, h, cls, y, ym, silk_level, silk_lab, stats }
}

/// The board frame with rounded corners, as a mask.
fn rounded_board(w: usize, h: usize, r: f64) -> Mask {
    let mut m = Mask { w, h, d: vec![true; w * h] };
    for y in 0..h { for x in 0..w {
        let dx = if (x as f64) < r { r - x as f64 } else if (x as f64) > w as f64 - r { x as f64 - (w as f64 - r) } else { 0.0 };
        let dy = if (y as f64) < r { r - y as f64 } else if (y as f64) > h as f64 - r { y as f64 - (h as f64 - r) } else { 0.0 };
        if dx > 0.0 && dy > 0.0 && dx * dx + dy * dy > r * r { m.d[y * w + x] = false; }
    } }
    m
}

/// Large solid bodies are convex and dark plastic reads as mask: fill each to its hull.
fn hull_big(m: &Mask) -> Mask {
    let (lab, cs) = raster::components(m);
    let mut out = m.clone();
    let f = crate::gerber::Frame { w: m.w, h: m.h, res: 1.0, ox: 0.0, oy: 0.0 };
    for c in cs.iter().filter(|c| c.area as f64 > 8.0 * MM2 && (c.area as f64) < 150.0 * MM2) {
        let mut pts = vec![];
        for y in c.y0..=c.y1 { let (mut a, mut b) = (None, None); for x in c.x0..=c.x1 { if lab[y * m.w + x] == c.label { if a.is_none() { a = Some(x); } b = Some(x); } } if let (Some(a), Some(b)) = (a, b) { pts.push((a as f64 + 0.5, y as f64 + 0.5)); pts.push((b as f64 + 0.5, y as f64 + 0.5)); } }
        let hull = crate::geom::hull(&pts);
        crate::gerber::fill_poly(&mut out, &f, &hull);
    }
    out
}

/// Grow `seed` through `through`: everything in `through` connected to a seed.
fn reconstruct(seed: &Mask, through: &Mask) -> Mask {
    let all = seed.or(through);
    let (lab, cs) = raster::components(&all);
    let mut hit = vec![false; cs.len() + 1];
    for i in 0..lab.len() { if seed.d[i] { hit[lab[i] as usize] = true; } }
    Mask { w: seed.w, h: seed.h, d: lab.iter().map(|l| hit[*l as usize]).collect() }
}

/// Holes: dark, not green, round or ringed by metal.
fn find_holes(y: &Plane, br: &Plane, gr: &Plane, metal: &Mask, objects: &Mask) -> Mask {
    let (w, h) = (y.w, y.h);
    let dark = Mask { w, h, d: (0..w * h).map(|i| y.d[i] < 110.0 && gr.d[i] < 8.0 && br.d[i] < 15.0 && !metal.d[i]).collect() }.close(2.0);
    let (lab, cs) = raster::components(&dark);
    let ringm = metal.dilate(1.0);
    let mut ok = vec![false; cs.len() + 1];
    for c in &cs {
        let a = c.area as f64;
        if a < 0.09 * MM2 || a > 12.0 * MM2 { continue; }
        let (bw, bh) = ((c.x1 - c.x0 + 1) as f64, (c.y1 - c.y0 + 1) as f64);
        if bw / bh > 1.3 || bh / bw > 1.3 { continue; }
        let round = a / (std::f64::consts::PI * bw * bh / 4.0);
        if round < 0.78 { continue; }
        // sample the ring 3 px outside the box-fitted circle
        let (cx, cy, rr) = (c.cx + 0.5, c.cy + 0.5, (bw + bh) / 4.0 + 3.0);
        let (mut on_metal, mut on_obj, mut t) = (0, 0, 0);
        for k in 0..64 { let ang = k as f64 / 64.0 * std::f64::consts::TAU; let (u, v) = (cx + rr * ang.cos(), cy + rr * ang.sin()); if u < 0.0 || v < 0.0 || u as usize >= w || v as usize >= h { continue; } t += 1; let i = v as usize * w + u as usize; if ringm.d[i] { on_metal += 1; } if objects.d[i] { on_obj += 1; } }
        if t == 0 { continue; }
        let share = on_metal as f64 / t as f64; let obj = on_obj as f64 / t as f64;
        // an unplated hole sits in bare mask; a plated one in its ring. Inside a part it is part of the part.
        ok[c.label as usize] = obj < 0.3 && (share > 0.5 || (a > 0.3 * MM2 && round > 0.86));
    }
    Mask { w, h, d: lab.iter().map(|l| ok[*l as usize]).collect() }
}

/// Worst to best: what this face's pixels are, drawn for looking at.
pub fn false_colour(img: &RgbImage, f: &Face) -> RgbImage {
    let mut o = img.clone();
    for (i, p) in o.pixels_mut().enumerate() {
        let c = match f.cls[i] { SILK => Some([70, 200, 255]), GOLD => Some([255, 190, 0]), SILVER => Some([255, 40, 40]), OBJECT => Some([200, 0, 200]), HOLE => Some([255, 255, 255]), _ => None };
        match c { Some(c) => { *p = image::Rgb(c); } None => { *p = image::Rgb([p[0] / 3, p[1] / 3, p[2] / 3]); } }
    }
    o
}

/// A hole goes through: one seen on a single face, with plain mask opposite, is a round dark part of something else
/// (the Driver reset button has one). It may stay one-sided only where the other face is hidden by a part or a joint.
pub fn link_holes(top: &mut Face, bot: &mut Face) {
    for (a, b) in [(0usize, 1usize), (1, 0)] {
        let (fa, fb): (&mut Face, &Face) = if a == 0 { (&mut *top, &*bot) } else { (&mut *bot, &*top) };
        let _ = b;
        let m = Mask { w: fa.w, h: fa.h, d: fa.cls.iter().map(|c| *c == HOLE).collect() };
        let (lab, cs) = raster::components(&m);
        let mut bad = vec![false; cs.len() + 1];
        for c in &cs {
            let (cx, cy) = (c.cx as usize, c.cy as usize);
            let r = ((c.x1 - c.x0).max(c.y1 - c.y0) / 2 + 12) as i64;
            let (mut opp_hole, mut opp_mask, mut t) = (0, 0, 0);
            for dy in -r..=r { for dx in -r..=r { if dx * dx + dy * dy > r * r { continue; } let (u, v) = (cx as i64 + dx, cy as i64 + dy); if u < 0 || v < 0 || u as usize >= fb.w || v as usize >= fb.h { continue; } t += 1; match fb.cls[v as usize * fb.w + u as usize] { HOLE => opp_hole += 1, MASK | SILK => opp_mask += 1, _ => {} } } }
            if opp_hole * 8 < c.area && (opp_mask as f64) > 0.7 * t as f64 { bad[c.label as usize] = true; }
        }
        for i in 0..lab.len() { if lab[i] != 0 && bad[lab[i] as usize] { fa.cls[i] = OBJECT; } }
    }
}