main
Caleb Publish 0.5.0 1984b40
//! 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));
    }
}