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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
//! The copper and laminate levels judged place by place against the face's own step edges, and grain kept out of those edges.
//!
//! `edgelevels` tests a pair of levels against the face's copper edges over the face as a whole, and replaces them only when over half of all edges lie wholly in
//! one class. A stitched face of a larger board is two faces in one: on test board 1's bottom (2026-11-10) the left half is laminate with thin tracks and the right
//! half is wide pours. The two-class fit put the copper level on the laminate over the left half, the pours on the right straddled their levels as they should, and
//! the face as a whole passed. One island then held 117 of the 148 pins joined on that face. The same was written down for the hub board's bottom right corner.
//!
//! Two things here, both decided by the face's own evidence:
//!
//! - `without_grain`: on a stitched face the dim, soft ends of a tile carry sensor grain whose crests join into one network, so the test for a run of crest pixels
//! keeps them, and there are more of them than copper edges. The step that occurs most often was then the grain's (0.06 on board 1's bottom, where copper over
//! laminate steps 0.15 to 0.35). A copper edge goes on in line: its crest is found again a little further along its own direction, on both sides, with the gradient
//! facing the same way. When the commonest step among such edges is not the commonest step among all of them, the straight ones are the face's copper edges.
//! - `correct`: the shares of `edgelevels::agreement`, counted in a window round every place. Where a majority of the edges near a place lie wholly in one class,
//! the levels there are taken from those edges; where the edges straddle the levels, nothing changes.
use crate::edgelevels::StepEdges;
use crate::raster::{blur, Plane};
/// How far along its own direction a crest must be found again, each way, frame pixels: 6 px, 0.15 mm at 40 px/mm. Not fitted: longer than the 1 to 3 px over which
/// grain keeps a direction, shorter than the straight part of any track the frame resolves.
const ALONG_PX: f64 = 6.0;
/// The gradient at the crest found again must face the same way: within 26 degrees (cosine 0.9). Not fitted. A track bends by 45 degrees at a corner, and the crest
/// either side of the corner is then dropped, which costs a few samples per corner.
const SAME_WAY: f32 = 0.9;
/// The bins of `edgelevels`' own histogram of steps.
const BIN: f32 = 0.025;
/// Window of the local test, half width in frame pixels: 6 mm at 40 px/mm, the middle of the three windows `edgelevels::levels` averages over.
const WINDOW_PX: usize = 240;
/// Fewer in-band edge samples than this in the window and the place is left alone: the count `edgelevels` asks of a window before it trusts its mean.
const MIN_SAMPLES: f32 = 60.0;
/// A majority of the edges near a place wholly in one class: the same line `edgelevels::Agreement::levels_are_wrong` draws for a face.
const WRONG_SHARE: f32 = 0.5;
/// The step that occurs most often, as `edgelevels::step_edges` finds it.
pub fn modal(all: &[(u32, u32, f32, f32)]) -> Option<f32> {
let mut hist = [0f32; 40];
for e in all { let s = e.2 - e.3; if s > 0.0 && s < 1.0 { hist[(s / BIN) 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())?;
Some((peak as f32 + 0.5) * BIN)
}
/// The edges that go on in line, and whether each of `all` is one. `gx`, `gy`: the gradient the crests were found on.
pub fn straight(all: &[(u32, u32, f32, f32)], gx: &Plane, gy: &Plane) -> Vec<bool> {
let (w, h) = (gx.w, gx.h);
let mut at = vec![false; w * h]; for e in all { at[e.1 as usize * w + e.0 as usize] = true; }
let unit = |i: usize| -> (f32, f32) { let g = (gx.d[i] * gx.d[i] + gy.d[i] * gy.d[i]).sqrt().max(1e-9); (gx.d[i] / g, gy.d[i] / g) };
all.iter().map(|e| {
let i = e.1 as usize * w + e.0 as usize; let (ux, uy) = unit(i);
[1.0f64, -1.0].iter().all(|s| {
let (px, py) = ((e.0 as f64 - s * uy as f64 * ALONG_PX).round() as i64, (e.1 as f64 + s * ux as f64 * ALONG_PX).round() as i64);
(-1i64..=1).any(|dy| (-1i64..=1).any(|dx| { let (x, y) = (px + dx, py + dy); if x < 0 || y < 0 || x >= w as i64 || y >= h as i64 { return false; } let j = y as usize * w + x as usize; at[j] && { let (vx, vy) = unit(j); ux * vx + uy * vy > SAME_WAY } }))
})
}).collect()
}
/// `all` with its commonest step `modal_all`, or only its straight edges with theirs when the two steps differ by more than one bin: then the commonest step was grain's.
/// A face whose two steps agree keeps every edge, exactly as before this test existed.
pub fn without_grain(all: Vec<(u32, u32, f32, f32)>, modal_all: f32, gx: &Plane, gy: &Plane) -> (Vec<(u32, u32, f32, f32)>, f32, bool) {
let keep = straight(&all, gx, gy);
let line: Vec<(u32, u32, f32, f32)> = all.iter().zip(&keep).filter(|x| *x.1).map(|x| *x.0).collect();
if line.len() < 500 { return (all, modal_all, false); }
match modal(&line) { Some(m) if (m - modal_all).abs() > BIN * 1.01 => (line, m, true), _ => (all, modal_all, false) }
}
/// How far each side of an edge is looked at to say which side a place lies on, frame pixels: inside the 2 to 7 px `edgelevels` samples a step over.
const SIDE_REACH: f32 = 5.0;
/// The window a level is averaged over once every place gives a sample, half width in frame pixels: 1.5 mm at 40 px/mm. Wide enough that a track the edge finder
/// missed (0.15 mm) does not carry the laminate level up with it, narrow against the 8 mm tiles `trace::light_spread` measures the light over. Not fitted.
const SIDE_WINDOW: usize = 60;
/// A place counts as across an edge, and not along it, when the line to it stands at least 30 degrees off the edge's own line (the cosine to the direction of rise is 0.5 or more). Not fitted.
const ACROSS_MIN: f32 = 0.5;
/// How far from a hole, a part or exposed metal an edge must lie to say which side of it is copper, frame pixels: 0.5 mm at 40 px/mm. The shadow rim round the solder joints
/// of test board 1's bottom is 5 to 10 px wide and its outer edge blurs over 4 more; at 12 px the rims still passed. From that photo.
const SOLID_CLEAR_PX: f32 = 20.0;
/// A window needs this share of its pixels seen to give a level; under it the level is the edges' own window mean.
const SIDE_MIN: f32 = 0.02;
/// The two levels over the face with every seen place giving a sample, for a face whose light moves.
///
/// `edgelevels::levels` averages the two sides of the edges over windows of 3 to 12 mm. Under a light with a hot spot that is too coarse: on test board 1's bottom the bare
/// laminate under the hot spot, 5 mm from the nearest edge, is brighter than the copper level averaged from the edges around it, so it read as copper and joined three
/// pours. But the edges say more than two numbers each: every place lies on one side of its nearest step edge, the bright side or the dark side, and nothing lies between
/// a place and its nearest edge. So the place is of that side's class, and its own brightness is a sample of that class's level right where it is. The levels are those
/// samples, each moved half the edges' step towards the middle, averaged over 1.5 mm, which follows the light as closely as the photo shows it. Where the edge finder missed a boundary the places beyond it take the wrong
/// side; that is the same error as before, no worse.
///
/// `solid`: holes, parts, exposed metal and wide print. An edge within `SOLID_CLEAR_PX` of one does not say which side is copper: a solder joint and a hole sit in a dark rim of shadow,
/// whose outer edge has bare laminate on its bright side (test board 1's bottom: the laminate inside a ring of header pins took the pins' rims for copper edges and read as a pour).
pub fn levels_by_side(e: &StepEdges, lg: &Plane, unseen: &crate::raster::Mask, solid: &crate::raster::Mask) -> (Plane, Plane) {
let (w, h) = (e.w, e.h);
let (ec, el) = crate::edgelevels::levels(e);
if e.at.is_empty() { return (ec, el); }
// the nearest edge sample to every pixel (two-pass chamfer sweep)
let mut d = vec![f32::MAX; w * h]; let mut ix = vec![u32::MAX; w * h];
let beside = solid.dilate(SOLID_CLEAR_PX);
for (k, s) in e.at.iter().enumerate() { let i = s.1 as usize * w + s.0 as usize; if beside.d[i] { continue; } d[i] = 0.0; ix[i] = k as u32; }
let relax = |i: usize, j: usize, cost: f32, d: &mut Vec<f32>, ix: &mut Vec<u32>| { if d[j] + cost < d[i] { d[i] = d[j] + cost; ix[i] = ix[j]; } };
for y in 0..h { for x in 0..w { let i = y * w + x;
if x > 0 { relax(i, i - 1, 3.0, &mut d, &mut ix); }
if y > 0 { relax(i, i - w, 3.0, &mut d, &mut ix); if x > 0 { relax(i, i - w - 1, 4.0, &mut d, &mut ix); } if x + 1 < w { relax(i, i - w + 1, 4.0, &mut d, &mut ix); } } } }
for y in (0..h).rev() { for x in (0..w).rev() { let i = y * w + x;
if x + 1 < w { relax(i, i + 1, 3.0, &mut d, &mut ix); }
if y + 1 < h { relax(i, i + w, 3.0, &mut d, &mut ix); if x + 1 < w { relax(i, i + w + 1, 4.0, &mut d, &mut ix); } if x > 0 { relax(i, i + w - 1, 4.0, &mut d, &mut ix); } } } }
// Each place gives one sample of the level half way between the two: its own brightness less half the step when it lies on the bright side of its nearest edge, plus half the step
// on the dark side. The step itself is the edges' own (`edgelevels::levels`). Averaging both sides into one mid level, and not each side into its own, is what keeps a false edge
// harmless: a scratch across bare laminate puts a few places on the "bright side" with the laminate's brightness, and they move the mid level by their share of the window,
// where a level of their own would have sat on the laminate and split its grain in two.
let floor = 0.5 * e.modal;
let at = |x: f32, y: f32| -> f32 { lg.d[(y.round().clamp(0.0, h as f32 - 1.0) as usize) * w + x.round().clamp(0.0, w as f32 - 1.0) as usize] };
let (mut sm, mut wm) = (Plane::new(w, h, 0.0), Plane::new(w, h, 0.0));
// the direction brightness rises in across each edge sample, a unit vector, from the smoothed brightness `SIDE_REACH` either way
let rise: Vec<(f32, f32)> = e.at.iter().map(|s| { let (x, y) = (s.0 as f32, s.1 as f32); let (a, b) = (at(x + SIDE_REACH, y) - at(x - SIDE_REACH, y), at(x, y + SIDE_REACH) - at(x, y - SIDE_REACH)); let n = (a * a + b * b).sqrt().max(1e-9); (a / n, b / n) }).collect();
for y in 0..h { for x in 0..w { let i = y * w + x; if unseen.d[i] || ix[i] == u32::MAX { continue; }
let s = e.at[ix[i] as usize]; let (dx, dy) = (x as f32 - s.0 as f32, y as f32 - s.1 as f32); let n = (dx * dx + dy * dy).sqrt();
// on the edge itself a place is of neither side
if n < 2.0 { continue; }
// Which side: by the edge's own direction of rise. A place that lies along the edge's line and not across it, past the end of a broken edge, is of neither side: the
// nearest sample to such a place is the edge's end, and what lies beyond an end the edge does not say (first tried without this: fans of bare laminate off the ends of
// faint edges read as copper on test board 1's bottom).
let (gx, gy) = rise[ix[i] as usize]; let across = (dx * gx + dy * gy) / n;
if across.abs() < ACROSS_MIN { continue; }
let half = (ec.d[i] - el.d[i]).max(floor) / 2.0;
sm.d[i] = if across > 0.0 { lg.d[i] - half } else { lg.d[i] + half }; wm.d[i] = 1.0; } }
if let Ok(dir) = std::env::var("TRACE_DUMP") { let im = image::RgbImage::from_fn(w as u32, h as u32, |x, y| { let i = y as usize * w + x as usize; if d[i] == 0.0 { image::Rgb([255, 255, 255]) } else if wm.d[i] == 0.0 { image::Rgb([0, 0, 0]) } else if sm.d[i] < lg.d[i] { image::Rgb([230, 150, 40]) } else { image::Rgb([30, 90, 60]) } }); let _ = im.save(format!("{dir}/sides.png")); }
let (bm, bw) = (blur(&sm, SIDE_WINDOW, 2), blur(&wm, SIDE_WINDOW, 2));
let mut mid = Plane::new(w, h, 0.0);
for i in 0..w * h { mid.d[i] = if bw.d[i] > SIDE_MIN { bm.d[i] / bw.d[i] } else { (ec.d[i] + el.d[i]) / 2.0 }; }
let mid = blur(&mid, 20, 2);
let (mut cop, mut lam) = (ec.clone(), el.clone());
for i in 0..w * h { let half = (ec.d[i] - el.d[i]).max(floor) / 2.0; cop.d[i] = mid.d[i] + half; lam.d[i] = mid.d[i] - half; }
(cop, lam)
}
/// The share of a face's edges that must straddle the levels averaged from them before the side of an edge is used. From four stitched faces and one whole-board photo, 2026-11-11: 0.94 and
/// 0.80 on test board 1 (bottom, top), 0.89 on board 4's bottom and 0.92 on the hub board's top, where levels by side read as well or better by eye; 0.56 on board 4's top, a face of
/// pours under print, parts and lint, where they turned 4 of 13 labelled places of pour into laminate. The line is set between, nearer the faces that work.
pub const SIDES_NEED: f32 = 0.75;
pub struct Local { pub cop: Plane, pub lam: Plane, /// the share of the face whose levels were taken from its edges
pub share: f32 }
/// The levels with every place whose own edges contradict them taken from those edges. None when no place does.
pub fn correct(e: &StepEdges, cop: &Plane, lam: &Plane, at: f32, from_edges: &dyn Fn() -> (Plane, Plane)) -> Option<Local> {
let (w, h) = (e.w, e.h);
let (mut one, mut n) = (Plane::new(w, h, 0.0), Plane::new(w, h, 0.0));
let (mut up, mut dn) = (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; 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);
n.d[i] += 1.0; if vl >= at { up.d[i] += 1.0; one.d[i] += 1.0; } else if vh <= at { dn.d[i] += 1.0; one.d[i] += 1.0; } }
let _ = one;
let (bu, bd, bn) = (blur(&up, WINDOW_PX, 2), blur(&dn, WINDOW_PX, 2), blur(&n, WINDOW_PX, 2));
let area = ((2 * WINDOW_PX + 1) * (2 * WINDOW_PX + 1)) as f32;
let wrong = Plane { w, h, d: (0..w * h).map(|i| if bn.d[i] * area >= MIN_SAMPLES && bu.d[i].max(bd.d[i]) > WRONG_SHARE * bn.d[i] { 1.0 } else { 0.0 }).collect() };
let hit = wrong.d.iter().filter(|v| **v > 0.0).count();
if hit == 0 { return None; }
// the hand-over from one pair of levels to the other must not step: half a window of blend
let wt = blur(&wrong, WINDOW_PX / 2, 2);
let (ec, el) = from_edges();
let (mut c2, mut l2) = (cop.clone(), lam.clone());
for i in 0..w * h { let t = wt.d[i]; if t > 0.0 { c2.d[i] = t * ec.d[i] + (1.0 - t) * cop.d[i]; l2.d[i] = t * el.d[i] + (1.0 - t) * lam.d[i]; } }
Some(Local { cop: c2, lam: l2, share: hit as f32 / (w * h) as f32 })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::raster::Mask;
/// The left half laminate at 100 with thin tracks at 130, the right half pour at 130 with lanes at 100.
fn two_halves() -> Plane {
let (w, h) = (1600usize, 700usize);
let mut p = Plane::new(w, h, 0.0);
for y in 0..h { for x in 0..w { let track = y % 60 < 8; let v = if x < w / 2 { if track { 130.0 } else { 100.0 } } else if track { 100.0 } else { 130.0 }; let n = ((x * 7919) ^ (y * 104729)) % 5; p.d[y * w + x] = v + n as f32; } }
p
}
#[test]
fn levels_wrong_over_half_a_face_are_put_right_there_and_left_alone_elsewhere() {
let p = two_halves(); let e = crate::edgelevels::step_edges(&p, &Mask::new(p.w, p.h)).unwrap();
// what the fit did on board 1's bottom: right for the pours, and the copper level on the laminate over most of the half with thin tracks
let (mut c, mut l) = (Plane::new(p.w, p.h, (130.0f32).ln()), Plane::new(p.w, p.h, (100.0f32).ln()));
for y in 0..p.h { for x in 0..p.w * 2 / 5 { c.d[y * p.w + x] = (104.0f32).ln(); l.d[y * p.w + x] = (80.0f32).ln(); } }
let a = crate::edgelevels::agreement(&e, &c, &l, 0.5);
assert!(!a.levels_are_wrong(), "the face as a whole passes: {a:?}");
let fixed = correct(&e, &c, &l, 0.5, &|| crate::edgelevels::levels(&e)).expect("the left half's edges contradict its levels");
let v = |x: usize, y: usize| { let i = y * p.w + x; (p.d[i].ln() - fixed.lam.d[i]) / (fixed.cop.d[i] - fixed.lam.d[i]) };
assert!(v(200, 330) < 0.5, "laminate on the left reads as laminate: {}", v(200, 330));
assert!(v(200, 303) > 0.5, "a track on the left reads as copper: {}", v(200, 303));
assert!(v(1400, 330) > 0.5 && v(1400, 303) < 0.5, "the right half reads as before");
let i = 330 * p.w + 1500; assert!((fixed.cop.d[i] - c.d[i]).abs() < 1e-4, "far from the fault the levels are untouched");
}
#[test]
fn bare_board_under_a_hot_spot_far_from_any_edge_stays_bare() {
// laminate at 100 with a pour at 130 along the top, under a light with a hot spot in the middle of the laminate that lifts it by half
let (w, h) = (1200usize, 800usize); let mut p = Plane::new(w, h, 0.0);
for y in 0..h { for x in 0..w { let base = if y < 200 || x % 300 < 6 { 130.0 } else { 100.0 }; let r2 = ((x as f32 - 600.0).powi(2) + (y as f32 - 520.0).powi(2)) / (220.0f32 * 220.0); let light = 1.0 + 0.5 * (-r2).exp(); let n = ((x * 7919) ^ (y * 104729)) % 5; p.d[y * w + x] = base * light + n as f32; } }
let e = crate::edgelevels::step_edges(&p, &Mask::new(w, h)).unwrap();
let lg = Plane { w, h, d: p.d.iter().map(|v| v.ln()).collect() };
let (c, l) = levels_by_side(&e, &lg, &Mask::new(w, h), &Mask::new(w, h));
let v = |x: usize, y: usize| { let i = y * w + x; (lg.d[i] - l.d[i]) / (c.d[i] - l.d[i]) };
assert!(v(650, 520) < 0.5, "the hot spot on laminate reads as laminate: {}", v(650, 520));
assert!(v(650, 100) > 0.5, "the pour reads as copper: {}", v(650, 100));
assert!(v(603, 520) > 0.5, "a track through the hot spot reads as copper: {}", v(603, 520));
}
#[test]
fn levels_that_fit_are_not_touched() {
let p = two_halves(); let e = crate::edgelevels::step_edges(&p, &Mask::new(p.w, p.h)).unwrap();
let (c, l) = (Plane::new(p.w, p.h, (130.0f32).ln()), Plane::new(p.w, p.h, (100.0f32).ln()));
assert!(correct(&e, &c, &l, 0.5, &|| crate::edgelevels::levels(&e)).is_none());
}
#[test]
fn a_face_with_more_grain_than_copper_edges_still_finds_the_copper_step() {
// tracks at 135 on laminate at 100, every 120 px, and over all of it a coarse grain of 10 percent that outnumbers the track edges
let (w, h) = (1200usize, 600usize); let mut p = Plane::new(w, h, 0.0);
for y in 0..h { for x in 0..w { let base = if y % 120 < 8 { 135.0 } else { 100.0 }; let cell = ((x / 5) * 7919) ^ ((y / 5) * 104729); let n = if cell % 3 == 0 { 1.10 } else { 1.0 }; p.d[y * w + x] = base * n; } }
let e = crate::edgelevels::step_edges(&p, &Mask::new(w, h)).unwrap();
assert!((e.modal - (135.0f32 / 100.0).ln()).abs() < 0.1, "modal step {}", e.modal);
}
}