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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
//! A tight board outline. `prep::board_corners` takes every pixel of the green family as board, and two things outside a board pass that
//! test: the grain of a dark shadow under the board's edge (value 0.07 to 0.12, any hue) and the grain of a blue table (hue 190 to 200).
//! Closed into a solid strip they moved an edge out by 0.9 mm on a 38 mm board and by 2.3 mm on a 101 mm one. Here the mask is this photo's
//! own hue and its own darkest value, and a pixel counts only where most of its neighbours count too.
use crate::geom::P;
use crate::prep;
use crate::raster::{self, Mask, Plane};
use image::{imageops, RgbImage};
/// How far from the peak of the mask's hue a pixel may lie, degrees. Pours and bare laminate of one board lay within 20 degrees of the peak on the
/// USB hub and MOSFET board photos of 2026-11-10 (peaks 137 to 148); the blue table's grain began 43 degrees away.
pub const HUE_REACH: f32 = 30.0;
/// The floor on value, as a share of the 2nd percentile of the mask's own value. On those photos the mask's 2nd percentile was 0.14 to 0.36
/// and the shadow's grain reached 0.12 at its 99th percentile. Never under the 0.07 the wide test uses, so a dark board reads as before.
pub const VALUE_FLOOR_SHARE: f32 = 0.8;
/// A pixel is board where more than this share of the 9 x 9 pixels round it (at a quarter of the photo's size) pass: grain passes at 0.2 to 0.5, mask at 1.
pub const DENSITY: f32 = 0.6;
/// The board mask at 1/`k` of the photo's size, and the value floor used.
pub fn board_mask(img: &RgbImage, k: u32) -> (Mask, f32) {
let small = imageops::resize(img, img.width() / k, img.height() / k, imageops::FilterType::Triangle);
let tone = prep::mask_tone(img);
let near = |h: f32| (h - tone.hue).abs() < HUE_REACH;
let mut vs: Vec<f32> = small.pixels().filter_map(|p| { let (h, s, v) = raster::hsv(p); (near(h) && s > 0.18 && v > 0.07 && v < tone.v_hi).then_some(v) }).collect();
vs.sort_by(|a, b| a.partial_cmp(b).unwrap());
let p02 = if vs.is_empty() { 0.0 } else { vs[(vs.len() - 1) / 50] };
let floor = (VALUE_FLOOR_SHARE * p02).max(0.07);
let raw = raster::mask_from(&small, |p| { let (h, s, v) = raster::hsv(p); near(h) && s > 0.18 && v > floor && v < tone.v_hi });
let dens = raster::blur(&Plane { w: raw.w, h: raw.h, d: raw.d.iter().map(|b| if *b { 1.0 } else { 0.0 }).collect() }, 4, 1);
(Mask { w: raw.w, h: raw.h, d: dens.d.iter().map(|v| *v > DENSITY).collect() }.close(3.0), floor)
}
/// The four outer corners (TL, TR, BR, BL) of the board in photo pixels, from the tight mask.
pub fn corners(img: &RgbImage) -> Result<[P; 4], String> {
let k = 4u32;
prep::corners_of_mask(&board_mask(img, k).0, k)
}
/// For each edge of an outline (corner i to the next), how much solder mask lies beyond it, as a share of the outline's own area. A true edge has the table beyond it;
/// an edge fitted along parts that hide the board (a row of terminal blocks) has more board beyond it, between and round those parts.
pub fn mask_beyond(img: &RgbImage, c: &[P; 4]) -> [f64; 4] {
let k = 4u32;
let (m, _) = board_mask(img, k);
let mid = ((c[0].0 + c[1].0 + c[2].0 + c[3].0) / 4.0, (c[0].1 + c[1].1 + c[2].1 + c[3].1) / 4.0);
let area = 0.5 * ((c[2].0 - c[0].0) * (c[3].1 - c[1].1) - (c[3].0 - c[1].0) * (c[2].1 - c[0].1)).abs();
let mut n = [0f64; 4];
for y in 0..m.h { for x in 0..m.w { if !m.d[y * m.w + x] { continue; }
let p = ((x as f64 + 0.5) * k as f64, (y as f64 + 0.5) * k as f64);
let mut worst: Option<(usize, f64)> = None;
for e in 0..4 { let (a, b) = (c[e], c[(e + 1) % 4]);
let side = |q: P| ((b.0 - a.0) * (q.1 - a.1) - (b.1 - a.1) * (q.0 - a.0)) / crate::geom::dist(a, b).max(1e-9);
let d = side(p) * -side(mid).signum();
if d > BEYOND_PX && worst.map_or(true, |w| d > w.1) { worst = Some((e, d)); } }
if let Some((e, _)) = worst { n[e] += (k * k) as f64; }
} }
n.map(|v| v / area.max(1.0))
}
/// The sides of an outline (top, right, bottom, left) that lie along the photo's own edge: the board runs out of the picture there, so that side is where the
/// photo stops and not where the board does. Both ends of the side within `CUT_PX` of one border of the photo.
pub fn cut_sides(c: &[P; 4], w: f64, h: f64) -> Vec<&'static str> {
let at = |p: P| [p.1 < CUT_PX, p.0 > w - CUT_PX, p.1 > h - CUT_PX, p.0 < CUT_PX];
["top", "right", "bottom", "left"].into_iter().enumerate().filter(|(e, _)| { let (a, b) = (at(c[*e]), at(c[(*e + 1) % 4])); a[*e] && b[*e] }).map(|x| x.1).collect()
}
/// How near the photo's border an outline's side must run to be the photo's edge, photo pixels: the mask is made at a quarter of the photo's size and closed by 3 of those pixels.
pub const CUT_PX: f64 = 16.0;
/// Mask counts as beyond an edge from this far out, photo pixels: about 0.5 mm at the 65 to 77 px/mm of the 2026-11-10 sets, past a rounded corner and a ragged edge.
pub const BEYOND_PX: f64 = 32.0;
/// An edge is not the board's when more than this share of the outline's area is mask beyond it. Terminal blocks over one edge left 7 percent beyond the fitted line; true edges under 0.1.
pub const OPEN_EDGE_SHARE: f64 = 0.01;
#[cfg(test)]
mod tests {
use super::*;
use image::Rgb;
#[test]
fn a_shadow_and_a_blue_table_are_not_board() {
// a board 100..700 x 80..500 on a blue table whose grain passes the wide green test in places, and a dark grainy shadow 20 px deep under its lower edge
let mut seed = 4242u64; let mut next = move || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); (seed >> 33) % 100 };
let img = RgbImage::from_fn(800, 600, |x, y| {
let r = next();
if (100..700).contains(&x) && (80..500).contains(&y) { Rgb([40, 150, 90]) }
else if (90..710).contains(&x) && (500..520).contains(&y) { if r < 45 { Rgb([8, 26, 20]) } else { Rgb([6, 8, 14]) } }
else if r < 30 { Rgb([40, 190, 200]) } else { Rgb([40, 110, 200]) }
});
let wide = prep::board_corners(&img).unwrap();
let tight = corners(&img).unwrap();
assert!(wide[2].1 > 512.0, "the wide test should take the shadow: {wide:?}");
for (c, t) in tight.iter().zip([(100.0, 80.0), (700.0, 80.0), (700.0, 500.0), (100.0, 500.0)]) { assert!(crate::geom::dist(*c, t) < 6.0, "{c:?} vs {t:?}"); }
}
#[test]
fn a_board_that_runs_out_of_the_photo_has_cut_sides() {
// the board fills the photo from left to right; its upper and lower edges are in the picture
let mut img = RgbImage::from_pixel(800, 600, Rgb([40, 110, 200]));
for y in 100..500 { for x in 0..800 { img.put_pixel(x, y, Rgb([40, 150, 90])); } }
let c = corners(&img).unwrap();
assert_eq!(cut_sides(&c, 800.0, 600.0), vec!["right", "left"], "{c:?}");
let whole = [(100.0, 80.0), (700.0, 80.0), (700.0, 500.0), (100.0, 500.0)];
assert!(cut_sides(&whole, 800.0, 600.0).is_empty());
}
#[test]
fn a_dark_board_keeps_its_outline() {
// the Driver's exposure: mask value 0.2, where the floor stays at 0.07
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([14, 52, 36])); } }
let (_, floor) = board_mask(&img, 4);
assert!(floor < 0.17, "{floor}");
let c = corners(&img).unwrap();
assert!(crate::geom::dist(c[0], (100.0, 80.0)) < 6.0 && crate::geom::dist(c[2], (700.0, 520.0)) < 6.0, "{c:?}");
}
}