use crate::image::GrayFrame;
const BINS: usize = 60;
const MIN_MAG2: f32 = 24.0 * 24.0;
const MIN_COHERENCE: f32 = 0.4;
pub fn dominant_gradient_angle(frame: &GrayFrame<'_>) -> Option<f32> {
let w = frame.width();
let h = frame.height();
if w < 8 || h < 8 {
return None;
}
let mut hist = [0.0f32; BINS];
let mut total = 0.0f32;
for y in 1..h - 1 {
for x in 1..w - 1 {
let gx =
f32::from(frame.get_unchecked(x + 1, y)) - f32::from(frame.get_unchecked(x - 1, y));
let gy =
f32::from(frame.get_unchecked(x, y + 1)) - f32::from(frame.get_unchecked(x, y - 1));
let mag2 = gx * gx + gy * gy;
if mag2 < MIN_MAG2 {
continue;
}
let a = gy.atan2(gx).rem_euclid(core::f32::consts::PI);
let bin = ((a / core::f32::consts::PI) * BINS as f32) as usize % BINS;
hist[bin] += mag2;
total += mag2;
}
}
if total <= 0.0 {
return None;
}
let mut smooth = [0.0f32; BINS];
for i in 0..BINS {
let l = hist[(i + BINS - 1) % BINS];
let r = hist[(i + 1) % BINS];
smooth[i] = 0.25 * l + 0.5 * hist[i] + 0.25 * r;
}
let peak = (0..BINS)
.max_by(|&a, &b| smooth[a].partial_cmp(&smooth[b]).unwrap())
.unwrap();
let near: f32 = (-2i32..=2)
.map(|d| hist[(peak as i32 + d).rem_euclid(BINS as i32) as usize])
.sum();
if near / total < MIN_COHERENCE {
return None;
}
let (mut sx, mut sy) = (0.0f64, 0.0f64);
for d in -2i32..=2 {
let i = (peak as i32 + d).rem_euclid(BINS as i32) as usize;
let centre = (i as f64 + 0.5) / BINS as f64 * core::f64::consts::PI;
sx += f64::from(hist[i]) * (2.0 * centre).cos();
sy += f64::from(hist[i]) * (2.0 * centre).sin();
}
if sx == 0.0 && sy == 0.0 {
return None;
}
let mut angle = (sy.atan2(sx) / 2.0) as f32; if angle <= -core::f32::consts::FRAC_PI_2 {
angle += core::f32::consts::PI;
}
Some(angle)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::image::GrayImage;
fn bars(angle: f32) -> GrayImage {
let (s, c) = angle.sin_cos();
let mut img = GrayImage::new(128, 128);
for y in 0..128 {
for x in 0..128 {
let t = c * x as f32 + s * y as f32;
let ph = (t / 8.0).rem_euclid(2.0);
let d = ph.min((1.0 - ph).abs()).min(2.0 - ph) * 8.0;
let soft = (d / 1.2).tanh();
let side = if ph < 1.0 { -1.0 } else { 1.0 };
img.set(x, y, (127.5 + 127.4 * side * soft).round() as u8);
}
}
img
}
#[test]
fn recovers_bar_orientation() {
for deg in [0.0f32, 20.0, 45.0, 90.0, -30.0, -75.0] {
let angle = deg.to_radians();
let img = bars(angle);
let got = dominant_gradient_angle(&img.as_frame())
.unwrap_or_else(|| panic!("no orientation for {deg}°"));
let mut diff = (got - angle).rem_euclid(core::f32::consts::PI);
if diff > core::f32::consts::FRAC_PI_2 {
diff = core::f32::consts::PI - diff;
}
assert!(
diff.to_degrees() < 4.0,
"angle {deg}°: got {:.1}° (diff {:.1}°)",
got.to_degrees(),
diff.to_degrees()
);
}
}
#[test]
fn rejects_isotropic_texture() {
let mut img = GrayImage::filled(96, 96, 255);
for y in 0..96 {
for x in 0..96 {
if (x / 6 + y / 6) % 2 == 0 {
img.set(x, y, 0);
}
}
}
assert!(dominant_gradient_angle(&img.as_frame()).is_none());
}
}