1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub struct Rgba {
27 pub r: u8,
28 pub g: u8,
29 pub b: u8,
30 pub a: u8,
31}
32
33impl Rgba {
34 pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
35 Self { r, g, b, a }
36 }
37 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
39 Self { r, g, b, a: 255 }
40 }
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub struct BBox {
48 pub min_x: u32,
49 pub min_y: u32,
50 pub max_x: u32,
51 pub max_y: u32,
52}
53
54impl BBox {
55 pub const EMPTY: BBox = BBox { min_x: 0, min_y: 0, max_x: 0, max_y: 0 };
57
58 pub fn width(&self) -> u32 {
59 self.max_x.saturating_sub(self.min_x)
60 }
61 pub fn height(&self) -> u32 {
62 self.max_y.saturating_sub(self.min_y)
63 }
64 pub fn is_empty(&self) -> bool {
65 self.width() == 0 || self.height() == 0
66 }
67}
68
69#[derive(Clone, Copy, Debug)]
71pub struct ScanReport {
72 pub spoke_score: f64,
76 pub high_freq_ratio: f64,
79 pub coverage: f64,
82 pub centroid: (f64, f64),
85 pub bbox: BBox,
87}
88
89#[inline]
92fn idx(x: u32, y: u32, w: u32) -> usize {
93 (y as usize * w as usize + x as usize) * 4
94}
95
96#[inline]
99fn luma_at(px: &[u8], x: u32, y: u32, w: u32) -> f64 {
100 let i = idx(x, y, w);
101 0.299 * px[i] as f64 + 0.587 * px[i + 1] as f64 + 0.114 * px[i + 2] as f64
102}
103
104#[inline]
108fn ok_dims(px: &[u8], w: u32, h: u32) -> bool {
109 w > 0 && h > 0 && px.len() >= (w as usize * h as usize * 4)
110}
111
112fn luma_plane(px: &[u8], w: u32, h: u32) -> Vec<f64> {
114 let mut out = vec![0.0; (w as usize) * (h as usize)];
115 for y in 0..h {
116 for x in 0..w {
117 out[(y as usize) * (w as usize) + (x as usize)] = luma_at(px, x, y, w);
118 }
119 }
120 out
121}
122
123pub fn spoke_score(px: &[u8], w: u32, h: u32) -> f64 {
144 if !ok_dims(px, w, h) || w < 3 || h < 3 {
145 return 0.0;
146 }
147 let luma = luma_plane(px, w, h);
148 let at = |x: u32, y: u32| luma[(y as usize) * (w as usize) + (x as usize)];
149
150 let mut mag = vec![0.0f64; (w as usize) * (h as usize)];
152 let mut sum_mag = 0.0;
153 for y in 1..h - 1 {
154 for x in 1..w - 1 {
155 let gx = (at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1))
156 - (at(x - 1, y - 1) + 2.0 * at(x - 1, y) + at(x - 1, y + 1));
157 let gy = (at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1))
158 - (at(x - 1, y - 1) + 2.0 * at(x, y - 1) + at(x + 1, y - 1));
159 let m = (gx * gx + gy * gy).sqrt();
160 mag[(y as usize) * (w as usize) + (x as usize)] = m;
161 sum_mag += m;
162 }
163 }
164 if sum_mag <= f64::EPSILON {
165 return 0.0;
166 }
167 let n_interior = ((w - 2) as f64) * ((h - 2) as f64);
168 let mean_mag = sum_mag / n_interior;
169 let strong_gate = (mean_mag * 2.0).max(8.0);
173
174 let bg_luma = median(&luma);
177 let lit = |x: u32, y: u32| (at(x, y) - bg_luma).abs() > 24.0;
178
179 let magv = |x: u32, y: u32| mag[(y as usize) * (w as usize) + (x as usize)];
180
181 let mut strong_energy = 0.0;
182 let mut thin_energy = 0.0;
183 for y in 1..h - 1 {
184 for x in 1..w - 1 {
185 let m = magv(x, y);
186 if m < strong_gate {
187 continue;
188 }
189 strong_energy += m;
190 let mut lit_neighbours = 0u32;
193 for (dx, dy) in
194 [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]
195 {
196 let nx = x as i32 + dx;
197 let ny = y as i32 + dy;
198 if lit(nx as u32, ny as u32) {
199 lit_neighbours += 1;
200 }
201 }
202 if lit_neighbours <= 2 {
206 thin_energy += m;
207 }
208 }
209 }
210 if strong_energy <= f64::EPSILON {
211 0.0
212 } else {
213 thin_energy / strong_energy
214 }
215}
216
217fn median(v: &[f64]) -> f64 {
220 if v.is_empty() {
221 return 0.0;
222 }
223 let mut s: Vec<f64> = v.to_vec();
224 s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
225 s[s.len() / 2]
226}
227
228pub fn high_freq_ratio(px: &[u8], w: u32, h: u32) -> f64 {
242 if !ok_dims(px, w, h) || w < 2 || h < 2 {
243 return 0.0;
244 }
245 let luma = luma_plane(px, w, h);
246 let lp = box_blur(&luma, w, h, 2);
247
248 let mut hf = 0.0; let mut lf = 0.0; let mean: f64 = luma.iter().sum::<f64>() / luma.len() as f64;
251 for i in 0..luma.len() {
252 hf += (luma[i] - lp[i]).abs();
253 lf += (lp[i] - mean).abs();
254 }
255 let total = hf + lf;
256 if total <= f64::EPSILON {
257 0.0
258 } else {
259 hf / total
260 }
261}
262
263fn box_blur(plane: &[f64], w: u32, h: u32, r: u32) -> Vec<f64> {
266 let (wu, hu) = (w as usize, h as usize);
267 let r = r as i32;
268 let mut tmp = vec![0.0f64; plane.len()];
270 for y in 0..hu {
271 for x in 0..wu {
272 let mut acc = 0.0;
273 let mut cnt = 0.0;
274 for dx in -r..=r {
275 let sx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize;
276 acc += plane[y * wu + sx];
277 cnt += 1.0;
278 }
279 tmp[y * wu + x] = acc / cnt;
280 }
281 }
282 let mut out = vec![0.0f64; plane.len()];
284 for y in 0..hu {
285 for x in 0..wu {
286 let mut acc = 0.0;
287 let mut cnt = 0.0;
288 for dy in -r..=r {
289 let sy = (y as i32 + dy).clamp(0, h as i32 - 1) as usize;
290 acc += tmp[sy * wu + x];
291 cnt += 1.0;
292 }
293 out[y * wu + x] = acc / cnt;
294 }
295 }
296 out
297}
298
299#[inline]
305fn differs(px: &[u8], x: u32, y: u32, w: u32, bg: Rgba) -> bool {
306 let i = idx(x, y, w);
307 let d = (px[i] as i32 - bg.r as i32).abs()
308 + (px[i + 1] as i32 - bg.g as i32).abs()
309 + (px[i + 2] as i32 - bg.b as i32).abs();
310 d > 24
311}
312
313pub fn coverage(px: &[u8], w: u32, h: u32, bg: Rgba) -> f64 {
317 if !ok_dims(px, w, h) {
318 return 0.0;
319 }
320 let mut painted = 0u64;
321 for y in 0..h {
322 for x in 0..w {
323 if differs(px, x, y, w, bg) {
324 painted += 1;
325 }
326 }
327 }
328 painted as f64 / (w as f64 * h as f64)
329}
330
331pub fn painted_centroid_and_bbox(px: &[u8], w: u32, h: u32, bg: Rgba) -> ((f64, f64), BBox) {
338 if !ok_dims(px, w, h) {
339 return ((0.0, 0.0), BBox::EMPTY);
340 }
341 let mut sum_x = 0.0;
342 let mut sum_y = 0.0;
343 let mut n = 0u64;
344 let (mut min_x, mut min_y, mut max_x, mut max_y) = (u32::MAX, u32::MAX, 0u32, 0u32);
345 for y in 0..h {
346 for x in 0..w {
347 if differs(px, x, y, w, bg) {
348 sum_x += x as f64;
349 sum_y += y as f64;
350 n += 1;
351 min_x = min_x.min(x);
352 min_y = min_y.min(y);
353 max_x = max_x.max(x);
354 max_y = max_y.max(y);
355 }
356 }
357 }
358 if n == 0 {
359 return ((0.0, 0.0), BBox::EMPTY);
360 }
361 let centroid = (sum_x / n as f64, sum_y / n as f64);
362 let bbox = BBox { min_x, min_y, max_x: max_x + 1, max_y: max_y + 1 };
364 (centroid, bbox)
365}
366
367pub fn scan(px: &[u8], w: u32, h: u32, bg: Rgba) -> ScanReport {
373 let (centroid, bbox) = painted_centroid_and_bbox(px, w, h, bg);
374 ScanReport {
375 spoke_score: spoke_score(px, w, h),
376 high_freq_ratio: high_freq_ratio(px, w, h),
377 coverage: coverage(px, w, h, bg),
378 centroid,
379 bbox,
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 const W: u32 = 200;
388 const H: u32 = 200;
389 const BG: Rgba = Rgba::rgb(12, 12, 18); fn frame(w: u32, h: u32, bg: Rgba) -> Vec<u8> {
393 let mut v = vec![0u8; (w as usize) * (h as usize) * 4];
394 for p in v.chunks_exact_mut(4) {
395 p[0] = bg.r;
396 p[1] = bg.g;
397 p[2] = bg.b;
398 p[3] = bg.a;
399 }
400 v
401 }
402
403 fn put(px: &mut [u8], x: i32, y: i32, w: u32, h: u32, c: Rgba) {
404 if x < 0 || y < 0 || x as u32 >= w || y as u32 >= h {
405 return;
406 }
407 let i = ((y as u32 * w + x as u32) * 4) as usize;
408 px[i] = c.r;
409 px[i + 1] = c.g;
410 px[i + 2] = c.b;
411 px[i + 3] = c.a;
412 }
413
414 fn fill_rect(px: &mut [u8], x0: u32, y0: u32, rw: u32, rh: u32, w: u32, h: u32, c: Rgba) {
415 for y in y0..(y0 + rh).min(h) {
416 for x in x0..(x0 + rw).min(w) {
417 put(px, x as i32, y as i32, w, h, c);
418 }
419 }
420 }
421
422 fn line(px: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, w: u32, h: u32, c: Rgba) {
424 let dx = (x1 - x0).abs();
425 let dy = -(y1 - y0).abs();
426 let sx = if x0 < x1 { 1 } else { -1 };
427 let sy = if y0 < y1 { 1 } else { -1 };
428 let mut err = dx + dy;
429 let (mut x, mut y) = (x0, y0);
430 loop {
431 put(px, x, y, w, h, c);
432 if x == x1 && y == y1 {
433 break;
434 }
435 let e2 = 2 * err;
436 if e2 >= dy {
437 err += dy;
438 x += sx;
439 }
440 if e2 <= dx {
441 err += dx;
442 y += sy;
443 }
444 }
445 }
446
447 fn clean_frame() -> Vec<u8> {
449 let mut f = frame(W, H, BG);
450 fill_rect(&mut f, 40, 40, 70, 70, W, H, Rgba::rgb(220, 80, 80));
453 fill_rect(&mut f, 100, 50, 60, 90, W, H, Rgba::rgb(80, 200, 120));
454 fill_rect(&mut f, 60, 110, 90, 60, W, H, Rgba::rgb(90, 140, 230));
455 f
456 }
457
458 fn smeared_frame() -> Vec<u8> {
461 let mut f = frame(W, H, BG);
462 let (cx, cy) = (W as i32 / 2, H as i32 / 2);
463 let spoke = Rgba::rgb(60, 230, 90); for k in 0..24 {
466 let a = std::f64::consts::TAU * k as f64 / 24.0;
467 let ex = (cx as f64 + a.cos() * 1000.0) as i32; let ey = (cy as f64 + a.sin() * 1000.0) as i32;
469 line(&mut f, cx, cy, ex, ey, W, H, spoke);
470 }
471 f
472 }
473
474 #[test]
477 fn clean_frame_scans_low_and_centred() {
478 let f = clean_frame();
479 let r = scan(&f, W, H, BG);
480 eprintln!("[imgscan] CLEAN {r:?}");
481 assert!(r.spoke_score < 0.30, "clean spoke_score should be low: {}", r.spoke_score);
483 assert!(r.high_freq_ratio < 0.30, "clean hf_ratio should be low: {}", r.high_freq_ratio);
484 assert!(r.coverage > 0.02 && r.coverage < 0.45, "clean coverage band: {}", r.coverage);
486 assert!((r.centroid.0 - W as f64 / 2.0).abs() < W as f64 * 0.25, "cx near centre: {:?}", r.centroid);
488 assert!((r.centroid.1 - H as f64 / 2.0).abs() < H as f64 * 0.25, "cy near centre: {:?}", r.centroid);
489 assert!(!r.bbox.is_empty(), "clean frame has a real bbox");
490 }
491
492 #[test]
493 fn smeared_frame_scans_high() {
494 let f = smeared_frame();
495 let r = scan(&f, W, H, BG);
496 eprintln!("[imgscan] SMEAR {r:?}");
497 assert!(r.spoke_score > 0.45, "smear spoke_score should be high: {}", r.spoke_score);
498 assert!(r.high_freq_ratio > 0.30, "smear hf_ratio should be high: {}", r.high_freq_ratio);
499 }
500
501 #[test]
505 fn clean_and_smeared_are_clearly_separated() {
506 let clean = scan(&clean_frame(), W, H, BG);
507 let smear = scan(&smeared_frame(), W, H, BG);
508 eprintln!(
509 "[imgscan] SEPARATION spoke: clean={:.3} smear={:.3} | hf: clean={:.3} smear={:.3}",
510 clean.spoke_score, smear.spoke_score, clean.high_freq_ratio, smear.high_freq_ratio
511 );
512 let t_spoke = 0.25;
514 assert!(
515 clean.spoke_score < t_spoke && t_spoke < smear.spoke_score,
516 "spoke_score must split at {t_spoke}: clean={} < {t_spoke} < smear={}",
517 clean.spoke_score,
518 smear.spoke_score
519 );
520 let t_hf = 0.30;
522 assert!(
523 clean.high_freq_ratio < t_hf && t_hf < smear.high_freq_ratio,
524 "hf_ratio must split at {t_hf}: clean={} < {t_hf} < smear={}",
525 clean.high_freq_ratio,
526 smear.high_freq_ratio
527 );
528 assert!(smear.spoke_score - clean.spoke_score > 0.25, "spoke gap wide enough");
530 assert!(smear.high_freq_ratio - clean.high_freq_ratio > 0.10, "hf gap wide enough");
531 }
532
533 #[test]
536 fn blank_frame_is_all_zero() {
537 let f = frame(W, H, BG);
538 let r = scan(&f, W, H, BG);
539 assert_eq!(r.spoke_score, 0.0, "blank: no edges");
540 assert_eq!(r.high_freq_ratio, 0.0, "blank: no variation");
541 assert_eq!(r.coverage, 0.0, "blank: nothing differs from bg");
542 assert_eq!(r.centroid, (0.0, 0.0), "blank: no centroid");
543 assert!(r.bbox.is_empty(), "blank: empty bbox");
544 }
545
546 #[test]
547 fn fully_filled_frame_is_full_coverage_low_freq() {
548 let f = frame(W, H, Rgba::rgb(200, 30, 30));
551 let r = scan(&f, W, H, BG);
552 assert!((r.coverage - 1.0).abs() < 1e-9, "fully filled: coverage == 1: {}", r.coverage);
553 assert_eq!(r.spoke_score, 0.0, "uniform fill: no thin lines");
554 assert_eq!(r.high_freq_ratio, 0.0, "uniform fill: no high-freq energy");
555 assert_eq!(r.bbox, BBox { min_x: 0, min_y: 0, max_x: W, max_y: H });
557 assert!((r.centroid.0 - (W as f64 - 1.0) / 2.0).abs() < 1.0);
558 assert!((r.centroid.1 - (H as f64 - 1.0) / 2.0).abs() < 1.0);
559 }
560
561 #[test]
562 fn single_pixel_image_does_not_panic() {
563 let mut f = frame(1, 1, BG);
566 let r = scan(&f, 1, 1, BG);
567 assert_eq!(r.coverage, 0.0);
568 assert_eq!(r.spoke_score, 0.0);
569 put(&mut f, 0, 0, 1, 1, Rgba::rgb(255, 255, 255));
571 let r2 = scan(&f, 1, 1, BG);
572 assert!((r2.coverage - 1.0).abs() < 1e-9);
573 assert_eq!(r2.centroid, (0.0, 0.0));
574 assert_eq!(r2.bbox, BBox { min_x: 0, min_y: 0, max_x: 1, max_y: 1 });
575 }
576
577 #[test]
578 fn corner_flung_geometry_moves_the_centroid() {
579 let mut f = frame(W, H, BG);
582 fill_rect(&mut f, 0, 0, 20, 20, W, H, Rgba::rgb(240, 240, 240));
583 let (cx, cy) = painted_centroid_and_bbox(&f, W, H, BG).0;
584 assert!(cx < W as f64 * 0.2 && cy < H as f64 * 0.2, "centroid in the corner: ({cx},{cy})");
585 }
586
587 #[test]
588 fn coverage_counts_only_above_threshold() {
589 let mut f = frame(W, H, BG);
591 fill_rect(&mut f, 10, 10, 50, 50, W, H, Rgba::rgb(BG.r + 5, BG.g + 5, BG.b + 5));
592 assert_eq!(coverage(&f, W, H, BG), 0.0, "sub-threshold delta is not coverage");
593 }
594
595 #[test]
596 fn bad_dims_degrade_gracefully() {
597 let f = vec![0u8; 16]; assert_eq!(coverage(&f, 100, 100, BG), 0.0);
601 assert_eq!(spoke_score(&f, 100, 100), 0.0);
602 assert_eq!(high_freq_ratio(&f, 100, 100), 0.0);
603 let (c, b) = painted_centroid_and_bbox(&f, 100, 100, BG);
604 assert_eq!(c, (0.0, 0.0));
605 assert!(b.is_empty());
606 }
607}