main
Caleb Publish 0.5.0 1984b40
//! Reading copper through a solder mask that is not green: which channel the copper shows in, and what on the face is not board at all.
//!
//! `maskcolour` finds the mask's hue and picks the brightest channel that is clear of the tone curve's bend. On the red board of the test set (board 7) that rule lands on
//! green or blue, whose median is 30 to 50 of 255: the camera's colour noise is as large there as the step from laminate to copper, pours break into grain and the
//! clearances round tracks are lost. The red channel shows the same step at a tenth of the grain. Which channel carries the copper depends on what the pigment lets
//! through and on what copper reflects, so it is measured on the photo: the structure the plain mask shows in each channel against that channel's grain.
//!
//! The second thing a green-only rule misses is a part whose colour is neither grey nor warm. The reader's test for a dark body, "value under 0.30 and saturation under
//! 0.42", was written where every dark thing with colour in it was green mask in shadow. On a red board a black chip under a blue-white light reads dark teal, saturation
//! 0.5, and was traced as copper. A pixel with colour whose hue lies far from the mask's is not board, whatever its brightness.
use crate::maskcolour::MaskColour;
use crate::raster::{blur, Mask, Plane};
use image::RgbImage;

/// A coloured pixel this far from the mask's hue, degrees, is not board. The red mask's own pixels (board 7, both faces) lie within 30 degrees of its peak, pink print rims
/// and highlights included; the blue mask's purple track rims (board 8 bottom) reach 48 degrees. The black chips on board 7 read 170 to 200 degrees from the mask.
pub const OFF_HUE_DEG: f32 = 60.0;
/// Colour enough for a hue to mean something: the floor `maskcolour` uses for the same purpose.
const OFF_SAT: f32 = 0.25;
/// The measured channel replaces the rule's only when it is this much better by `scores`. Board 7: red scores 1.73 and 1.45 (top, bottom) against 0.95 and 0.58 for the
/// rule's channel, 1.8 and 2.5 times. Board 8: the rule's channel, green, is best on the top (0.93 against 0.73 and 0.86) and within 1.2 times of the best on the bottom,
/// where red (0.86 against 0.74) owes its structure to the pale rims of tracks and shows a pour and bare laminate alike. So the bar sits between 1.2 and 1.8.
const SWITCH: f32 = 1.5;

fn hue_gap(a: f32, b: f32) -> f32 { let d = (a - b).rem_euclid(360.0); d.min(360.0 - d) }

/// Whether a pixel has a colour that cannot be this mask's: a tinted body, a coloured connector, the table. Never true on a green mask, whose readers have their own rules.
pub fn not_board(mc: &MaskColour, r: f32, g: f32, b: f32) -> bool {
    if mc.green { return false; }
    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 > OFF_SAT && v > 0.06 && hue_gap(h, mc.hue) >= OFF_HUE_DEG
}

/// For each channel: how much structure the plain mask shows in it (5th to 95th percentile of the log brightness between 2 and 12 px of smoothing) over its grain
/// (the same spread of the log brightness against its own 1 px smoothing). Zero where the photo has too little plain mask to say.
pub fn scores(img: &RgbImage, mc: &MaskColour) -> [f32; 3] {
    let (w, h) = (img.width() as usize, img.height() as usize);
    let plain = Mask { w, h, d: img.pixels().map(|p| mc.is_mask(p[0] as f32, p[1] as f32, p[2] as f32)).collect() }.erode(7.0);
    let at: Vec<usize> = (0..w * h).step_by(5).filter(|i| plain.d[*i]).collect();
    if at.len() < 2000 { return [0.0; 3]; }
    let spread = |v: &mut Vec<f32>| -> f32 { v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[(v.len() - 1) * 95 / 100] - v[(v.len() - 1) * 5 / 100] };
    let mut out = [0f32; 3];
    for c in 0..3 {
        let l = Plane { w, h, d: img.pixels().map(|p| (p[c] as f32).max(1.0).ln()).collect() };
        let (fine, mid, wide) = (blur(&l, 1, 2), blur(&l, 2, 3), blur(&l, 12, 3));
        let mut structure: Vec<f32> = at.iter().map(|i| mid.d[*i] - wide.d[*i]).collect();
        let mut grain: Vec<f32> = at.iter().map(|i| l.d[*i] - fine.d[*i]).collect();
        out[c] = spread(&mut structure) / spread(&mut grain).max(1e-3);
    }
    out
}

/// The fitted levels may be this many times further apart than the step the face's own copper edges show most often, before they are taken from the edges instead.
/// A face that is nearly all pour with thin clearances gives the two-class fit almost no laminate, and its lower level lands on shadow: board 7 top, levels 0.49 apart
/// in log brightness at the edges where the edges step by 0.21, 2.3 times (and 5.0 times on the bottom, which the older test catches too). Read from the edges, the
/// clearances round the tracks beside the top header come out as lines where the fit gave one dark blob. On board 8 the ratio is 0.58 and 0.64 (top, bottom): levels
/// closer than the step. Taking those from the edges was tried and turned most of the top face into laminate, because the strongest edges there are the pale rims of
/// tracks, so only the wide side is acted on. The bar, 1.6, lies between 0.64 and 2.3. Applied to masks that are not green only; the green boards keep the test they
/// were developed with (`edgelevels::Agreement`).
const STEP_RATIO: f32 = 1.6;

/// Whether the two levels sit much further apart than the face's own copper edges step (median over the edges).
pub fn separation_is_off(e: &crate::edgelevels::StepEdges, cop: &Plane, lam: &Plane, debug: bool) -> bool {
    if e.at.len() < 500 { return false; }
    let mut sep: Vec<f32> = e.at.iter().map(|(x, y, _, _)| { let i = *y as usize * e.w + *x as usize; cop.d[i] - lam.d[i] }).collect();
    let k = sep.len() / 2; let med = *sep.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap()).1;
    let ratio = med / e.modal.max(1e-3);
    if debug { eprintln!("levels {med:.3} apart at the edges, the edges step by {:.3}: ratio {ratio:.2}", e.modal); }
    ratio > STEP_RATIO
}

/// The mask colour with the channel copper is read in settled on the photo. A green mask is returned as it came.
pub fn with_channel(img: &RgbImage, mc: MaskColour, debug: bool) -> MaskColour {
    if mc.green { return mc; }
    let s = scores(img, &mc);
    let best = (0..3).max_by(|a, b| s[*a].partial_cmp(&s[*b]).unwrap()).unwrap_or(mc.measure);
    let measure = if s[best] >= SWITCH * s[mc.measure] && s[mc.measure] > 0.0 { best } else { mc.measure };
    if debug { eprintln!("structure over grain by channel {:.2} {:.2} {:.2}: read in channel {measure} (the rule alone gives {})", s[0], s[1], s[2], mc.measure); }
    MaskColour { measure, ..mc }
}

#[cfg(test)]
mod tests {
    use super::*;
    use image::Rgb;
    /// A red mask whose copper shows as a clean step in red and as a step buried in grain in green and blue.
    fn red_board() -> RgbImage {
        RgbImage::from_fn(400, 300, |x, y| { let copper = (x / 40) % 2 == 0; let n = (((x * 7919) ^ (y * 104729)) % 23) as i32 - 11;
            let (r, g, b) = if copper { (200, 36, 30) } else { (172, 26, 24) };
            Rgb([(r + n / 6).clamp(0, 255) as u8, (g + n).clamp(1, 255) as u8, (b + n).clamp(1, 255) as u8]) })
    }
    #[test]
    fn a_red_mask_is_read_in_red_when_red_shows_the_copper_cleanly() {
        let im = red_board();
        let mc = crate::maskcolour::of(&im);
        assert!(!mc.green && mc.measure != 0, "the rule alone avoids the mask's own channel: {mc:?}");
        let s = scores(&im, &mc);
        assert!(s[0] > 1.5 * s[1] && s[0] > 1.5 * s[2], "{s:?}");
        assert_eq!(with_channel(&im, mc, false).measure, 0);
    }
    #[test]
    fn levels_far_wider_than_the_edges_step_are_caught() {
        let e = crate::edgelevels::StepEdges { w: 100, h: 100, at: (0..600).map(|k| ((k % 90 + 5) as u32, (k / 90 + 5) as u32, 5.3, 5.1)).collect(), modal: 0.2, measured: 600 };
        let lv = |c: f32, l: f32| (Plane::new(100, 100, c), Plane::new(100, 100, l));
        let (c, l) = lv(5.4, 4.8); assert!(separation_is_off(&e, &c, &l, false), "0.6 apart where the edges step 0.2");
        let (c, l) = lv(5.32, 5.1); assert!(!separation_is_off(&e, &c, &l, false));
        let (c, l) = lv(5.2, 5.12); assert!(!separation_is_off(&e, &c, &l, false), "closer than the step is left to the older test");
    }
    #[test]
    fn a_green_mask_is_left_alone_and_nothing_on_it_is_off_hue() {
        let im = RgbImage::from_fn(300, 200, |x, y| { let n = (((x * 7919) ^ (y * 104729)) % 9) as u8; Rgb([41 + n, 130 + n, 74 + n]) });
        let mc = crate::maskcolour::of(&im);
        assert!(mc.green);
        assert_eq!(with_channel(&im, mc, false).measure, 1);
        assert!(!not_board(&mc, 40.0, 70.0, 75.0));
    }
    #[test]
    fn a_tinted_body_is_not_board_on_a_red_or_a_blue_mask_and_the_masks_own_shades_are() {
        let red = crate::maskcolour::of(&red_board());
        assert!(not_board(&red, 40.0, 70.0, 75.0), "a black chip that reads dark teal");
        assert!(!not_board(&red, 120.0, 20.0, 18.0), "red mask in shadow");
        assert!(!not_board(&red, 230.0, 120.0, 150.0), "a pink highlight");
        assert!(!not_board(&red, 60.0, 60.0, 62.0), "grey has no hue to speak of");
        let blue = crate::maskcolour::of(&RgbImage::from_fn(300, 200, |x, y| { let n = (((x * 7919) ^ (y * 104729)) % 9) as u8; Rgb([45 + n, 80 + n, 180 + n]) }));
        assert!(not_board(&blue, 205.0, 165.0, 70.0), "a gold pin");
        assert!(!not_board(&blue, 110.0, 90.0, 190.0), "the purple rim of a track");
    }
}