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
Version 0.3.0: new copper reader, dome via filter, every layer read from the photos, net evidence, measured confidence;…
e874d7d
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
//! What the photographs say conducts. A reading of each face that does not depend on the copper reader's over-splitting:
//!
//! - A gap map: dark lines at least 0.6 mm long and straight, and wide areas whose colour is laminate. A dome's shadow
//! and grain are too short to be a line, and glints and domes are bright, so none of them are gaps.
//! - Corridors: two pieces of copper are one conductor where a corridor free of gaps runs between them, as wide as the
//! narrower piece allows. A real gap leaves no corridor, or one no wider than a break in the detected line.
//! - Reach: from a pad's or via's own metal, how far free copper can be followed, how wide and over which directions.
//!
//! Nothing here decides a net. It writes numbers, and `attrib::attribute` decides from them without reading a photo.
use crate::board;
use crate::kicad_out::Pad;
use crate::raster::{blur, Mask, Plane};
use crate::trace::{self, Traced};
use image::RgbImage;
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap};
/// A pixel of the smoothed brightness below this, on the scale laminate 0 and copper 1, can belong to a gap. Read off crops
/// of the Driver photos: gap lines reach below 0.2, dome shadows go that dark too, which is why length has to decide.
const DARK_VALUE: f32 = 0.40;
/// A dark line must run straight for this long, px (0.6 mm). Dome shadows on the Driver bottom are at most 0.5 mm long, and
/// a clearance ring of radius 0.45 mm still holds a chord this long within its 6 px width.
const GAP_RUN_PX: i64 = 24;
/// Green minus red, over the local copper level: copper under mask stays green, and bare laminate and gaps are grey. It
/// separates wide laminate where brightness does not: on the Driver bottom laminate patches reach 0.9 of copper's
/// brightness but stay under 0.7 of its colour.
const LAMINATE_CHROMA: f32 = 0.70;
/// Laminate by colour must be at least twice this wide, px: colour is blurred over about 3 px, which washes out thin tracks.
const LAMINATE_OPEN_PX: f32 = 7.0;
/// Half widths of corridor that get measured, px.
pub const LEVELS: [f32; 4] = [2.0, 3.0, 5.0, 8.0];
/// How far apart, px, two pieces can be and still meet: half a dome's shadow from each side.
const FRONT_REACH: usize = 20;
/// How far copper is followed from a pad's or via's metal, px (0.4 mm), and the ring it looks at around it.
const REACH_PX: usize = 16;
const RING_NEAR_PX: f32 = 11.0;
const SECTORS: usize = 16;
/// Copper this close to the metal, by steps, px (0.2 mm), is at the pad's edge. The reader's halo round bright metal is about this wide.
const TOUCH_PX: u16 = 8;
/// A sector shows a copper edge when this many of its pixels near the metal lie on one.
const EDGE_SECTOR_PX: u32 = 3;
/// Silk: how far copper is followed from each side under it, px, with the dark edge beside the line.
const UNDER_SILK_PX: usize = 12;
const SILK_EDGE_PX: f32 = 4.0;
/// A thin bright line at least this far above what a 3 px disc leaves of it, running straight for this long, px, is the lit
/// edge of copper: oblique light catches the raised edge through the mask. On crops of the Driver photos these lines trace
/// the clearance around isolated pads on both faces, and pads joined to a pour do not have them. Grain stays below.
const EDGE_RISE: f32 = 16.0;
const EDGE_RUN_PX: i64 = 12;
pub struct Seen { pub value: Plane, pub chroma: Plane, pub gap: Mask, pub free: Mask, pub edge: Mask }
/// Each pixel's mean along the straightest line through it, over the pixels that can be seen; the mean around it where no
/// line stands out. Grain cancels without blurring across a gap.
fn along_lines(p: &Plane, skip: &Mask, half: i64) -> Plane {
const NA: usize = 16; const STRUCTURE: f32 = 0.55; const ISO: usize = 6;
let (w, h) = (p.w, p.h);
let (mut a, mut a2, mut c) = (p.clone(), p.clone(), Plane::new(w, h, 1.0));
for i in 0..w * h { if skip.d[i] { a.d[i] = 0.0; a2.d[i] = 0.0; c.d[i] = 0.0; } else { a2.d[i] = p.d[i] * p.d[i]; } }
let (ba, ba2, bc) = (blur(&a, ISO, 1), blur(&a2, ISO, 1), blur(&c, ISO, 1));
let lines: Vec<Vec<(i64, i64)>> = (0..NA).map(|k| { let a = k as f64 * std::f64::consts::PI / NA as f64; (-half..=half).map(|t| ((t as f64 * a.cos()).round() as i64, (t as f64 * a.sin()).round() as i64)).collect() }).collect();
let mut out = Plane::new(w, h, 0.0);
let rows = h / 8 + 1;
std::thread::scope(|sc| {
for (ci, chunk) in out.d.chunks_mut(rows * w).enumerate() {
let (lines, ba, ba2, bc) = (&lines, &ba, &ba2, &bc);
sc.spawn(move || {
for (off, o) in chunk.iter_mut().enumerate() {
let (x, y) = (off % w, ci * rows + off / w); let i = y * w + x;
let (mut best_v, mut best_m) = (f32::MAX, p.d[i]);
for l in lines {
let (mut s, mut s2, mut n) = (0f32, 0f32, 0f32);
for q in l { let (px, py) = (x as i64 + q.0, y as i64 + q.1); if px < 0 || py < 0 || px >= w as i64 || py >= h as i64 { continue; } let j = py as usize * w + px as usize; if !skip.d[j] { let v = p.d[j]; s += v; s2 += v * v; n += 1.0; } }
if n < 5.0 { continue; }
let var = s2 / n - (s / n) * (s / n);
if var < best_v { best_v = var; best_m = s / n; }
}
let n = bc.d[i];
if n > 0.2 { let m = ba.d[i] / n; let v = ba2.d[i] / n - m * m; if best_v >= STRUCTURE * v { best_m = m; } }
*o = best_m;
}
});
}
});
out
}
/// Pixels on a straight run of at least `len` px of the set, in any of sixteen directions.
pub fn on_long_run(m: &Mask, len: i64) -> Mask {
let (w, h) = (m.w as i64, m.h as i64);
let dirs: Vec<Vec<(i64, i64)>> = (0..16).map(|k| { let a = k as f64 * std::f64::consts::PI / 16.0; (0..len).map(|t| ((t as f64 * a.cos()).round() as i64, (t as f64 * a.sin()).round() as i64)).collect() }).collect();
let results: Vec<Vec<bool>> = std::thread::scope(|sc| {
let hs: Vec<_> = dirs.chunks(2).map(|group| { sc.spawn(move || {
let mut out = vec![false; m.d.len()];
for seg in group { for y in 0..h { for x in 0..w {
if !m.d[(y * w + x) as usize] { continue; }
if seg.iter().all(|q| { let (a, b) = (x + q.0, y + q.1); a >= 0 && b >= 0 && a < w && b < h && m.d[(b * w + a) as usize] }) { for q in seg { out[((y + q.1) * w + x + q.0) as usize] = true; } }
} } }
out
}) }).collect();
hs.into_iter().map(|t| t.join().unwrap()).collect()
});
let mut out = Mask::new(m.w, m.h);
for r in results { for (o, v) in out.d.iter_mut().zip(r) { *o |= v; } }
out
}
/// A level for every pixel: the upper quartile of a window of half width `rad`, on a coarse grid, read off the seen pixels.
fn local_upper(c: &Plane, excl: &Mask, rad: usize) -> Plane {
const STEP: usize = 20;
let (w, h) = (c.w, c.h);
let (gw, gh) = (w / STEP + 2, h / STEP + 2);
let mut g = vec![f32::NAN; gw * gh];
for gy in 0..gh { for gx in 0..gw {
let (cx, cy) = (gx * STEP, gy * STEP); let mut v: Vec<f32> = vec![];
for y in (cy.saturating_sub(rad)..(cy + rad).min(h)).step_by(2) { for x in (cx.saturating_sub(rad)..(cx + rad).min(w)).step_by(2) { let i = y * w + x; if !excl.d[i] { v.push(c.d[i]); } } }
if v.len() >= 50 { let k = v.len() * 3 / 4; v.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap()); g[gy * gw + gx] = v[k]; }
} }
// windows with nothing to see take their nearest neighbour
for _ in 0..gw.max(gh) { let prev = g.clone(); let mut any = false; for y in 0..gh { for x in 0..gw { let i = y * gw + x; if !g[i].is_nan() { continue; } any = true; let (mut s, mut n) = (0.0, 0.0); for (dx, dy) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] { let (a, b) = (x as i64 + dx, y as i64 + dy); if a >= 0 && b >= 0 && a < gw as i64 && b < gh as i64 { let v = prev[b as usize * gw + a as usize]; if !v.is_nan() { s += v; n += 1.0; } } } if n > 0.0 { g[i] = s / n; } } } if !any { break; } }
let gp = blur(&Plane { w: gw, h: gh, d: g.iter().map(|v| if v.is_nan() { 40.0 } else { *v }).collect() }, 2, 2);
let mut out = Plane::new(w, h, 0.0);
for y in 0..h { for x in 0..w { out.d[y * w + x] = gp.sample(x as f32 / STEP as f32, y as f32 / STEP as f32); } }
out
}
/// Thin bright lines: brightness above its grey opening by a 3 px disc, kept where they run straight.
fn edge_lines(g: &Plane) -> Mask {
let (w, h) = (g.w, g.h);
let b = blur(g, 1, 1);
let disc: Vec<(i64, i64)> = (-3i64..=3).flat_map(|y| (-3i64..=3).map(move |x| (x, y))).filter(|q| q.0 * q.0 + q.1 * q.1 <= 10).collect();
let filter = |src: &[f32], take_min: bool| -> Vec<f32> { let mut out = vec![0f32; w * h]; for y in 0..h as i64 { for x in 0..w as i64 { let mut v = if take_min { f32::MAX } else { f32::MIN }; for q in &disc { let (a, c) = (x + q.0, y + q.1); if a < 0 || c < 0 || a >= w as i64 || c >= h as i64 { continue; } let p = src[c as usize * w + a as usize]; if take_min { v = v.min(p); } else { v = v.max(p); } } out[y as usize * w + x as usize] = v; } } out };
let opened = filter(&filter(&b.d, true), false);
let thin = Mask { w, h, d: (0..w * h).map(|i| b.d[i] - opened[i] > EDGE_RISE).collect() };
on_long_run(&thin, EDGE_RUN_PX)
}
/// Read one face.
pub fn read_face(img: &RgbImage, t: &Traced) -> Seen {
let (w, h) = (t.w, t.h);
let (mut gp, mut rp) = (Plane::new(w, h, 0.0), Plane::new(w, h, 0.0));
for (i, p) in img.pixels().enumerate() { gp.d[i] = p[1] as f32; rp.d[i] = p[0] as f32; }
let gold = Mask { w, h, d: rp.d.iter().zip(&gp.d).map(|(r, g)| *r > *g + 10.0 && r.max(*g) > 115.0).collect() }.open(2.0);
// brightness on the scale laminate 0, copper 1, over the seen pixels
let excl = t.other.or(&t.pale.dilate(4.0));
let lg_fit = { let b = blur(&gp, 3, 2); Plane { w, h, d: b.d.iter().map(|v| v.max(1.0).ln()).collect() } };
let (cop, lam) = trace::two_levels(&lg_fit, &excl);
let base = { let b = blur(&gp, 1, 1); Plane { w, h, d: b.d.iter().map(|v| v.max(1.0).ln()).collect() } };
let mut clipped = base.clone();
for i in 0..w * h { let (c, l) = (cop.d[i], lam.d[i]); let d = (c - l).max(0.05); clipped.d[i] = base.d[i].clamp(l - 0.5 * d, c + 0.25 * d); }
let unseen = t.other.or(&t.pale).or(&t.silk.dilate(3.0)).or(&gold);
let sm = along_lines(&clipped, &unseen, 8);
let mut value = Plane::new(w, h, 0.0);
for i in 0..w * h { value.d[i] = (sm.d[i] - lam.d[i]) / (cop.d[i] - lam.d[i]); }
// colour against the local copper level
let mut c0 = Plane::new(w, h, 0.0); for i in 0..w * h { c0.d[i] = gp.d[i] - rp.d[i]; }
let cex = t.other.or(&t.pale.dilate(2.5));
let (mut a, mut wt) = (c0.clone(), Plane::new(w, h, 1.0));
for i in 0..w * h { if cex.d[i] { a.d[i] = 0.0; wt.d[i] = 0.0; } }
let (sa, sw) = (blur(&a, 3, 2), blur(&wt, 3, 2));
let s: Vec<f32> = sa.d.iter().zip(&sw.d).map(|(x, y)| *x / y.max(0.05)).collect();
let level = local_upper(&blur(&c0, 3, 2), &cex, 80);
let chroma = Plane { w, h, d: (0..w * h).map(|i| s[i] / level.d[i].max(8.0)).collect() };
let lamin = Mask { w, h, d: (0..w * h).map(|i| chroma.d[i] < LAMINATE_CHROMA && !cex.d[i]).collect() }.open(LAMINATE_OPEN_PX);
let lines = on_long_run(&Mask { w, h, d: (0..w * h).map(|i| value.d[i] < DARK_VALUE && !t.other.d[i]).collect() }, GAP_RUN_PX);
let gap = lines.or(&lamin);
let free = Mask { w, h, d: (0..w * h).map(|i| !gap.d[i] && !t.other.d[i] && !t.silk.d[i]).collect() };
let edge = edge_lines(&gp);
Seen { value, chroma, gap, free, edge }
}
/// Grow labels through `open` for `reach` steps, each pixel taking the nearest label by steps. Returns the labels.
fn grow(labels: &[u32], open: &[bool], w: usize, h: usize, reach: usize) -> Vec<u32> {
let mut l: Vec<u32> = labels.iter().zip(open).map(|(a, o)| if *o { *a } else { 0 }).collect();
let mut front: Vec<usize> = (0..w * h).filter(|i| l[*i] != 0).collect();
for _ in 0..reach {
let mut next = vec![];
for &i in &front {
let (x, y) = (i % w, i / w);
for (ok, j) in [(x > 0, i.wrapping_sub(1)), (x + 1 < w, i + 1), (y > 0, i.wrapping_sub(w)), (y + 1 < h, i + w)] { if ok && open[j] && l[j] == 0 { l[j] = l[i]; next.push(j); } }
}
if next.is_empty() { break; }
front = next;
}
l
}
/// Every place two labels meet: pair, count and where.
fn meetings(l: &[u32], w: usize, h: usize) -> HashMap<(u32, u32), (u32, f64, f64, Vec<usize>)> {
let mut m: HashMap<(u32, u32), (u32, f64, f64, Vec<usize>)> = HashMap::new();
for y in 0..h { for x in 0..w { let i = y * w + x; if l[i] == 0 { continue; }
for (ok, j) in [(x + 1 < w, i + 1), (y + 1 < h, i + w)] { if ok && l[j] != 0 && l[j] != l[i] { let k = (l[i].min(l[j]), l[i].max(l[j])); let e = m.entry(k).or_default(); e.0 += 1; e.1 += x as f64; e.2 += y as f64; if e.3.len() < 400 { e.3.push(i); } } }
} }
m
}
/// What each piece is made of: median brightness and colour, how much of it is pale.
pub fn piece_stats(map: &[u32], n: usize, s: &Seen, t: &Traced) -> Vec<Value> {
let (mut vs, mut cs, mut pale, mut total): (Vec<Vec<f32>>, Vec<Vec<f32>>, Vec<u32>, Vec<u32>) = (vec![vec![]; n + 1], vec![vec![]; n + 1], vec![0; n + 1], vec![0; n + 1]);
for i in 0..map.len() { let k = map[i] as usize; if k == 0 { continue; } total[k] += 1; if t.pale.d[i] { pale[k] += 1; } else if i % 3 == 0 || total[k] < 300 { vs[k].push(s.value.d[i]); cs[k].push(s.chroma.d[i]); } }
let med = |v: &mut Vec<f32>| -> f64 { if v.is_empty() { return 0.0; } let k = v.len() / 2; v.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap()); v[k] as f64 };
(1..=n).map(|k| json!({"value": r2(med(&mut vs[k])), "chroma": r2(med(&mut cs[k])), "pale": r2(pale[k] as f64 / total[k].max(1) as f64)})).collect()
}
/// Where each pair of pieces meets through free space, at each corridor width, and what lies on that meeting.
pub fn corridors(map: &[u32], s: &Seen, keep_off: &Mask, t: &Traced, side: &str) -> Vec<Value> {
let (w, h) = (t.w, t.h);
let dt = s.free.andnot(keep_off).not().distance_to_set();
let mut rows: BTreeMap<(u32, u32), Value> = BTreeMap::new();
let on_map = |i: usize| map[i] != 0;
for (li, lv) in LEVELS.iter().enumerate() {
let open: Vec<bool> = dt.d.iter().map(|v| *v >= *lv).collect();
let l = grow(map, &open, w, h, FRONT_REACH);
for (k, e) in meetings(&l, w, h) {
let row = rows.entry(k).or_insert_with(|| json!({"side": side, "a": k.0, "b": k.1, "open": [0, 0, 0, 0]}));
row["open"][li] = json!(e.0);
if row.get("at").is_none() || li <= 1 {
row["at"] = json!([r3(board::to_mm(e.1 / e.0 as f64, e.2 / e.0 as f64).0), r3(board::to_mm(e.1 / e.0 as f64, e.2 / e.0 as f64).1)]);
let (mut pale, mut dim, mut touch) = (0u32, 0u32, 0u32);
for &i in &e.3 { if t.pale.d[i] { pale += 1; } else if s.value.d[i] < 0.6 { dim += 1; } if on_map(i) { touch += 1; } }
row["onMeeting"] = json!({"pale": pale, "dim": dim, "copper": e.3.len() as u32 - pale - dim, "readerCut": touch});
}
}
}
rows.into_values().collect()
}
/// Where a silk line lies over copper: copper followed a little way under it from each side, and how long two pieces face
/// each other across it. A silk line that crosses a gap touches each side only over its own width.
pub fn silk_contacts(map: &[u32], s: &Seen, keep_off: &Mask, t: &Traced, side: &str) -> Vec<Value> {
let (w, h) = (t.w, t.h);
let under = t.silk.dilate(SILK_EDGE_PX).andnot(keep_off);
let open: Vec<bool> = (0..w * h).map(|i| under.d[i] || (map[i] != 0 && !keep_off.d[i])).collect();
let l = grow(map, &open, w, h, UNDER_SILK_PX);
let near_gap = s.gap.andnot(&t.silk.dilate(1.0)).distance_to_set();
meetings(&l, w, h).into_iter().filter(|(_, e)| e.0 >= 8).filter(|(_, e)| e.3.iter().any(|i| under.d[*i] && map[*i] == 0)).map(|(k, e)| {
let nearg = e.3.iter().filter(|i| near_gap.d[**i] <= 3.0).count();
let (x, y) = board::to_mm(e.1 / e.0 as f64, e.2 / e.0 as f64);
json!({"side": side, "a": k.0, "b": k.1, "contact": e.0, "gapBeside": r2(nearg as f64 / e.3.len().max(1) as f64), "at": [r3(x), r3(y)]})
}).collect()
}
/// A rectangle or ellipse test in a pad's own frame.
pub fn pad_inside(p: &Pad, x: f64, y: f64, grow: f64) -> bool {
let (dx, dy) = (x - p.x, y - p.y);
let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());
let (lx, ly) = (dx * c - dy * s, dx * s + dy * c);
if p.round { (lx / (p.w / 2.0 + grow)).powi(2) + (ly / (p.h / 2.0 + grow)).powi(2) <= 1.0 } else { lx.abs() <= p.w / 2.0 + grow && ly.abs() <= p.h / 2.0 + grow }
}
/// The pads drawn on this face, each pixel holding its pad's index + 1. No growth: a via is only blocked by the pad itself.
pub fn pad_rects(pads: &[Pad], layer: &str, w: usize, h: usize) -> Vec<u32> {
let mut cell = vec![0u32; w * h];
for (k, p) in pads.iter().enumerate() {
if !p.on(layer) { continue; }
let m = p.w.max(p.h) / 2.0 * std::f64::consts::SQRT_2 + 0.05;
let (u0, v0) = board::to_px(p.x - m, p.y - m); let (u1, v1) = board::to_px(p.x + m, p.y + m);
for v in (v0.max(0.0) as usize)..=(v1.max(0.0) as usize).min(h - 1) { for u in (u0.max(0.0) as usize)..=(u1.max(0.0) as usize).min(w - 1) {
let (x, y) = board::to_mm(u as f64, v as f64);
if pad_inside(p, x, y, 0.0) { cell[v * w + u] = k as u32 + 1; }
} }
}
cell
}
/// For every pixel near a pad on this face, which pad's cell it is in: nearest pad by steps from its outline.
pub fn pad_cells(rects: &[u32], w: usize, h: usize) -> Vec<u32> { grow(rects, &vec![true; w * h], w, h, 40) }
/// From a metal shape, how far free copper can be followed. `own` is the pad's index + 1, or 0 for a via. `cells`
/// holds what the flood may not enter: other pads' cells for a pad, other pads' outlines for a via.
pub fn reach_from(p: &Pad, own: u32, cells: &[u32], map: &[u32], s: &Seen, t: &Traced) -> Value {
let (w, h) = (t.w, t.h);
let m = p.w.max(p.h) / 2.0 * std::f64::consts::SQRT_2 + 1.0;
let bound = |v: f64, hi: usize| ((v - board::ORIGIN) * board::RES).clamp(0.0, hi as f64) as usize;
let (x0, x1, y0, y1) = (bound(p.x - m, w - 1), bound(p.x + m, w - 1) + 1, bound(p.y - m, h - 1), bound(p.y + m, h - 1) + 1);
let (lw, lh) = (x1 - x0, y1 - y0);
if lw < 4 || lh < 4 { return json!({"visible": 0, "gapArc": 0, "reach": [], "around": []}); }
let idx = |i: usize| (y0 + i / lw) * w + x0 + i % lw;
let mm = |i: usize| board::to_mm((x0 + i % lw) as f64, (y0 + i / lw) as f64);
let foreign = |k: usize| { let c = cells[k]; c != 0 && c != own };
let mut seed = vec![false; lw * lh];
for i in 0..lw * lh { let (x, y) = mm(i); if pad_inside(p, x, y, 0.05) { seed[i] = true; } }
// the pad's own metal as the photo shows it: hidden or pale pixels joined to it, a little beyond its drawn outline
let metal: Vec<bool> = (0..lw * lh).map(|i| { let k = idx(i); let (x, y) = mm(i); (t.other.d[k] || t.silk.d[k]) && !foreign(k) && pad_inside(p, x, y, 0.25) }).collect();
let mut st: Vec<usize> = (0..lw * lh).filter(|i| seed[*i]).collect();
while let Some(i) = st.pop() { let (x, y) = (i % lw, i / lw); for (ok, j) in [(x > 0, i.wrapping_sub(1)), (x + 1 < lw, i + 1), (y > 0, i.wrapping_sub(lw)), (y + 1 < lh, i + lw)] { if ok && metal[j] && !seed[j] { seed[j] = true; st.push(j); } } }
let open: Vec<bool> = (0..lw * lh).map(|i| seed[i] || (s.free.d[idx(i)] && !foreign(idx(i)))).collect();
let ldt = Mask { w: lw, h: lh, d: open.iter().map(|v| !*v).collect() }.distance_to_set();
let dseed = Mask { w: lw, h: lh, d: seed.clone() }.distance_to_set();
let (mut sx, mut sy, mut sn) = (0f64, 0f64, 0f64);
for i in 0..lw * lh { if seed[i] { sx += (i % lw) as f64; sy += (i / lw) as f64; sn += 1.0; } }
let (cx, cy) = (sx / sn.max(1.0), sy / sn.max(1.0));
let sector = |i: usize| { let a = ((i / lw) as f64 - cy).atan2((i % lw) as f64 - cx) + std::f64::consts::PI / SECTORS as f64; (a.rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU * SECTORS as f64) as usize % SECTORS };
let mut by_piece: HashMap<u32, ([u32; 4], u32, u16, u32)> = HashMap::new();
for (li, lv) in LEVELS.iter().enumerate() {
let mut depth = vec![0u16; lw * lh];
let mut front: Vec<usize> = (0..lw * lh).filter(|i| seed[*i]).collect();
for d in 1..=REACH_PX as u16 {
let mut next = vec![];
for &i in &front { let (x, y) = (i % lw, i / lw); for (ok, j) in [(x > 0, i.wrapping_sub(1)), (x + 1 < lw, i + 1), (y > 0, i.wrapping_sub(lw)), (y + 1 < lh, i + lw)] {
if ok && !seed[j] && depth[j] == 0 && open[j] && ldt.d[j] >= *lv { depth[j] = d; next.push(j); }
} }
front = next;
}
for i in 0..lw * lh { if depth[i] == 0 { continue; } let q = map[idx(i)]; if q == 0 { continue; } let e = by_piece.entry(q).or_default(); e.0[li] += 1; if li == 0 { e.1 |= 1 << sector(i); e.2 = e.2.max(depth[i]); if depth[i] <= TOUCH_PX { e.3 |= 1 << sector(i); } } }
}
// the ring: which sectors can be seen, which show a gap or a lit copper edge, and what lies beyond
let (mut seen, mut ring, mut closed, mut gapped) = ([0u32; SECTORS], [0u32; SECTORS], [0u32; SECTORS], [0u32; SECTORS]);
let mut around: Vec<HashMap<u32, u32>> = vec![HashMap::new(); SECTORS]; let mut far = [0u32; SECTORS];
for i in 0..lw * lh {
let d = dseed.d[i]; if d < 2.0 { continue; }
let (k, se) = (idx(i), sector(i));
if d <= RING_NEAR_PX { ring[se] += 1; if !t.other.d[k] && !t.silk.d[k] && !foreign(k) { seen[se] += 1; if s.gap.d[k] { gapped[se] += 1; } if s.edge.d[k] { closed[se] += 1; } } }
if (6.0..=24.0).contains(&d) { far[se] += 1; if map[k] != 0 { *around[se].entry(map[k]).or_default() += 1; } }
}
let shows: Vec<bool> = (0..SECTORS).map(|c| seen[c] >= 10 && seen[c] as f64 >= 0.4 * ring[c] as f64).collect();
let is_closed: Vec<bool> = (0..SECTORS).map(|c| shows[c] && (closed[c] >= EDGE_SECTOR_PX || gapped[c] as f64 >= 0.2 * seen[c] as f64)).collect();
let visible = shows.iter().filter(|v| **v).count();
let gap_arc = is_closed.iter().filter(|v| **v).count();
let mut open_mask = 0u32; for c in 0..SECTORS { if shows[c] && !is_closed[c] { open_mask |= 1 << c; } }
let around: Vec<u32> = (0..SECTORS).map(|se| around[se].iter().max_by_key(|x| (*x.1, *x.0)).filter(|x| *x.1 as f64 >= 0.4 * far[se].max(1) as f64).map(|x| *x.0).unwrap_or(0)).collect();
let mut reach: Vec<(u32, [u32; 4], u32, u16, u32)> = by_piece.into_iter().map(|(q, e)| (q, e.0, e.1, e.2, e.3)).collect();
reach.sort_by(|a, b| b.1[0].cmp(&a.1[0]).then(a.0.cmp(&b.0))); reach.truncate(8);
let gap_only = (0..SECTORS).filter(|c| shows[*c] && gapped[*c] as f64 >= 0.2 * seen[*c] as f64).count();
json!({"visible": visible, "gapArc": gap_arc, "gapOnly": gap_only, "openMask": open_mask, "around": around, "reach": reach.iter().map(|q| json!({"piece": q.0, "open": q.1, "arc": q.2, "depth": q.3, "touch": q.4})).collect::<Vec<_>>()})
}
/// A plated pin's ring on the bottom photo: sectors of median brightness against distance from the pin's bright pixels, so
/// grain averages out. A clearance gap shows as a valley, then a bright arc on the side away from the light where its far
/// edge catches it. Returns the highest arc and the median valley, both over the level of the copper beyond.
pub fn plated_ring(green: &[f32], smooth: &[f32], other: &Mask, w: usize, h: usize, p: &Pad) -> (f64, f64) {
fn med(v: &mut [f32]) -> f32 { v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[v.len() / 2] }
let m = p.w.max(p.h) / 2.0 + 1.2;
let bound = |v: f64, hi: usize| ((v - board::ORIGIN) * board::RES).clamp(0.0, hi as f64) as usize;
let (x0, x1, y0, y1) = (bound(p.x - m, w - 1), bound(p.x + m, w - 1) + 1, bound(p.y - m, h - 1), bound(p.y + m, h - 1) + 1);
let (lw, lh) = (x1 - x0, y1 - y0);
if lw < 4 || lh < 4 { return (0.0, 0.0); }
let mm = |i: usize| board::to_mm((x0 + i % lw) as f64, (y0 + i / lw) as f64);
let idx = |i: usize| (y0 + i / lw) * w + x0 + i % lw;
let (mut inner, mut near) = (Mask::new(lw, lh), Mask::new(lw, lh));
for i in 0..lw * lh { let (x, y) = mm(i); inner.d[i] = pad_inside(p, x, y, 0.0); near.d[i] = pad_inside(p, x, y, 0.4); }
let mut outside: Vec<f32> = (0..lw * lh).filter(|i| !pad_inside(p, mm(*i).0, mm(*i).1, 0.15)).map(|i| green[idx(i)]).collect();
let level = if outside.is_empty() { 60.0 } else { med(&mut outside) };
let mut inside: Vec<f32> = (0..lw * lh).filter(|i| inner.d[*i]).map(|i| smooth[idx(i)]).collect();
inside.sort_by(|a, b| a.partial_cmp(b).unwrap());
let top = inside.get(inside.len() * 4 / 5).copied().unwrap_or(level);
let thr = ((top + level) / 2.0).max(level * 1.5);
let bright = Mask { w: lw, h: lh, d: (0..lw * lh).map(|i| smooth[idx(i)] > thr && near.d[i]).collect() };
let (cl, comps) = crate::raster::components(&bright);
let mut keep = vec![false; comps.len() + 1];
for i in 0..lw * lh { if cl[i] != 0 && inner.d[i] { keep[cl[i] as usize] = true; } }
let mut blob = Mask { w: lw, h: lh, d: (0..lw * lh).map(|i| cl[i] != 0 && keep[cl[i] as usize]).collect() }.fill_holes();
if blob.count() < 20 { blob = inner.clone(); }
let dist = blob.distance_to_set();
let (mut sx, mut sy, mut sn) = (0f64, 0f64, 0f64);
for i in 0..lw * lh { if blob.d[i] { sx += (i % lw) as f64; sy += (i / lw) as f64; sn += 1.0; } }
let (cx, cy) = (sx / sn, sy / sn);
const BINS: usize = 26;
let mut bins: Vec<Vec<Vec<f32>>> = vec![vec![vec![]; BINS]; SECTORS];
for i in 0..lw * lh {
let d = dist.d[i]; if d < 1.0 || d >= BINS as f32 { continue; }
let a = ((i / lw) as f64 - cy).atan2((i % lw) as f64 - cx) + std::f64::consts::PI / SECTORS as f64;
bins[(a.rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU * SECTORS as f64) as usize % SECTORS][d as usize].push(green[idx(i)]);
}
let _ = other;
let (mut ridges, mut valleys) = (vec![], vec![]);
for (s, sector) in bins.iter().enumerate() {
let r: Vec<Option<f32>> = sector.iter().map(|b| { let mut v = b.clone(); if v.len() >= 3 { Some(med(&mut v)) } else { None } }).collect();
let mut pl: Vec<f32> = r[14..BINS - 1].iter().flatten().copied().collect();
if pl.is_empty() { continue; }
let plateau = med(&mut pl).max(1.0);
let (mut pk, mut ipk) = (f32::MIN, 3usize);
for (b, v) in r.iter().enumerate().take(13).skip(4) { if let Some(v) = v { if *v > pk { pk = *v; ipk = b; } } }
let vl = (2..ipk.max(3)).filter_map(|b| r[b]).fold(f32::MAX, f32::min);
if (1..=7).contains(&s) { ridges.push(if pk > f32::MIN { pk as f64 / plateau as f64 } else { 1.0 }); }
valleys.push(if vl < f32::MAX { vl as f64 / plateau as f64 } else { 1.0 });
}
let ridge = ridges.iter().cloned().fold(0.0, f64::max);
let valley = if valleys.is_empty() { 0.0 } else { valleys.sort_by(|a, b| a.partial_cmp(b).unwrap()); valleys[valleys.len() / 2] };
(r2(ridge), r2(valley))
}
fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }
fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_short_dark_blob_is_not_a_gap_and_a_long_line_is() {
let (w, h) = (100, 100);
let mut m = Mask::new(w, h);
for x in 10..90 { for y in 49..=51 { m.set(x, y, true); } }
for x in 20..30 { for y in 10..20 { m.set(x, y, true); } }
let r = on_long_run(&m, 24);
assert!(r.at(50, 50)); assert!(!r.at(25, 15));
}
#[test]
fn two_pieces_meet_through_free_space_and_not_across_a_gap() {
let (w, h) = (40, 20);
let mut map = vec![0u32; w * h];
for y in 0..h { for x in 0..15 { map[y * w + x] = 1; } for x in 25..40 { map[y * w + x] = 2; } }
let open = vec![true; w * h];
let l = grow(&map, &open, w, h, 20);
assert_eq!(meetings(&l, w, h)[&(1, 2)].0, 20);
let mut closed = open.clone(); for y in 0..h { closed[y * w + 20] = false; }
let l = grow(&map, &closed, w, h, 20);
assert!(meetings(&l, w, h).is_empty());
}
}