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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
//! The solder mask's colour, measured on each photo, and the tests that depend on it.
//!
//! The readers were written on green boards: copper was "the green channel", a part or gold was "red over green", mask was "green over red". A red or a blue mask breaks
//! all three. This module finds the mask's hue from the photo, picks the channel copper is read in, and says whether a pixel has a warm colour that is not the mask's.
//! A green mask (the bracket `prep::mask_tone` has always used, 65 to 200 degrees) keeps the channel and the tests the readers were developed with, to the letter, so
//! nothing measured on the Driver, the CM5 or the hub board moves.
use image::RgbImage;
#[derive(Clone, Copy, Debug)]
pub struct MaskColour {
/// the peak of the hue histogram of the photo's coloured pixels, degrees
pub hue: f32,
/// the share of the photo within `NEAR_DEG` of that hue
pub share: f32,
/// median red, green and blue of those pixels, 8 bit
pub rgb: [f32; 3],
/// the hue lies in the green family
pub green: bool,
/// the channel copper under the mask is read in: 0 red, 1 green, 2 blue
pub measure: usize,
}
/// A pixel counts as coloured from this saturation and value up: grey parts, print, solder and black bodies fall below.
const MIN_SAT: f32 = 0.25;
const MIN_VAL: f32 = 0.10;
/// How far round the peak the mask's own hue reaches. A red and a blue mask (test boards 7 and 8, close-up tiles) hold 90 percent of their coloured pixels within 15 degrees of the peak.
pub const NEAR_DEG: f32 = 25.0;
/// The channel copper is read in must sit below this at the mask's median, 8 bit. A camera's tone curve flattens towards white, and a mask's own channel can sit in the
/// flat part: on the blue board the blue channel has a median of 190 to 207 and shows copper over laminate as a step of 0.06 to 0.11 in log brightness, where the green
/// channel at 82 to 114 shows 0.24 to 0.44. On the red board the red channel is clipped at 255 over 16 to 18 percent of the mask. From tiles Board7.1, 7.3, 8.1 and 8.5.
const MEASURE_MAX_MEDIAN: f32 = 160.0;
fn hue_gap(a: f32, b: f32) -> f32 { let d = (a - b).rem_euclid(360.0); d.min(360.0 - d) }
/// Measure the mask's colour. The photo should be the board's own frame: a coloured table in view counts as mask if it is the larger.
pub fn of(img: &RgbImage) -> MaskColour {
let step = ((img.width() as usize * img.height() as usize) / 400_000).max(1);
// the same floor `prep::mask_tone` has always used for what counts as coloured, so a dim green mask (value 0.09 in a dark corner) is found as it was
let px: Vec<(f32, [u8; 3])> = img.pixels().step_by(step).filter_map(|p| { let (h, s, v) = crate::raster::hsv(p); if s > 0.18 && v > 0.07 { Some((h, [p[0], p[1], p[2]])) } else { None } }).collect();
let total = (img.width() as usize * img.height() as usize / step).max(1);
let mut hist = [0f32; 72];
for (h, _) in &px { hist[((h / 5.0) as usize).min(71)] += 1.0; }
// smoothed round the circle, so a peak that straddles 0 degrees (red) is one peak
let sm: Vec<f32> = (0..72).map(|i| (0..5).map(|k| hist[(i + 72 + k - 2) % 72]).sum::<f32>()).collect();
let peak = (0..72).max_by(|a, b| sm[*a].partial_cmp(&sm[*b]).unwrap()).unwrap_or(24);
let hue = peak as f32 * 5.0 + 2.5;
let mut ch: [Vec<u8>; 3] = [vec![], vec![], vec![]];
for (h, c) in &px { if hue_gap(*h, hue) < NEAR_DEG { for k in 0..3 { ch[k].push(c[k]); } } }
let n = ch[0].len();
let med = |v: &mut Vec<u8>| -> f32 { if v.is_empty() { return 0.0; } let k = v.len() / 2; *v.select_nth_unstable(k).1 as f32 };
let rgb = [med(&mut ch[0]), med(&mut ch[1]), med(&mut ch[2])];
// a photo with next to nothing coloured in it says nothing about the mask: the readers' own assumption, green, stands
let green = (65.0..200.0).contains(&hue) || n * 100 < total;
let measure = if green { 1 } else {
let mut order = [0usize, 1, 2]; order.sort_by(|a, b| rgb[*b].partial_cmp(&rgb[*a]).unwrap());
order.iter().copied().find(|k| rgb[*k] <= MEASURE_MAX_MEDIAN).unwrap_or(order[2])
};
MaskColour { hue, share: n as f32 / total as f32, rgb, green, measure }
}
impl MaskColour {
/// Whether a pixel is a warm colour that is not the mask's: gold plating, a tan ceramic body, bare copper, flux. `margin` is how far red must stand over green, 8 bit,
/// on a green mask, where that one difference tells them from mask (the rule the readers were developed with). On any other mask the pixel's hue has to lie in the
/// warm band, 8 to 75 degrees (tan ceramics 20 to 33, gold 40 to 55 on the Driver photos), and at least 20 degrees from the mask's own hue: a red mask sits at 355 to 10.
pub fn warm(&self, r: f32, g: f32, b: f32, margin: f32) -> bool {
if self.green { return r > g + margin; }
let (h, s, _) = crate::raster::hsv(&image::Rgb([r.clamp(0.0, 255.0) as u8, g.clamp(0.0, 255.0) as u8, b.clamp(0.0, 255.0) as u8]));
s > MIN_SAT && (8.0..75.0).contains(&h) && hue_gap(h, self.hue) >= 20.0
}
/// Whether a pixel has the mask's own hue.
pub fn is_mask(&self, r: f32, g: f32, b: f32) -> bool {
let (h, s, v) = crate::raster::hsv(&image::Rgb([r.clamp(0.0, 255.0) as u8, g.clamp(0.0, 255.0) as u8, b.clamp(0.0, 255.0) as u8]));
s > MIN_SAT && v > MIN_VAL && hue_gap(h, self.hue) < NEAR_DEG
}
}
/// "How far green stands over red", 8 bit, as `photomat` uses it to tell mask from everything else, for a mask of any colour: on a green mask it is that difference
/// itself. On another mask it is rebuilt from what the difference meant on the photos the rules were written on: 100 times the saturation for a pixel of the mask's own
/// hue (a Driver mask pixel reads 27 to 45 and up), -60 for a warm colour off the mask's hue (gold and tan read -40 to -110 there), 0 for anything else, as a neutral reads.
pub fn mask_over_red(mc: &MaskColour, r: f32, g: f32, b: f32) -> f32 {
if mc.green { return g - r; }
let (h, s, v) = crate::raster::hsv(&image::Rgb([r.clamp(0.0, 255.0) as u8, g.clamp(0.0, 255.0) as u8, b.clamp(0.0, 255.0) as u8]));
if s > MIN_SAT && v > MIN_VAL && hue_gap(h, mc.hue) < NEAR_DEG { 100.0 * s } else if s > MIN_SAT && (8.0..75.0).contains(&h) { -60.0 } else { 0.0 }
}
/// The mask's tone as `photomat` wants it. A green mask is measured as it always was. Any other: median value and luma of the pixels at the mask's hue, and a chroma
/// factor of 1, because `mask_over_red` already puts those pixels where a Driver mask pixel sits.
pub fn tone(img: &RgbImage, mc: &MaskColour) -> crate::prep::MaskTone {
if mc.green { return crate::prep::mask_tone(img); }
let step = ((img.width() as usize * img.height() as usize) / 400_000).max(1);
let (mut vs, mut ys) = (vec![], vec![]);
for p in img.pixels().step_by(step) { if mc.is_mask(p[0] as f32, p[1] as f32, p[2] as f32) { let (_, _, v) = crate::raster::hsv(p); vs.push(v); ys.push(0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32); } }
let at = |v: &mut Vec<f32>, q: f64| -> f32 { if v.is_empty() { return 0.0; } v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[((v.len() - 1) as f64 * q) as usize] };
let v50 = at(&mut vs, 0.5);
crate::prep::MaskTone { hue: mc.hue, v50, v_hi: (at(&mut vs, 0.98) * 1.1).clamp(0.62, 1.001), y50: at(&mut ys, 0.5), gr50: crate::prep::MASK_GR_READ_AT }
}
#[cfg(test)]
mod tests {
use super::*;
use image::Rgb;
fn board(mask: [u8; 3]) -> RgbImage {
// mask with a little grain, a gold pad, white print and a black body
RgbImage::from_fn(300, 200, |x, y| { let n = ((x * 7919) ^ (y * 104729)) % 9; let m = |c: u8| c.saturating_add(n as u8).saturating_sub(4);
if x < 40 && y < 40 { Rgb([205, 165, 70]) } else if x > 250 && y < 30 { Rgb([235, 235, 235]) } else if x > 200 && y > 150 { Rgb([30, 30, 32]) } else { Rgb([m(mask[0]), m(mask[1]), m(mask[2])]) } })
}
#[test]
fn a_green_mask_keeps_the_green_channel_and_the_red_over_green_rule() {
let c = of(&board([41, 130, 74]));
assert!(c.green && c.measure == 1, "{c:?}");
assert!(c.warm(120.0, 100.0, 60.0, 10.0) && !c.warm(105.0, 100.0, 60.0, 10.0));
}
#[test]
fn a_red_mask_is_found_across_zero_degrees_and_is_not_read_in_its_clipped_channel() {
let c = of(&board([233, 65, 61]));
assert!(!c.green && (c.hue < 15.0 || c.hue > 345.0), "{c:?}");
assert_eq!(c.measure, 1, "red sits at 233, past the tone curve's bend: green is next");
assert!(c.warm(205.0, 165.0, 70.0, 10.0), "gold on a red mask is warm and not mask");
assert!(!c.warm(233.0, 65.0, 61.0, 10.0), "the mask itself is not");
assert!(c.is_mask(225.0, 70.0, 66.0) && !c.is_mask(205.0, 165.0, 70.0));
}
#[test]
fn the_material_reader_finds_print_and_gold_on_a_red_and_a_blue_mask() {
use crate::photomat::{classify, GOLD, MASK, SILK};
for mask in [[200u8, 50, 45], [45, 80, 180], [41, 130, 74]] {
// mask, a printed bar 12 px wide and a gold pad 60 px square
let im = RgbImage::from_fn(400, 300, |x, y| { let n = ((x * 7919) ^ (y * 104729)) % 7; let m = |c: u8| c.saturating_add(n as u8);
if (100..300).contains(&x) && (60..72).contains(&y) { Rgb([232, 236, 244]) } else if (150..210).contains(&x) && (180..240).contains(&y) { Rgb([215, 170, 60]) } else { Rgb([m(mask[0]), m(mask[1]), m(mask[2])]) } });
let f = classify(&im, false);
let at = |x: usize, y: usize| f.cls[y * 400 + x];
assert_eq!(at(200, 66), SILK, "print on mask {mask:?}");
assert_eq!(at(180, 210), GOLD, "gold on mask {mask:?}");
assert_eq!(at(50, 150), MASK, "mask {mask:?}");
}
}
#[test]
fn a_dim_green_mask_and_a_photo_with_no_colour_are_green() {
assert!(of(&RgbImage::from_pixel(200, 200, Rgb([6, 22, 12]))).green);
assert!(of(&RgbImage::from_pixel(200, 200, Rgb([90, 90, 92]))).green);
}
#[test]
fn a_blue_mask_is_read_in_green_when_blue_is_near_the_top() {
let c = of(&board([65, 114, 207]));
assert!(!c.green && (c.hue - 220.0).abs() < 12.0, "{c:?}");
assert_eq!(c.measure, 1);
let dim = of(&board([20, 40, 120]));
assert_eq!(dim.measure, 2, "a dim blue mask is read in blue, its brightest channel");
}
}