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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
//! What one photo's own surfaces look like, so that no reader has to carry another board's numbers: the board surface (solder mask)
//! by the photo's own dominant hue, and the level of its bright neutral things (solder, tin, white print). Hue, saturation and value
//! are OpenCV's 8-bit ones (H 0..180, S and V 0..255), as `passives::hsv8` gives them.
/// A pixel counts as coloured, for finding the board's hue, above this saturation and value. 46 of 255 is the 0.18 that `prep::mask_tone`
/// uses for the same purpose, 18 of 255 its 0.07.
const COLOURED: (u8, u8) = (46, 18);
/// Half-width of the board's hue window, OpenCV hue units (24 degrees). On the CM5 (both faces), Driver top and board 10 top photos
/// the window holds the solder mask over copper and over bare laminate and leaves tan ceramic, gold and solder out. Read on those
/// four photos, 2026-11-10; a board whose mask spans more hue than this needs a wider one.
pub const HUE_WINDOW: i32 = 12;
/// The board's saturation floor as a share of the 10th percentile of its own saturation: paler than half of that is print, tin or
/// a pale part, even at the board's hue. Never under 30 of 255, where hue stops meaning anything.
const SAT_FLOOR: f64 = 0.5;
/// The board is no brighter than this many times the 98th percentile of its own value (the factor `prep::mask_tone` uses): solder
/// that takes the board's tint, as on the CM5's yellow-green bottom face, and bright plastic of the board's hue are brighter.
const BRIGHTEST: f64 = 1.1;
/// A pixel is neutral, for finding the bright level, under this saturation (50 of 255).
const NEUTRAL: u8 = 50;
#[derive(Clone, Debug)]
pub struct Tone {
/// peak of the hue histogram of the coloured pixels, smoothed over 11 hue units
pub hue: u8,
/// 10th percentile of saturation of the pixels within the hue window, and the brightest value still board
pub s10: f64, pub ceiling: f64,
/// the bright neutral level: median value of the brighter of the two groups Otsu's threshold makes of the neutral pixels that are
/// not board. 200 when the photo has none.
pub bright: f64,
/// per pixel: board surface
pub board: Vec<bool>,
}
/// Distance between two hues on the circle of 180.
pub fn hue_dist(a: u8, b: u8) -> i32 { ((a as i32 - b as i32 + 90).rem_euclid(180) - 90).abs() }
/// Otsu's threshold of a 256-bin histogram: the level `k` that splits it into `<= k` and `> k` with the largest between-class variance.
pub fn otsu(hist: &[u64; 256]) -> usize {
let tot: u64 = hist.iter().sum(); if tot == 0 { return 0; }
let all: f64 = hist.iter().enumerate().map(|(i, n)| i as f64 * *n as f64).sum();
let (mut w0, mut s0, mut best, mut at) = (0f64, 0f64, -1f64, 0usize);
for k in 0..255 {
w0 += hist[k] as f64; s0 += k as f64 * hist[k] as f64; let w1 = tot as f64 - w0;
if w0 == 0.0 || w1 == 0.0 { continue; }
let d = s0 / w0 - (all - s0) / w1; let var = w0 * w1 * d * d;
if var > best { best = var; at = k; }
}
at
}
/// The value at quantile `q` of a 256-bin histogram.
fn quantile(hist: &[u64; 256], q: f64) -> f64 {
let tot: u64 = hist.iter().sum(); if tot == 0 { return 0.0; }
let want = (q * tot as f64).ceil().max(1.0) as u64; let mut c = 0;
for (i, n) in hist.iter().enumerate() { c += n; if c >= want { return i as f64; } }
255.0
}
/// Measure a photo given as 8-bit HSV per pixel.
pub fn of(hsv: &[[u8; 3]]) -> Tone {
let coloured = |p: &[u8; 3]| p[1] > COLOURED.0 && p[2] > COLOURED.1;
let mut hist = [0u64; 180];
for p in hsv { if coloured(p) { hist[p[0] as usize % 180] += 1; } }
// of windows that hold the same count, the one whose own hue is the fullest
let hue = (0..180).max_by_key(|h| ((-5..=5).map(|d| hist[(*h as i32 + d).rem_euclid(180) as usize]).sum::<u64>(), hist[*h as usize])).unwrap_or(60) as u8;
let mut sh = [0u64; 256];
for p in hsv { if coloured(p) && hue_dist(p[0], hue) <= HUE_WINDOW { sh[p[1] as usize] += 1; } }
let s10 = quantile(&sh, 0.10);
let floor = (SAT_FLOOR * s10).max(30.0);
let mut vh = [0u64; 256];
for p in hsv { if coloured(p) && hue_dist(p[0], hue) <= HUE_WINDOW { vh[p[2] as usize] += 1; } }
let ceiling = BRIGHTEST * quantile(&vh, 0.98);
let board: Vec<bool> = hsv.iter().map(|p| hue_dist(p[0], hue) <= HUE_WINDOW && p[1] as f64 >= floor && p[2] > COLOURED.1 && p[2] as f64 <= ceiling).collect();
let mut vh = [0u64; 256];
for (p, b) in hsv.iter().zip(&board) { if !*b && p[1] < NEUTRAL && p[2] > 0 { vh[p[2] as usize] += 1; } }
let k = otsu(&vh); let mut hi = [0u64; 256];
for v in k + 1..256 { hi[v] = vh[v]; }
let bright = if hi.iter().sum::<u64>() == 0 { 200.0 } else { quantile(&hi, 0.5) };
Tone { hue, s10, ceiling, bright, board }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn otsu_splits_two_groups() {
let mut h = [0u64; 256]; h[40] = 100; h[45] = 80; h[200] = 60; h[210] = 90;
let k = otsu(&h); assert!((45..200).contains(&k), "{k}");
assert_eq!(otsu(&[0u64; 256]), 0);
}
#[test]
fn the_board_is_the_photos_own_hue() {
// a blue board (hue 110) with pale print, a tan body and grey solder: the same reader finds the blue as board
let mut px = vec![[110u8, 200, 120]; 900];
px.extend(vec![[112u8, 150, 90]; 300]); px.extend(vec![[0u8, 5, 240]; 200]); px.extend(vec![[15u8, 130, 190]; 100]); px.extend(vec![[0u8, 10, 60]; 100]);
let t = of(&px);
assert!(hue_dist(t.hue, 110) <= 2, "{}", t.hue);
assert!(t.board[..1200].iter().all(|b| *b) && t.board[1200..].iter().all(|b| !*b));
assert_eq!(t.bright, 240.0);
assert_eq!(hue_dist(2, 178), 4);
// a green board reads the same way, with nothing changed
let mut g = vec![[70u8, 180, 110]; 500]; g.extend(vec![[0u8, 0, 210]; 100]);
let t = of(&g); assert_eq!((t.hue, t.bright), (70, 210.0));
}
}