use super::convert::DynamicImageTraitConvert;
use anyhow::{Result, bail, ensure};
use image::{DynamicImage, GenericImageView};
use versatiles_derive::context;
#[derive(Clone, Copy, Debug)]
pub struct MarkerParameters {
pub offset: f64,
pub angle: f64,
pub scale: f64,
}
impl MarkerParameters {
#[must_use]
pub fn new(offset: f64, angle: f64, scale: f64) -> Self {
Self { offset, angle, scale }
}
}
#[derive(Clone, Copy, Debug)]
pub struct MarkerResult {
pub offset: f64,
pub angle: f64,
pub scale: f64,
pub error: f64,
}
impl MarkerResult {
#[context("comparing marker result (factor={:.3}) to expected (offset={:.3}, angle={:.1}, scale={:.3})", factor, p.offset, p.angle, p.scale)]
pub fn compare(&self, p: &MarkerParameters, factor: f64) -> Result<()> {
fn angle_delta(a: f64, b: f64) -> f64 {
let mut d = (a - b).rem_euclid(360.0);
if d >= 180.0 {
d -= 360.0;
}
if d.abs() > 90.0 {
d = 180.0 - d.abs();
}
d.abs()
}
let mut errors = vec![];
if (p.offset - self.offset).abs() > 0.11 * factor {
errors.push(format!(
" - offset mismatch: expected {:.3}, got {:.3} (Δ={:.3})",
p.offset,
self.offset,
(p.offset - self.offset).abs()
));
}
if angle_delta(p.angle, self.angle).abs() > 0.4 * factor {
errors.push(format!(
" - angle mismatch: expected {:.1}, got {:.1} (Δ={:.1})",
p.angle,
self.angle,
angle_delta(p.angle, self.angle)
));
}
if (p.scale - self.scale).abs() > 0.1 * factor {
errors.push(format!(
" - scale mismatch: expected {:.3}, got {:.3} (Δ={:.3})",
p.scale,
self.scale,
(p.scale - self.scale).abs()
));
}
if self.error > 1.0 * factor {
errors.push(format!(" - high residual error: {:.3}", self.error));
}
if !errors.is_empty() {
bail!("{}", errors.join("\n"));
}
Ok(())
}
}
#[context("comparing {} channel marker results", params.len())]
pub fn compare_marker_result(params: &[MarkerParameters], results: &[MarkerResult]) -> Result<()> {
ensure!(
params.len() == results.len(),
"parameter/result count mismatch: expected {}, got {}",
params.len(),
results.len()
);
for (i, (p, r)) in params.iter().zip(results.iter()).enumerate() {
if let Err(errors) = r.compare(p, 1.0) {
bail!("error in channel {}:\n{}", i + 1, errors);
}
}
Ok(())
}
pub trait DynamicImageTraitTest: DynamicImageTraitConvert {
fn new_test_rgba() -> DynamicImage;
fn new_test_rgb() -> DynamicImage;
fn new_test_grey() -> DynamicImage;
fn new_test_greya() -> DynamicImage;
fn new_marker(parameters: &[MarkerParameters]) -> DynamicImage;
fn gauge_marker(&self) -> Vec<MarkerResult>;
}
impl DynamicImageTraitTest for DynamicImage
where
DynamicImage: DynamicImageTraitConvert,
{
fn new_test_rgba() -> DynamicImage {
#[allow(clippy::cast_possible_truncation)]
DynamicImage::from_fn(256, 256, |x, y| [x as u8, (255 - x) as u8, y as u8, (255 - y) as u8])
}
fn new_test_rgb() -> DynamicImage {
#[allow(clippy::cast_possible_truncation)]
DynamicImage::from_fn(256, 256, |x, y| [x as u8, (255 - x) as u8, y as u8])
}
fn new_test_grey() -> DynamicImage {
#[allow(clippy::cast_possible_truncation)]
DynamicImage::from_fn(256, 256, |x, _y| [x as u8])
}
fn new_test_greya() -> DynamicImage {
#[allow(clippy::cast_possible_truncation)]
DynamicImage::from_fn(256, 256, |x, y| [x as u8, y as u8])
}
fn new_marker(parameters: &[MarkerParameters]) -> DynamicImage {
fn f<const N: usize>(x: u32, y: u32, parameters: &[MarkerParameters; N]) -> [u8; N] {
let xf = f64::from(x) - 128.0;
let yf = f64::from(y) - 128.0;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] parameters.map(|p| {
let angle_rad = p.angle.to_radians();
let v = angle_rad.cos() * xf + angle_rad.sin() * yf - p.offset;
(v * p.scale / 256.0 + 128.0).round().clamp(0.0, 255.0) as u8
})
}
match parameters.len() {
1 => {
let p = [parameters[0]];
DynamicImage::from_fn(256, 256, |x, y| f(x, y, &p))
}
2 => {
let p = [parameters[0], parameters[1]];
DynamicImage::from_fn(256, 256, |x, y| f(x, y, &p))
}
3 => {
let p = [parameters[0], parameters[1], parameters[2]];
DynamicImage::from_fn(256, 256, |x, y| f(x, y, &p))
}
4 => {
let p = [parameters[0], parameters[1], parameters[2], parameters[3]];
DynamicImage::from_fn(256, 256, |x, y| f(x, y, &p))
}
_ => panic!("new_marker supports only 1 to 4 channels"),
}
}
fn gauge_marker(&self) -> Vec<MarkerResult> {
let (width, height) = self.dimensions();
let mut results = Vec::new();
for c in 0..self.color().channel_count() {
let mut s_x = 0.0;
let mut s_y = 0.0;
let mut s_1 = 0.0;
let mut s_xx = 0.0;
let mut s_xy = 0.0;
let mut s_yy = 0.0;
let mut s_xv = 0.0;
let mut s_yv = 0.0;
let mut s_v = 0.0;
for y in 0..height {
for x in 0..width {
let b = f64::from(self.raw_pixel(x, y)[c as usize]);
if b <= 0.0 || b >= 255.0 {
continue;
}
let v = b - 128.0;
let xf = f64::from(x) - f64::from(width) / 2.0;
let yf = f64::from(y) - f64::from(height) / 2.0;
s_x += xf;
s_y += yf;
s_1 += 1.0;
s_xx += xf * xf;
s_xy += xf * yf;
s_yy += yf * yf;
s_xv += xf * v;
s_yv += yf * v;
s_v += v;
}
}
let det = s_xx * (s_yy * s_1 - s_y * s_y) - s_xy * (s_xy * s_1 - s_x * s_y) + s_x * (s_xy * s_y - s_yy * s_x);
if !det.is_finite() || det.abs() < 1e-9 {
results.push(MarkerResult {
offset: 0.0,
angle: 0.0,
scale: 0.0,
error: f64::INFINITY,
});
continue;
}
let det_a =
s_xv * (s_yy * s_1 - s_y * s_y) - s_xy * (s_yv * s_1 - s_x * s_v) + s_x * (s_yv * s_y - s_yy * s_v);
let det_b =
s_xx * (s_yv * s_1 - s_x * s_v) - s_xv * (s_xy * s_1 - s_x * s_y) + s_x * (s_xy * s_v - s_yv * s_x);
let det_c =
s_xx * (s_yy * s_v - s_yv * s_y) - s_xy * (s_xy * s_v - s_yv * s_x) + s_x * (s_xy * s_yv - s_yy * s_xv);
let a = det_a / det;
let b2 = det_b / det; let c_hat = det_c / det;
let angle = b2.atan2(a).to_degrees();
let scale = (a * a + b2 * b2).sqrt() * 256.0;
let offset = -c_hat / (scale / 256.0);
let mut error_sum = 0.0;
let mut n = 0.0;
for y in 0..height {
for x in 0..width {
let bb = f64::from(self.raw_pixel(x, y)[c as usize]);
if bb <= 0.0 || bb >= 255.0 {
continue;
}
let v = bb - 128.0;
let xf = f64::from(x) - f64::from(width) / 2.0;
let yf = f64::from(y) - f64::from(height) / 2.0;
let v_hat = a * xf + b2 * yf + c_hat;
error_sum += (v - v_hat).abs();
n += 1.0;
}
}
let error = if n > 0.0 { error_sum / n } else { f64::INFINITY };
results.push(MarkerResult {
offset,
angle,
scale,
error,
});
}
results
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use versatiles_derive::context;
fn compare_err_msg(p: MarkerParameters, r: MarkerResult, factor: f64) -> String {
r.compare(&p, factor).unwrap_err().chain().last().unwrap().to_string()
}
#[rstest]
#[case::grey(DynamicImage::new_test_grey(), [
"...# +++# ####",
"...# +++# ####",
"...# +++# ####"
])]
#[case::greya(DynamicImage::new_test_greya(), [
".... +++. ###.",
"...+ ++++ ###+",
"...# +++# ####"
])]
#[case::rgb(DynamicImage::new_test_rgb(), [
".#.# ++.# #..#",
".#+# +++# #.+#",
".### ++## #.##"
])]
#[case::rgba(DynamicImage::new_test_rgba(), [
".#.# ++.# #..#",
".#++ ++++ #.++",
".##. ++#. #.#."
])]
fn check_dimensions_and_gradients(#[case] img: DynamicImage, #[case] colors: [&str; 3]) {
assert_eq!(img.dimensions(), (256, 256));
let get_pixel = |x: u32, y: u32| {
img.get_pixel(x, y)
.0
.iter()
.map(|v| match v {
0 => '.',
127 | 128 => '+',
255 => '#',
_ => panic!("unexpected value {v}"),
})
.collect::<String>()
};
let colors_result = [
[get_pixel(0, 0), get_pixel(128, 0), get_pixel(255, 0)].join(" "),
[get_pixel(0, 128), get_pixel(128, 128), get_pixel(255, 128)].join(" "),
[get_pixel(0, 255), get_pixel(128, 255), get_pixel(255, 255)].join(" "),
];
assert_eq!(colors_result, colors);
}
#[rstest]
#[case::grey([ ( 7, 21, 85)])]
#[case::greya([(10, -23, 90), (-6, 63, 70)])]
#[case::rgb([ ( 3, 14, 80), (-7, -54, 65), (12, 80, 75)])]
#[case::rgba([ ( 4, 34, 88), (-9, -68, 67), (15, 60, 72), (-20, -17, 93)])]
fn marker_gauge_roundtrip<const N: usize>(#[case] args: [(i32, i32, i32); N]) {
let params = args
.map(|(offset, angle, scale)| MarkerParameters::new(f64::from(offset), f64::from(angle), f64::from(scale)));
let img = DynamicImage::new_marker(¶ms);
assert_eq!(img.dimensions(), (256, 256));
assert_eq!(img.color().channel_count() as usize, N);
let results = img.gauge_marker();
compare_marker_result(¶ms, &results).unwrap();
}
#[test]
#[context("test: compare tolerates exact thresholds")]
fn compare_tolerates_exact_thresholds() -> Result<()> {
let p = MarkerParameters {
offset: 10.0,
angle: 30.0,
scale: 80.0,
};
let r = MarkerResult {
offset: 10.11,
angle: 30.4,
scale: 80.10,
error: 1.0,
};
r.compare(&p, 1.0)
}
#[test]
fn compare_fails_just_over_thresholds() {
let p = MarkerParameters {
offset: 10.0,
angle: 30.0,
scale: 80.0,
};
let r = MarkerResult {
offset: 10.11001,
angle: 30.4001,
scale: 80.1001,
error: 1.00001,
};
let msg = compare_err_msg(p, r, 1.0);
assert!(msg.contains("offset mismatch"), "msg: {msg}");
assert!(msg.contains("angle mismatch"), "msg: {msg}");
assert!(msg.contains("scale mismatch"), "msg: {msg}");
assert!(msg.contains("high residual error"), "msg: {msg}");
}
#[test]
fn compare_respects_factor_scaling() {
let p = MarkerParameters {
offset: 0.0,
angle: 0.0,
scale: 100.0,
};
let r = MarkerResult {
offset: 0.21,
angle: 0.79,
scale: 100.19,
error: 1.99,
};
r.compare(&p, 2.0).expect("should pass with scaled thresholds");
let msg = compare_err_msg(p, r, 1.0);
assert!(
msg.contains("offset mismatch")
|| msg.contains("angle mismatch")
|| msg.contains("scale mismatch")
|| msg.contains("high residual error")
);
}
#[test]
#[context("test: compare angle wrap & principal-axis equivalence")]
fn compare_angle_wrap_and_principal_axis_equivalence() -> Result<()> {
let p = MarkerParameters {
offset: 0.0,
angle: 179.0,
scale: 50.0,
};
let r = MarkerResult {
offset: 0.0,
angle: -181.0,
scale: 50.0,
error: 0.1,
};
r.compare(&p, 1.0)?;
let p2 = MarkerParameters {
offset: 0.0,
angle: 170.0,
scale: 50.0,
};
let r2 = MarkerResult {
offset: 0.0,
angle: -10.0,
scale: 50.0,
error: 0.1,
};
r2.compare(&p2, 30.0)
}
}