#![allow(clippy::must_use_candidate)]
#![allow(clippy::suboptimal_flops)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::cast_lossless)]
#![allow(clippy::option_if_let_else)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::doc_markdown)]
#![allow(clippy::explicit_iter_loop)]
use crate::error::DotmaxError;
use image::{DynamicImage, GrayImage, Luma};
use tracing::debug;
#[derive(Debug, Clone)]
pub struct BinaryImage {
pub width: u32,
pub height: u32,
pub pixels: Vec<bool>,
}
impl BinaryImage {
pub fn new(width: u32, height: u32) -> Self {
let pixel_count = (width as usize)
.checked_mul(height as usize)
.expect("Image dimensions too large");
Self {
width,
height,
pixels: vec![false; pixel_count],
}
}
pub fn pixel_count(&self) -> usize {
self.pixels.len()
}
pub fn get_pixel(&self, x: u32, y: u32) -> Option<bool> {
if x >= self.width || y >= self.height {
return None;
}
let index = (y * self.width + x) as usize;
self.pixels.get(index).copied()
}
pub fn set_pixel(&mut self, x: u32, y: u32, value: bool) -> bool {
if x >= self.width || y >= self.height {
return false;
}
let index = (y * self.width + x) as usize;
if let Some(pixel) = self.pixels.get_mut(index) {
*pixel = value;
true
} else {
false
}
}
pub fn from_grayscale(gray: &image::GrayImage) -> Self {
let width = gray.width();
let height = gray.height();
let pixels: Vec<bool> = gray.pixels().map(|p| p.0[0] > 0).collect();
Self {
width,
height,
pixels,
}
}
}
pub fn otsu_threshold(gray: &GrayImage) -> u8 {
debug!(
"Calculating Otsu threshold for {}×{} image",
gray.width(),
gray.height()
);
let mut histogram = [0u32; 256];
for pixel in gray.pixels() {
histogram[pixel[0] as usize] += 1;
}
let total_pixels = (gray.width() * gray.height()) as f64;
let sum_total: f64 = histogram
.iter()
.enumerate()
.map(|(i, &count)| i as f64 * count as f64)
.sum();
let mut max_variance = 0.0;
let mut best_threshold = 0u8;
let mut weight_background = 0.0;
let mut sum_background = 0.0;
#[allow(clippy::needless_range_loop)]
for threshold in 0..256 {
weight_background += histogram[threshold] as f64;
if weight_background == 0.0 {
continue;
}
let weight_foreground = total_pixels - weight_background;
if weight_foreground == 0.0 {
break;
}
sum_background += threshold as f64 * histogram[threshold] as f64;
let mean_background = sum_background / weight_background;
let mean_foreground = (sum_total - sum_background) / weight_foreground;
let variance =
weight_background * weight_foreground * (mean_background - mean_foreground).powi(2);
if variance > max_variance {
max_variance = variance;
best_threshold = threshold as u8;
}
}
debug!("Calculated Otsu threshold: {}", best_threshold);
best_threshold
}
pub fn apply_threshold(gray: &GrayImage, threshold: u8) -> BinaryImage {
debug!(
"Applying threshold {} to {}×{} image",
threshold,
gray.width(),
gray.height()
);
let width = gray.width();
let height = gray.height();
let mut binary = BinaryImage::new(width, height);
for (i, pixel) in gray.pixels().enumerate() {
binary.pixels[i] = pixel[0] >= threshold;
}
debug!("Binary conversion complete");
binary
}
pub fn auto_threshold(image: &DynamicImage) -> BinaryImage {
use super::convert::to_grayscale;
debug!("Auto-threshold pipeline starting");
let gray = to_grayscale(image);
let threshold = otsu_threshold(&gray);
let binary = apply_threshold(&gray, threshold);
debug!(
"Auto-threshold pipeline complete (threshold: {})",
threshold
);
binary
}
pub fn adjust_brightness(gray: &GrayImage, factor: f32) -> Result<GrayImage, DotmaxError> {
if !(0.0..=2.0).contains(&factor) {
return Err(DotmaxError::InvalidParameter {
parameter_name: "brightness factor".to_string(),
value: factor.to_string(),
min: "0.0".to_string(),
max: "2.0".to_string(),
});
}
debug!("Adjusting brightness by factor {}", factor);
let (width, height) = gray.dimensions();
let mut adjusted = GrayImage::new(width, height);
for (dest, src) in adjusted.pixels_mut().zip(gray.pixels()) {
let old_value = f32::from(src[0]);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let new_value = (old_value * factor).clamp(0.0, 255.0) as u8;
*dest = Luma([new_value]);
}
Ok(adjusted)
}
pub fn adjust_contrast(gray: &GrayImage, factor: f32) -> Result<GrayImage, DotmaxError> {
if !(0.0..=2.0).contains(&factor) {
return Err(DotmaxError::InvalidParameter {
parameter_name: "contrast factor".to_string(),
value: factor.to_string(),
min: "0.0".to_string(),
max: "2.0".to_string(),
});
}
debug!("Adjusting contrast by factor {}", factor);
let (width, height) = gray.dimensions();
let mut adjusted = GrayImage::new(width, height);
for (dest, src) in adjusted.pixels_mut().zip(gray.pixels()) {
let old_value = f32::from(src[0]);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let new_value = ((old_value - 128.0) * factor + 128.0).clamp(0.0, 255.0) as u8;
*dest = Luma([new_value]);
}
Ok(adjusted)
}
pub fn adjust_gamma(gray: &GrayImage, gamma: f32) -> Result<GrayImage, DotmaxError> {
if !(0.1..=3.0).contains(&gamma) {
return Err(DotmaxError::InvalidParameter {
parameter_name: "gamma".to_string(),
value: gamma.to_string(),
min: "0.1".to_string(),
max: "3.0".to_string(),
});
}
debug!("Applying gamma correction: {}", gamma);
let (width, height) = gray.dimensions();
let mut adjusted = GrayImage::new(width, height);
for (dest, src) in adjusted.pixels_mut().zip(gray.pixels()) {
let normalized = f32::from(src[0]) / 255.0;
let corrected = normalized.powf(gamma);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let new_value = (corrected * 255.0).clamp(0.0, 255.0) as u8;
*dest = Luma([new_value]);
}
Ok(adjusted)
}
#[cfg(test)]
mod tests {
use super::*;
use image::{GrayImage, Luma};
fn create_uniform_gray_image(width: u32, height: u32, value: u8) -> GrayImage {
let mut img = GrayImage::new(width, height);
for pixel in img.pixels_mut() {
*pixel = Luma([value]);
}
img
}
#[test]
fn test_binary_image_new() {
let binary = BinaryImage::new(10, 20);
assert_eq!(binary.width, 10);
assert_eq!(binary.height, 20);
assert_eq!(binary.pixel_count(), 200);
assert!(
binary.pixels.iter().all(|&p| !p),
"All pixels should be false"
);
}
#[test]
fn test_binary_image_get_set_pixel() {
let mut binary = BinaryImage::new(5, 5);
assert!(binary.set_pixel(2, 3, true));
assert_eq!(binary.get_pixel(2, 3), Some(true));
assert!(!binary.set_pixel(10, 10, true));
assert_eq!(binary.get_pixel(10, 10), None);
}
#[test]
fn test_otsu_all_black() {
let img = create_uniform_gray_image(10, 10, 0);
let threshold = otsu_threshold(&img);
assert_eq!(threshold, 0, "All black should return threshold 0");
}
#[test]
fn test_otsu_all_white() {
let img = create_uniform_gray_image(10, 10, 255);
let threshold = otsu_threshold(&img);
assert_eq!(threshold, 0, "All white has no variance, returns 0");
}
#[test]
fn test_otsu_uniform_gray() {
let img = create_uniform_gray_image(10, 10, 128);
let threshold = otsu_threshold(&img);
assert_eq!(threshold, 0);
}
#[test]
fn test_otsu_bimodal_distribution() {
let mut img = GrayImage::new(10, 10);
for y in 0..10 {
for x in 0..10 {
let value = if y < 5 { 50 } else { 200 };
img.put_pixel(x, y, Luma([value]));
}
}
let threshold = otsu_threshold(&img);
assert_eq!(
threshold, 50,
"Bimodal distribution returns first optimal threshold (lower peak)"
);
}
#[test]
fn test_apply_threshold_simple() {
let mut img = GrayImage::new(4, 4);
for y in 0..4 {
for x in 0..4 {
let value = if y < 2 { 200 } else { 50 };
img.put_pixel(x, y, Luma([value]));
}
}
let binary = apply_threshold(&img, 128);
assert_eq!(binary.get_pixel(0, 0), Some(true));
assert_eq!(binary.get_pixel(3, 1), Some(true));
assert_eq!(binary.get_pixel(0, 2), Some(false));
assert_eq!(binary.get_pixel(3, 3), Some(false));
}
#[test]
fn test_auto_threshold_pipeline() {
use image::{DynamicImage, Rgb, RgbImage};
let mut rgb_img = RgbImage::new(10, 10);
for pixel in rgb_img.pixels_mut() {
*pixel = Rgb([128, 128, 128]);
}
let dynamic = DynamicImage::ImageRgb8(rgb_img);
let binary = auto_threshold(&dynamic);
assert_eq!(binary.width, 10);
assert_eq!(binary.height, 10);
assert_eq!(binary.pixel_count(), 100);
}
#[test]
fn test_adjust_brightness_darkens() {
let img = create_uniform_gray_image(5, 5, 100);
let result = adjust_brightness(&img, 0.5).unwrap();
let pixel_value = result.get_pixel(0, 0)[0];
assert_eq!(pixel_value, 50, "Brightness 0.5 should halve values");
}
#[test]
fn test_adjust_brightness_brightens() {
let img = create_uniform_gray_image(5, 5, 100);
let result = adjust_brightness(&img, 1.5).unwrap();
let pixel_value = result.get_pixel(0, 0)[0];
assert_eq!(pixel_value, 150, "Brightness 1.5 should multiply by 1.5");
}
#[test]
fn test_adjust_brightness_no_op() {
let img = create_uniform_gray_image(5, 5, 100);
let result = adjust_brightness(&img, 1.0).unwrap();
let pixel_value = result.get_pixel(0, 0)[0];
assert_eq!(pixel_value, 100, "Brightness 1.0 should not change values");
}
#[test]
fn test_adjust_brightness_invalid_factor() {
let img = create_uniform_gray_image(5, 5, 100);
assert!(adjust_brightness(&img, -0.5).is_err());
assert!(adjust_brightness(&img, 3.0).is_err());
}
#[test]
fn test_adjust_contrast_reduces() {
let img = create_uniform_gray_image(5, 5, 200);
let result = adjust_contrast(&img, 0.5).unwrap();
let pixel_value = result.get_pixel(0, 0)[0];
assert_eq!(pixel_value, 164, "Contrast 0.5 should reduce spread");
}
#[test]
fn test_adjust_contrast_increases() {
let img = create_uniform_gray_image(5, 5, 150);
let result = adjust_contrast(&img, 1.5).unwrap();
let pixel_value = result.get_pixel(0, 0)[0];
assert_eq!(pixel_value, 161, "Contrast 1.5 should increase spread");
}
#[test]
fn test_adjust_contrast_no_op() {
let img = create_uniform_gray_image(5, 5, 150);
let result = adjust_contrast(&img, 1.0).unwrap();
let pixel_value = result.get_pixel(0, 0)[0];
assert_eq!(pixel_value, 150, "Contrast 1.0 should not change values");
}
#[test]
fn test_adjust_gamma_brightens() {
let img = create_uniform_gray_image(5, 5, 100);
let result = adjust_gamma(&img, 0.5).unwrap();
let pixel_value = result.get_pixel(0, 0)[0];
assert!(
pixel_value > 100,
"Gamma < 1.0 should brighten, got {}",
pixel_value
);
}
#[test]
fn test_adjust_gamma_darkens() {
let img = create_uniform_gray_image(5, 5, 150);
let result = adjust_gamma(&img, 2.0).unwrap();
let pixel_value = result.get_pixel(0, 0)[0];
assert!(
pixel_value < 150,
"Gamma > 1.0 should darken, got {}",
pixel_value
);
}
#[test]
fn test_adjust_gamma_no_op() {
let img = create_uniform_gray_image(5, 5, 150);
let result = adjust_gamma(&img, 1.0).unwrap();
let pixel_value = result.get_pixel(0, 0)[0];
assert_eq!(pixel_value, 150, "Gamma 1.0 should not change values");
}
#[test]
fn test_adjust_gamma_invalid() {
let img = create_uniform_gray_image(5, 5, 100);
assert!(adjust_gamma(&img, 0.05).is_err());
assert!(adjust_gamma(&img, 5.0).is_err());
}
}