#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rgba {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Rgba {
pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 255 }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BBox {
pub min_x: u32,
pub min_y: u32,
pub max_x: u32,
pub max_y: u32,
}
impl BBox {
pub const EMPTY: BBox = BBox { min_x: 0, min_y: 0, max_x: 0, max_y: 0 };
pub fn width(&self) -> u32 {
self.max_x.saturating_sub(self.min_x)
}
pub fn height(&self) -> u32 {
self.max_y.saturating_sub(self.min_y)
}
pub fn is_empty(&self) -> bool {
self.width() == 0 || self.height() == 0
}
}
#[derive(Clone, Copy, Debug)]
pub struct ScanReport {
pub spoke_score: f64,
pub high_freq_ratio: f64,
pub coverage: f64,
pub centroid: (f64, f64),
pub bbox: BBox,
}
#[inline]
fn idx(x: u32, y: u32, w: u32) -> usize {
(y as usize * w as usize + x as usize) * 4
}
#[inline]
fn luma_at(px: &[u8], x: u32, y: u32, w: u32) -> f64 {
let i = idx(x, y, w);
0.299 * px[i] as f64 + 0.587 * px[i + 1] as f64 + 0.114 * px[i + 2] as f64
}
#[inline]
fn ok_dims(px: &[u8], w: u32, h: u32) -> bool {
w > 0 && h > 0 && px.len() >= (w as usize * h as usize * 4)
}
fn luma_plane(px: &[u8], w: u32, h: u32) -> Vec<f64> {
let mut out = vec![0.0; (w as usize) * (h as usize)];
for y in 0..h {
for x in 0..w {
out[(y as usize) * (w as usize) + (x as usize)] = luma_at(px, x, y, w);
}
}
out
}
pub fn spoke_score(px: &[u8], w: u32, h: u32) -> f64 {
if !ok_dims(px, w, h) || w < 3 || h < 3 {
return 0.0;
}
let luma = luma_plane(px, w, h);
let at = |x: u32, y: u32| luma[(y as usize) * (w as usize) + (x as usize)];
let mut mag = vec![0.0f64; (w as usize) * (h as usize)];
let mut sum_mag = 0.0;
for y in 1..h - 1 {
for x in 1..w - 1 {
let gx = (at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1))
- (at(x - 1, y - 1) + 2.0 * at(x - 1, y) + at(x - 1, y + 1));
let gy = (at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1))
- (at(x - 1, y - 1) + 2.0 * at(x, y - 1) + at(x + 1, y - 1));
let m = (gx * gx + gy * gy).sqrt();
mag[(y as usize) * (w as usize) + (x as usize)] = m;
sum_mag += m;
}
}
if sum_mag <= f64::EPSILON {
return 0.0;
}
let n_interior = ((w - 2) as f64) * ((h - 2) as f64);
let mean_mag = sum_mag / n_interior;
let strong_gate = (mean_mag * 2.0).max(8.0);
let bg_luma = median(&luma);
let lit = |x: u32, y: u32| (at(x, y) - bg_luma).abs() > 24.0;
let magv = |x: u32, y: u32| mag[(y as usize) * (w as usize) + (x as usize)];
let mut strong_energy = 0.0;
let mut thin_energy = 0.0;
for y in 1..h - 1 {
for x in 1..w - 1 {
let m = magv(x, y);
if m < strong_gate {
continue;
}
strong_energy += m;
let mut lit_neighbours = 0u32;
for (dx, dy) in
[(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]
{
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if lit(nx as u32, ny as u32) {
lit_neighbours += 1;
}
}
if lit_neighbours <= 2 {
thin_energy += m;
}
}
}
if strong_energy <= f64::EPSILON {
0.0
} else {
thin_energy / strong_energy
}
}
fn median(v: &[f64]) -> f64 {
if v.is_empty() {
return 0.0;
}
let mut s: Vec<f64> = v.to_vec();
s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
s[s.len() / 2]
}
pub fn high_freq_ratio(px: &[u8], w: u32, h: u32) -> f64 {
if !ok_dims(px, w, h) || w < 2 || h < 2 {
return 0.0;
}
let luma = luma_plane(px, w, h);
let lp = box_blur(&luma, w, h, 2);
let mut hf = 0.0; let mut lf = 0.0; let mean: f64 = luma.iter().sum::<f64>() / luma.len() as f64;
for i in 0..luma.len() {
hf += (luma[i] - lp[i]).abs();
lf += (lp[i] - mean).abs();
}
let total = hf + lf;
if total <= f64::EPSILON {
0.0
} else {
hf / total
}
}
fn box_blur(plane: &[f64], w: u32, h: u32, r: u32) -> Vec<f64> {
let (wu, hu) = (w as usize, h as usize);
let r = r as i32;
let mut tmp = vec![0.0f64; plane.len()];
for y in 0..hu {
for x in 0..wu {
let mut acc = 0.0;
let mut cnt = 0.0;
for dx in -r..=r {
let sx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize;
acc += plane[y * wu + sx];
cnt += 1.0;
}
tmp[y * wu + x] = acc / cnt;
}
}
let mut out = vec![0.0f64; plane.len()];
for y in 0..hu {
for x in 0..wu {
let mut acc = 0.0;
let mut cnt = 0.0;
for dy in -r..=r {
let sy = (y as i32 + dy).clamp(0, h as i32 - 1) as usize;
acc += tmp[sy * wu + x];
cnt += 1.0;
}
out[y * wu + x] = acc / cnt;
}
}
out
}
#[inline]
fn differs(px: &[u8], x: u32, y: u32, w: u32, bg: Rgba) -> bool {
let i = idx(x, y, w);
let d = (px[i] as i32 - bg.r as i32).abs()
+ (px[i + 1] as i32 - bg.g as i32).abs()
+ (px[i + 2] as i32 - bg.b as i32).abs();
d > 24
}
pub fn coverage(px: &[u8], w: u32, h: u32, bg: Rgba) -> f64 {
if !ok_dims(px, w, h) {
return 0.0;
}
let mut painted = 0u64;
for y in 0..h {
for x in 0..w {
if differs(px, x, y, w, bg) {
painted += 1;
}
}
}
painted as f64 / (w as f64 * h as f64)
}
pub fn painted_centroid_and_bbox(px: &[u8], w: u32, h: u32, bg: Rgba) -> ((f64, f64), BBox) {
if !ok_dims(px, w, h) {
return ((0.0, 0.0), BBox::EMPTY);
}
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut n = 0u64;
let (mut min_x, mut min_y, mut max_x, mut max_y) = (u32::MAX, u32::MAX, 0u32, 0u32);
for y in 0..h {
for x in 0..w {
if differs(px, x, y, w, bg) {
sum_x += x as f64;
sum_y += y as f64;
n += 1;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
}
}
if n == 0 {
return ((0.0, 0.0), BBox::EMPTY);
}
let centroid = (sum_x / n as f64, sum_y / n as f64);
let bbox = BBox { min_x, min_y, max_x: max_x + 1, max_y: max_y + 1 };
(centroid, bbox)
}
pub fn scan(px: &[u8], w: u32, h: u32, bg: Rgba) -> ScanReport {
let (centroid, bbox) = painted_centroid_and_bbox(px, w, h, bg);
ScanReport {
spoke_score: spoke_score(px, w, h),
high_freq_ratio: high_freq_ratio(px, w, h),
coverage: coverage(px, w, h, bg),
centroid,
bbox,
}
}
#[cfg(test)]
mod tests {
use super::*;
const W: u32 = 200;
const H: u32 = 200;
const BG: Rgba = Rgba::rgb(12, 12, 18);
fn frame(w: u32, h: u32, bg: Rgba) -> Vec<u8> {
let mut v = vec![0u8; (w as usize) * (h as usize) * 4];
for p in v.chunks_exact_mut(4) {
p[0] = bg.r;
p[1] = bg.g;
p[2] = bg.b;
p[3] = bg.a;
}
v
}
fn put(px: &mut [u8], x: i32, y: i32, w: u32, h: u32, c: Rgba) {
if x < 0 || y < 0 || x as u32 >= w || y as u32 >= h {
return;
}
let i = ((y as u32 * w + x as u32) * 4) as usize;
px[i] = c.r;
px[i + 1] = c.g;
px[i + 2] = c.b;
px[i + 3] = c.a;
}
fn fill_rect(px: &mut [u8], x0: u32, y0: u32, rw: u32, rh: u32, w: u32, h: u32, c: Rgba) {
for y in y0..(y0 + rh).min(h) {
for x in x0..(x0 + rw).min(w) {
put(px, x as i32, y as i32, w, h, c);
}
}
}
fn line(px: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, w: u32, h: u32, c: Rgba) {
let dx = (x1 - x0).abs();
let dy = -(y1 - y0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx + dy;
let (mut x, mut y) = (x0, y0);
loop {
put(px, x, y, w, h, c);
if x == x1 && y == y1 {
break;
}
let e2 = 2 * err;
if e2 >= dy {
err += dy;
x += sx;
}
if e2 <= dx {
err += dx;
y += sy;
}
}
}
fn clean_frame() -> Vec<u8> {
let mut f = frame(W, H, BG);
fill_rect(&mut f, 40, 40, 70, 70, W, H, Rgba::rgb(220, 80, 80));
fill_rect(&mut f, 100, 50, 60, 90, W, H, Rgba::rgb(80, 200, 120));
fill_rect(&mut f, 60, 110, 90, 60, W, H, Rgba::rgb(90, 140, 230));
f
}
fn smeared_frame() -> Vec<u8> {
let mut f = frame(W, H, BG);
let (cx, cy) = (W as i32 / 2, H as i32 / 2);
let spoke = Rgba::rgb(60, 230, 90); for k in 0..24 {
let a = std::f64::consts::TAU * k as f64 / 24.0;
let ex = (cx as f64 + a.cos() * 1000.0) as i32; let ey = (cy as f64 + a.sin() * 1000.0) as i32;
line(&mut f, cx, cy, ex, ey, W, H, spoke);
}
f
}
#[test]
fn clean_frame_scans_low_and_centred() {
let f = clean_frame();
let r = scan(&f, W, H, BG);
eprintln!("[imgscan] CLEAN {r:?}");
assert!(r.spoke_score < 0.30, "clean spoke_score should be low: {}", r.spoke_score);
assert!(r.high_freq_ratio < 0.30, "clean hf_ratio should be low: {}", r.high_freq_ratio);
assert!(r.coverage > 0.02 && r.coverage < 0.45, "clean coverage band: {}", r.coverage);
assert!((r.centroid.0 - W as f64 / 2.0).abs() < W as f64 * 0.25, "cx near centre: {:?}", r.centroid);
assert!((r.centroid.1 - H as f64 / 2.0).abs() < H as f64 * 0.25, "cy near centre: {:?}", r.centroid);
assert!(!r.bbox.is_empty(), "clean frame has a real bbox");
}
#[test]
fn smeared_frame_scans_high() {
let f = smeared_frame();
let r = scan(&f, W, H, BG);
eprintln!("[imgscan] SMEAR {r:?}");
assert!(r.spoke_score > 0.45, "smear spoke_score should be high: {}", r.spoke_score);
assert!(r.high_freq_ratio > 0.30, "smear hf_ratio should be high: {}", r.high_freq_ratio);
}
#[test]
fn clean_and_smeared_are_clearly_separated() {
let clean = scan(&clean_frame(), W, H, BG);
let smear = scan(&smeared_frame(), W, H, BG);
eprintln!(
"[imgscan] SEPARATION spoke: clean={:.3} smear={:.3} | hf: clean={:.3} smear={:.3}",
clean.spoke_score, smear.spoke_score, clean.high_freq_ratio, smear.high_freq_ratio
);
let t_spoke = 0.25;
assert!(
clean.spoke_score < t_spoke && t_spoke < smear.spoke_score,
"spoke_score must split at {t_spoke}: clean={} < {t_spoke} < smear={}",
clean.spoke_score,
smear.spoke_score
);
let t_hf = 0.30;
assert!(
clean.high_freq_ratio < t_hf && t_hf < smear.high_freq_ratio,
"hf_ratio must split at {t_hf}: clean={} < {t_hf} < smear={}",
clean.high_freq_ratio,
smear.high_freq_ratio
);
assert!(smear.spoke_score - clean.spoke_score > 0.25, "spoke gap wide enough");
assert!(smear.high_freq_ratio - clean.high_freq_ratio > 0.10, "hf gap wide enough");
}
#[test]
fn blank_frame_is_all_zero() {
let f = frame(W, H, BG);
let r = scan(&f, W, H, BG);
assert_eq!(r.spoke_score, 0.0, "blank: no edges");
assert_eq!(r.high_freq_ratio, 0.0, "blank: no variation");
assert_eq!(r.coverage, 0.0, "blank: nothing differs from bg");
assert_eq!(r.centroid, (0.0, 0.0), "blank: no centroid");
assert!(r.bbox.is_empty(), "blank: empty bbox");
}
#[test]
fn fully_filled_frame_is_full_coverage_low_freq() {
let f = frame(W, H, Rgba::rgb(200, 30, 30));
let r = scan(&f, W, H, BG);
assert!((r.coverage - 1.0).abs() < 1e-9, "fully filled: coverage == 1: {}", r.coverage);
assert_eq!(r.spoke_score, 0.0, "uniform fill: no thin lines");
assert_eq!(r.high_freq_ratio, 0.0, "uniform fill: no high-freq energy");
assert_eq!(r.bbox, BBox { min_x: 0, min_y: 0, max_x: W, max_y: H });
assert!((r.centroid.0 - (W as f64 - 1.0) / 2.0).abs() < 1.0);
assert!((r.centroid.1 - (H as f64 - 1.0) / 2.0).abs() < 1.0);
}
#[test]
fn single_pixel_image_does_not_panic() {
let mut f = frame(1, 1, BG);
let r = scan(&f, 1, 1, BG);
assert_eq!(r.coverage, 0.0);
assert_eq!(r.spoke_score, 0.0);
put(&mut f, 0, 0, 1, 1, Rgba::rgb(255, 255, 255));
let r2 = scan(&f, 1, 1, BG);
assert!((r2.coverage - 1.0).abs() < 1e-9);
assert_eq!(r2.centroid, (0.0, 0.0));
assert_eq!(r2.bbox, BBox { min_x: 0, min_y: 0, max_x: 1, max_y: 1 });
}
#[test]
fn corner_flung_geometry_moves_the_centroid() {
let mut f = frame(W, H, BG);
fill_rect(&mut f, 0, 0, 20, 20, W, H, Rgba::rgb(240, 240, 240));
let (cx, cy) = painted_centroid_and_bbox(&f, W, H, BG).0;
assert!(cx < W as f64 * 0.2 && cy < H as f64 * 0.2, "centroid in the corner: ({cx},{cy})");
}
#[test]
fn coverage_counts_only_above_threshold() {
let mut f = frame(W, H, BG);
fill_rect(&mut f, 10, 10, 50, 50, W, H, Rgba::rgb(BG.r + 5, BG.g + 5, BG.b + 5));
assert_eq!(coverage(&f, W, H, BG), 0.0, "sub-threshold delta is not coverage");
}
#[test]
fn bad_dims_degrade_gracefully() {
let f = vec![0u8; 16]; assert_eq!(coverage(&f, 100, 100, BG), 0.0);
assert_eq!(spoke_score(&f, 100, 100), 0.0);
assert_eq!(high_freq_ratio(&f, 100, 100), 0.0);
let (c, b) = painted_centroid_and_bbox(&f, 100, 100, BG);
assert_eq!(c, (0.0, 0.0));
assert!(b.is_empty());
}
}