use super::types::{ContentRect, MarginDetection, MarginError, Margins, Result, TrimResult};
use super::{ContentDetectionMode, MarginOptions};
use image::{GenericImageView, GrayImage};
use rayon::prelude::*;
use std::path::{Path, PathBuf};
use super::types::UnifiedMargins;
pub struct ImageMarginDetector;
impl ImageMarginDetector {
pub fn detect(image_path: &Path, options: &MarginOptions) -> Result<MarginDetection> {
if !image_path.exists() {
return Err(MarginError::ImageNotFound(image_path.to_path_buf()));
}
let img = image::open(image_path).map_err(|e| MarginError::InvalidImage(e.to_string()))?;
let gray = img.to_luma8();
let (width, height) = img.dimensions();
let is_background =
|pixel: &image::Luma<u8>| -> bool { pixel.0[0] >= options.background_threshold };
let (top, bottom, left, right) = match options.detection_mode {
ContentDetectionMode::BackgroundColor => {
Self::detect_background_margins(&gray, is_background, options)
}
ContentDetectionMode::EdgeDetection => Self::detect_edge_margins(&gray, options),
ContentDetectionMode::Histogram => Self::detect_histogram_margins(&gray, options),
ContentDetectionMode::Combined => {
let (t1, b1, l1, r1) =
Self::detect_background_margins(&gray, is_background, options);
let (t2, b2, l2, r2) = Self::detect_edge_margins(&gray, options);
((t1 + t2) / 2, (b1 + b2) / 2, (l1 + l2) / 2, (r1 + r2) / 2)
}
};
let margins = Margins {
top: top.max(options.min_margin),
bottom: bottom.max(options.min_margin),
left: left.max(options.min_margin),
right: right.max(options.min_margin),
};
let content_width = width.saturating_sub(margins.total_horizontal());
let content_height = height.saturating_sub(margins.total_vertical());
if content_width == 0 || content_height == 0 {
return Err(MarginError::NoContentDetected);
}
let content_rect = ContentRect {
x: margins.left,
y: margins.top,
width: content_width,
height: content_height,
};
Ok(MarginDetection {
margins,
image_size: (width, height),
content_rect,
confidence: 1.0,
})
}
fn detect_background_margins<F>(
gray: &GrayImage,
is_background: F,
_options: &MarginOptions,
) -> (u32, u32, u32, u32)
where
F: Fn(&image::Luma<u8>) -> bool,
{
let (width, height) = gray.dimensions();
let top = Self::find_content_start_vertical(gray, &is_background, true);
let bottom = height - Self::find_content_start_vertical(gray, &is_background, false);
let left = Self::find_content_start_horizontal(gray, &is_background, true);
let right = width - Self::find_content_start_horizontal(gray, &is_background, false);
(top, bottom, left, right)
}
fn find_content_start_vertical<F>(gray: &GrayImage, is_background: F, from_top: bool) -> u32
where
F: Fn(&image::Luma<u8>) -> bool,
{
let (width, height) = gray.dimensions();
let rows: Box<dyn Iterator<Item = u32>> = if from_top {
Box::new(0..height)
} else {
Box::new((0..height).rev())
};
for y in rows {
let non_bg_count = (0..width)
.filter(|&x| !is_background(gray.get_pixel(x, y)))
.count();
if non_bg_count as f32 / width as f32 > 0.1 {
return if from_top { y } else { height - y };
}
}
0
}
fn find_content_start_horizontal<F>(gray: &GrayImage, is_background: F, from_left: bool) -> u32
where
F: Fn(&image::Luma<u8>) -> bool,
{
let (width, height) = gray.dimensions();
let cols: Box<dyn Iterator<Item = u32>> = if from_left {
Box::new(0..width)
} else {
Box::new((0..width).rev())
};
for x in cols {
let non_bg_count = (0..height)
.filter(|&y| !is_background(gray.get_pixel(x, y)))
.count();
if non_bg_count as f32 / height as f32 > 0.1 {
return if from_left { x } else { width - x };
}
}
0
}
fn detect_edge_margins(gray: &GrayImage, _options: &MarginOptions) -> (u32, u32, u32, u32) {
let (width, height) = gray.dimensions();
let mut has_edge_row = vec![false; height as usize];
let mut has_edge_col = vec![false; width as usize];
for y in 1..height - 1 {
for x in 1..width - 1 {
let center = gray.get_pixel(x, y).0[0] as i32;
let neighbors = [
gray.get_pixel(x - 1, y).0[0] as i32,
gray.get_pixel(x + 1, y).0[0] as i32,
gray.get_pixel(x, y - 1).0[0] as i32,
gray.get_pixel(x, y + 1).0[0] as i32,
];
let max_diff = neighbors
.iter()
.map(|&n| (n - center).abs())
.max()
.unwrap_or(0);
if max_diff > 30 {
has_edge_row[y as usize] = true;
has_edge_col[x as usize] = true;
}
}
}
let top = has_edge_row.iter().position(|&e| e).unwrap_or(0) as u32;
let bottom = height
- has_edge_row
.iter()
.rposition(|&e| e)
.map(|p| p + 1)
.unwrap_or(height as usize) as u32;
let left = has_edge_col.iter().position(|&e| e).unwrap_or(0) as u32;
let right = width
- has_edge_col
.iter()
.rposition(|&e| e)
.map(|p| p + 1)
.unwrap_or(width as usize) as u32;
(top, bottom, left, right)
}
fn detect_histogram_margins(gray: &GrayImage, options: &MarginOptions) -> (u32, u32, u32, u32) {
let is_background = |pixel: &image::Luma<u8>| -> bool {
pixel.0[0] >= options.background_threshold.saturating_sub(10)
};
Self::detect_background_margins(gray, is_background, options)
}
pub fn detect_unified(images: &[PathBuf], options: &MarginOptions) -> Result<UnifiedMargins> {
let detections: Vec<MarginDetection> = images
.par_iter()
.map(|path| Self::detect(path, options))
.collect::<Result<Vec<_>>>()?;
let margins = Margins {
top: detections.iter().map(|d| d.margins.top).min().unwrap_or(0),
bottom: detections
.iter()
.map(|d| d.margins.bottom)
.min()
.unwrap_or(0),
left: detections.iter().map(|d| d.margins.left).min().unwrap_or(0),
right: detections
.iter()
.map(|d| d.margins.right)
.min()
.unwrap_or(0),
};
let max_content_width = detections
.iter()
.map(|d| d.content_rect.width)
.max()
.unwrap_or(0);
let max_content_height = detections
.iter()
.map(|d| d.content_rect.height)
.max()
.unwrap_or(0);
Ok(UnifiedMargins {
margins,
page_detections: detections,
unified_size: (max_content_width, max_content_height),
})
}
pub fn trim(input_path: &Path, output_path: &Path, margins: &Margins) -> Result<TrimResult> {
if !input_path.exists() {
return Err(MarginError::ImageNotFound(input_path.to_path_buf()));
}
let img = image::open(input_path).map_err(|e| MarginError::InvalidImage(e.to_string()))?;
let (width, height) = img.dimensions();
let original_size = (width, height);
let crop_width = width.saturating_sub(margins.total_horizontal());
let crop_height = height.saturating_sub(margins.total_vertical());
if crop_width == 0 || crop_height == 0 {
return Err(MarginError::NoContentDetected);
}
let cropped = img.crop_imm(margins.left, margins.top, crop_width, crop_height);
let trimmed_size = (cropped.width(), cropped.height());
cropped
.save(output_path)
.map_err(|e| MarginError::InvalidImage(e.to_string()))?;
Ok(TrimResult {
input_path: input_path.to_path_buf(),
output_path: output_path.to_path_buf(),
original_size,
trimmed_size,
margins_applied: *margins,
})
}
pub fn pad_to_size(
input_path: &Path,
output_path: &Path,
target_size: (u32, u32),
background: [u8; 3],
) -> Result<TrimResult> {
if !input_path.exists() {
return Err(MarginError::ImageNotFound(input_path.to_path_buf()));
}
let img = image::open(input_path).map_err(|e| MarginError::InvalidImage(e.to_string()))?;
let original_size = (img.width(), img.height());
let (target_w, target_h) = target_size;
let mut padded = image::RgbImage::new(target_w, target_h);
for pixel in padded.pixels_mut() {
*pixel = image::Rgb(background);
}
let offset_x = (target_w.saturating_sub(img.width())) / 2;
let offset_y = (target_h.saturating_sub(img.height())) / 2;
let rgb = img.to_rgb8();
for y in 0..img.height().min(target_h) {
for x in 0..img.width().min(target_w) {
let px = x + offset_x;
let py = y + offset_y;
if px < target_w && py < target_h {
padded.put_pixel(px, py, *rgb.get_pixel(x, y));
}
}
}
padded
.save(output_path)
.map_err(|e| MarginError::InvalidImage(e.to_string()))?;
let margins_applied = Margins {
top: offset_y,
bottom: target_h.saturating_sub(img.height() + offset_y),
left: offset_x,
right: target_w.saturating_sub(img.width() + offset_x),
};
Ok(TrimResult {
input_path: input_path.to_path_buf(),
output_path: output_path.to_path_buf(),
original_size,
trimmed_size: target_size,
margins_applied,
})
}
pub fn process_batch(
images: &[(PathBuf, PathBuf)],
options: &MarginOptions,
) -> Result<Vec<TrimResult>> {
let input_paths: Vec<PathBuf> = images.iter().map(|(i, _)| i.clone()).collect();
let unified = Self::detect_unified(&input_paths, options)?;
let results: Vec<TrimResult> = images
.iter()
.map(|(input, output)| Self::trim(input, output, &unified.margins))
.collect::<Result<Vec<_>>>()?;
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_nonexistent_file() {
let options = MarginOptions::default();
let result = ImageMarginDetector::detect(Path::new("/nonexistent/image.png"), &options);
assert!(matches!(result, Err(MarginError::ImageNotFound(_))));
}
#[test]
fn test_trim_nonexistent_file() {
let margins = Margins::uniform(10);
let result = ImageMarginDetector::trim(
Path::new("/nonexistent.png"),
Path::new("/out.png"),
&margins,
);
assert!(matches!(result, Err(MarginError::ImageNotFound(_))));
}
#[test]
fn test_pad_nonexistent_file() {
let result = ImageMarginDetector::pad_to_size(
Path::new("/nonexistent.png"),
Path::new("/out.png"),
(100, 100),
[255, 255, 255],
);
assert!(matches!(result, Err(MarginError::ImageNotFound(_))));
}
}