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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
//! Copper and laminate levels read from the photo's own step edges, and a test of any pair of levels against those edges.
//!
//! The two-class fit in `trace.rs` splits the brightness of plain mask in two. On a face that is nearly all laminate with thin tracks on it, under a light that also
//! casts shadows, the split lands between shadow and laminate, the laminate takes the copper level, and every track, pad and pour joins through it into one island
//! (USB hub test board, top face, 2026-11-10: one island held 192 of 246 joined pins).
//!
//! A copper edge is a sharp step, whatever share of the face each class has. The step that occurs most often among a face's sharp edges is its copper over laminate
//! step, and each such edge gives one sample of the copper level (its bright side) and one of the laminate level (its dark side) at that place. Two uses:
//!
//! - `agreement` says how many of those edges have both sides on one side of the class threshold of a given pair of levels. Levels that put most copper edges wholly
//! in one class are wrong, and the photo itself says so.
//! - `levels` interpolates the edge samples into the two level fields, which then follow the light because the samples do.
use crate::raster::{blur, Mask, Plane};
/// How far each side of an edge is sampled, frame pixels at 40 px/mm: from 2 px, clear of the edge's own blur, to 7 px, 0.175 mm, which stays on a track of the
/// narrowest width the frame resolves. Not fitted to any photo.
const NEAR: i64 = 2;
const FAR: i64 = 7;
/// A copper edge is sharp: the brightness change across `SHARP_PX` pixels at the crest, at the crest's slope, is at least `SHARP_SHARE` of the whole step. An edge the lens
/// and a 1.4 px smoothing have blurred to sigma 2 px gives 0.8; the penumbra of a part's shadow, a ramp 15 px wide on the hub board's top beside its connectors, gives 0.27.
const SHARP_PX: f32 = 4.0;
const SHARP_SHARE: f32 = 0.5;
/// The shortest run of crest pixels that counts as an edge: 0.5 mm at 40 px/mm. Specks on the hub board's top give runs of 3 to 10.
const MIN_RUN: u32 = 20;
/// Edges are the pixels in the top tenth of the gradient of the seen face. Not fitted: a face with fewer sharp edges than that only adds weak ones, whose small
/// steps fall below the modal band.
const EDGE_SHARE: f64 = 0.90;
/// A step counts as a copper edge within 35 percent of the modal step. On the hub board's top the histogram's peak at 0.37 has fallen to half by 0.30 and 0.45;
/// shadows, pits and glare make the wide tails outside that.
const BAND: f32 = 0.35;
/// Below this modal step (6 percent in brightness) the face shows no copper edges that stand clear of grain, and nothing here applies.
const MIN_STEP: f32 = 0.06;
/// Fewer edge samples than this in a window and the next wider window is used.
const MIN_SAMPLES: f32 = 60.0;
pub struct StepEdges {
pub w: usize, pub h: usize,
/// position, bright side and dark side in the log measure, for every edge whose step lies in the modal band
pub at: Vec<(u32, u32, f32, f32)>,
/// the step that occurs most often, in the log measure
pub modal: f32,
/// all edges measured, in the band or not
pub measured: usize,
}
/// How a pair of levels sits against the face's copper edges: shares of the in-band edges.
#[derive(Clone, Copy, Debug, Default)]
pub struct Agreement { pub edges: usize, pub straddle: f32, pub both_copper: f32, pub both_laminate: f32, pub modal_step: f32 }
impl Agreement {
/// More than half the face's copper edges wholly inside one class: the levels did not come from the copper. A majority, not a fitted line. The larger of the two shares reads 0.19 and 0.29 on the
/// Driver top and bottom and 0.20 on the CM5 bottom, where the fit holds, and 0.77 on the hub board's top and 0.58 on the CM5 top, where it does not (`TRACE_DEBUG` prints them).
pub fn levels_are_wrong(&self) -> bool { self.edges >= 500 && self.both_copper.max(self.both_laminate) > 0.5 }
}
/// Every sharp edge of `measure` (linear brightness in the mask's channel) clear of `unseen`, with the level on each side. None when the face shows no usable steps.
pub fn step_edges(measure: &Plane, unseen: &Mask) -> Option<StepEdges> {
let (w, h) = (measure.w, measure.h);
if w < 40 || h < 40 { return None; }
let l = { let b = blur(measure, 1, 3); Plane { w, h, d: b.d.iter().map(|v| v.max(1.0).ln()).collect() } };
let off = unseen.dilate(4.0);
let (mut gx, mut gy, mut mag) = (Plane::new(w, h, 0.0), Plane::new(w, h, 0.0), Plane::new(w, h, 0.0));
for y in 1..h - 1 { for x in 1..w - 1 { let i = y * w + x; let (a, b) = ((l.d[i + 1] - l.d[i - 1]) / 2.0, (l.d[i + w] - l.d[i - w]) / 2.0); gx.d[i] = a; gy.d[i] = b; mag.d[i] = (a * a + b * b).sqrt(); } }
let mut seen: Vec<f32> = (0..w * h).step_by(7).filter(|i| !off.d[*i]).map(|i| mag.d[i]).collect();
if seen.len() < 2000 { return None; }
let k = ((seen.len() - 1) as f64 * EDGE_SHARE) as usize;
let bar = *seen.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap()).1;
let m = FAR as usize + 2;
let mut all: Vec<(u32, u32, f32, f32)> = vec![];
for y in m..h - m { for x in m..w - m { let i = y * w + x; let g = mag.d[i]; if g <= bar || off.d[i] { continue; }
let (ux, uy) = ((gx.d[i] / g) as f64, (gy.d[i] / g) as f64);
let px = |k: f64| -> usize { ((y as f64 + uy * k).round() as usize) * w + (x as f64 + ux * k).round() as usize };
// the crest of the edge only: a pixel beside the crest measures the same step again, off centre
if g < mag.d[px(1.0)] || g < mag.d[px(-1.0)] { continue; }
if (-FAR..=FAR).any(|k| off.d[px(k as f64)]) { continue; }
let mut up: Vec<f32> = (NEAR..=FAR).map(|k| l.d[px(k as f64)]).collect(); let mut down: Vec<f32> = (NEAR..=FAR).map(|k| l.d[px(-(k as f64))]).collect();
up.sort_by(|a, b| a.partial_cmp(b).unwrap()); down.sort_by(|a, b| a.partial_cmp(b).unwrap());
// the upper quartile of the bright side and the lower of the dark: a track or a lane narrower than the reach has the other class at its far end
let (hi, lo) = (up[up.len() * 3 / 4], down[down.len() / 4]);
// a cast shadow's edge is a ramp: the copper edge's step is nearly all there within `SHARP_PX` of the crest
if g * SHARP_PX < SHARP_SHARE * (hi - lo) { continue; }
all.push((x as u32, y as u32, hi, lo));
} }
// A copper edge runs on; a bright speck of grain or dust gives a few crest pixels in a ring. Crests are joined through their neighbours and short runs dropped.
let all: Vec<(u32, u32, f32, f32)> = {
let mut crest = Mask::new(w, h); for e in &all { crest.set(e.0 as usize, e.1 as usize, true); }
let (lab, cs) = crate::raster::components(&crest.dilate(1.0));
let mut n = vec![0u32; cs.len() + 1]; for e in &all { n[lab[e.1 as usize * w + e.0 as usize] as usize] += 1; }
all.into_iter().filter(|e| n[lab[e.1 as usize * w + e.0 as usize] as usize] >= MIN_RUN).collect()
};
if all.len() < 500 { return None; }
let mut hist = [0f32; 40];
for e in &all { let s = e.2 - e.3; if s > 0.0 && s < 1.0 { hist[(s / 0.025) as usize] += 1.0; } }
let sm: Vec<f32> = (0..40).map(|i| { let (a, b) = (i.max(1) - 1, (i + 2).min(40)); hist[a..b].iter().sum::<f32>() }).collect();
let peak = (0..40).max_by(|a, b| sm[*a].partial_cmp(&sm[*b]).unwrap())?;
let modal = (peak as f32 + 0.5) * 0.025;
// on a stitched face grain can outnumber copper edges: then the commonest step among edges that go on in line is the copper's (`locallevels::without_grain`)
let (all, modal, _) = crate::locallevels::without_grain(all, modal, &gx, &gy);
if modal < MIN_STEP { return None; }
let measured = all.len();
let at: Vec<_> = all.into_iter().filter(|e| { let s = e.2 - e.3; s > (1.0 - BAND) * modal && s < (1.0 + BAND) * modal }).collect();
Some(StepEdges { w, h, at, modal, measured })
}
/// Where the face's copper edges fall against the class threshold `at` (a share of the way from `lam` to `cop`).
pub fn agreement(e: &StepEdges, cop: &Plane, lam: &Plane, at: f32) -> Agreement {
let (mut st, mut up, mut dn) = (0usize, 0usize, 0usize);
for (x, y, hi, lo) in &e.at { let i = *y as usize * e.w + *x as usize; let (c, l) = (cop.d[i], lam.d[i]); let d = (c - l).max(0.02);
let (vh, vl) = ((hi - l) / d, (lo - l) / d);
if vl >= at { up += 1; } else if vh <= at { dn += 1; } else { st += 1; } }
let n = e.at.len().max(1) as f32;
Agreement { edges: e.at.len(), straddle: st as f32 / n, both_copper: up as f32 / n, both_laminate: dn as f32 / n, modal_step: e.modal }
}
/// The copper and laminate levels over the face, from the edge samples: a mean over the nearest window that holds enough of them (12, 6 and 3 mm half width at
/// 40 px/mm, the finest that has the samples), the face's medians where none does.
pub fn levels(e: &StepEdges) -> (Plane, Plane) {
let (w, h) = (e.w, e.h);
let (mut sh, mut sl, mut sc) = (Plane::new(w, h, 0.0), Plane::new(w, h, 0.0), Plane::new(w, h, 0.0));
for (x, y, hi, lo) in &e.at { let i = *y as usize * w + *x as usize; sh.d[i] += hi; sl.d[i] += lo; sc.d[i] += 1.0; }
let med = |k: usize| -> f32 { let mut v: Vec<f32> = e.at.iter().map(|s| if k == 0 { s.2 } else { s.3 }).collect(); if v.is_empty() { return 0.0; } let n = v.len() / 2; *v.select_nth_unstable_by(n, |a, b| a.partial_cmp(b).unwrap()).1 };
let (mut cop, mut lam) = (Plane::new(w, h, med(0)), Plane::new(w, h, med(1)));
for r in [480usize, 240, 120] {
let (bh, bl, bc) = (blur(&sh, r, 2), blur(&sl, r, 2), blur(&sc, r, 2));
let area = ((2 * r + 1) * (2 * r + 1)) as f32;
for i in 0..w * h { if bc.d[i] * area >= MIN_SAMPLES { cop.d[i] = bh.d[i] / bc.d[i]; lam.d[i] = bl.d[i] / bc.d[i]; } }
}
// where one window hands over to the next the level must not step
let (mut cop, mut lam) = (blur(&cop, 40, 2), blur(&lam, 40, 2));
let floor = 0.5 * e.modal;
for i in 0..w * h { if cop.d[i] - lam.d[i] < floor { let m = (cop.d[i] + lam.d[i]) / 2.0; cop.d[i] = m + floor / 2.0; lam.d[i] = m - floor / 2.0; } }
(cop, lam)
}
#[cfg(test)]
mod tests {
use super::*;
/// Laminate at 100 with tracks at 145 every 60 px, under a light that falls to 60 percent across the face, with a little grain.
fn face() -> (Plane, Mask) {
let (w, h) = (900usize, 400usize);
let mut p = Plane::new(w, h, 0.0);
for y in 0..h { for x in 0..w { let light = 1.0 - 0.4 * x as f32 / w as f32; let base = if y % 60 < 8 { 145.0 } else { 100.0 }; let n = ((x * 7919) ^ (y * 104729)) % 7; p.d[y * w + x] = base * light + n as f32; } }
(p, Mask::new(w, h))
}
#[test]
fn the_modal_step_is_copper_over_laminate() {
let (p, m) = face();
let e = step_edges(&p, &m).unwrap();
assert!((e.modal - (145.0f32 / 100.0).ln()).abs() < 0.08, "modal step {}", e.modal);
assert!(e.at.len() > 2000, "{}", e.at.len());
}
#[test]
fn levels_that_put_the_laminate_in_the_copper_class_are_caught_and_the_edges_give_better_ones() {
let (p, m) = face();
let e = step_edges(&p, &m).unwrap();
// what the two-class fit did on the hub board: the copper level on the laminate, the laminate level on something darker
let (bad_c, bad_l) = (Plane::new(p.w, p.h, (80.0f32).ln()), Plane::new(p.w, p.h, (45.0f32).ln()));
let a = agreement(&e, &bad_c, &bad_l, 0.5);
assert!(a.levels_are_wrong() && a.both_copper > 0.5, "{a:?}");
let (c, l) = levels(&e);
let b = agreement(&e, &c, &l, 0.5);
assert!(!b.levels_are_wrong() && b.straddle > 0.9, "{b:?}");
// the levels follow the light: at both ends the track reads copper and the laminate does not
for x in [100usize, 800] { let (i, j) = (2 * p.w + x, 30 * p.w + x); let v = |k: usize| ((p.d[k].ln()) - l.d[k]) / (c.d[k] - l.d[k]); assert!(v(i) > 0.5 && v(j) < 0.5, "x {x}: track {} laminate {}", v(i), v(j)); }
}
}