Files
tools/backend/workers/src/scanner/enhance.rs
T
asepharyanaandKilo a00ad62f6c feat: initial tools service with document scanner, image & PDF tools
Self-hosted document scanner and media processing tools.
- Rust Axum gateway + worker pool with NATS JetStream
- Next.js 16 frontend with shadcn/ui
- Scanner pipeline: edge detection, warp, binarization, OCR
- Image tools: compress, resize, convert
- PDF tools: merge, split, compress
- CI/CD with Docker multi-stage build

Co-Authored-By: Kilo <kilo@kilo.ai>
2026-07-24 13:10:59 +07:00

134 lines
4.3 KiB
Rust

use image::{GrayImage, Luma};
use imageproc::filter::gaussian_blur_f32;
/// Apply final sharpening and contrast optimization.
pub fn enhance_final(img: &GrayImage) -> GrayImage {
let sharpened = unsharp_mask(img, 1.0, 1.0);
adjust_contrast(&sharpened, 1.2)
}
/// Unsharp mask: add high-frequency detail back to the image.
/// result = img + amount * (img - blurred)
pub fn unsharp_mask(img: &GrayImage, sigma: f64, amount: f64) -> GrayImage {
let (w, h) = (img.width(), img.height());
let blurred = gaussian_blur_f32(img, sigma as f32);
let mut output = GrayImage::new(w, h);
for y in 0..h {
for x in 0..w {
let orig = img.get_pixel(x, y)[0] as f64;
let blur = blurred.get_pixel(x, y)[0] as f64;
let mask = orig - blur;
let result = (orig + amount * mask).clamp(0.0, 255.0) as u8;
output.put_pixel(x, y, Luma([result]));
}
}
output
}
/// Adjust contrast by scaling pixel values around the mean.
pub fn adjust_contrast(img: &GrayImage, factor: f64) -> GrayImage {
let (w, h) = (img.width(), img.height());
let mean = mean_value(img);
let mut output = GrayImage::new(w, h);
for y in 0..h {
for x in 0..w {
let pixel = img.get_pixel(x, y)[0] as f64;
let adjusted = ((pixel - mean) * factor + mean).clamp(0.0, 255.0) as u8;
output.put_pixel(x, y, Luma([adjusted]));
}
}
output
}
/// Remove salt-and-pepper noise using a median-like filter.
#[allow(dead_code)]
pub fn remove_noise(img: &GrayImage, threshold: u8) -> GrayImage {
let (w, h) = (img.width(), img.height());
let mut output = GrayImage::new(w, h);
for y in 1..h - 1 {
for x in 1..w - 1 {
let center = img.get_pixel(x, y)[0];
// Check if pixel is significantly different from neighbors
let mut neighbors = Vec::new();
for dy in -1i32..=1 {
for dx in -1i32..=1 {
if dx == 0 && dy == 0 {
continue;
}
neighbors.push(
img.get_pixel((x as i32 + dx) as u32, (y as i32 + dy) as u32)[0],
);
}
}
let min = *neighbors.iter().min().unwrap_or(&0);
let max = *neighbors.iter().max().unwrap_or(&255);
if (center as i16 - min as i16).abs() > threshold as i16
|| (center as i16 - max as i16).abs() > threshold as i16
{
// Replace with median
neighbors.sort();
output.put_pixel(x, y, Luma([neighbors[neighbors.len() / 2]]));
} else {
output.put_pixel(x, y, Luma([center]));
}
}
}
// Copy edges
for x in 0..w {
output.put_pixel(x, 0, *img.get_pixel(x, 0));
output.put_pixel(x, h - 1, *img.get_pixel(x, h - 1));
}
for y in 0..h {
output.put_pixel(0, y, *img.get_pixel(0, y));
output.put_pixel(w - 1, y, *img.get_pixel(w - 1, y));
}
output
}
/// Compute mean pixel value.
fn mean_value(img: &GrayImage) -> f64 {
let sum: u64 = img.iter().map(|&p| p as u64).sum();
let count = img.width() as u64 * img.height() as u64;
if count > 0 {
sum as f64 / count as f64
} else {
128.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unsharp_mask_no_change() {
// Uniform image should remain unchanged
let img = GrayImage::from_pixel(50, 50, Luma([128]));
let result = unsharp_mask(&img, 1.0, 0.0);
assert_eq!(result.get_pixel(25, 25)[0], 128);
}
#[test]
fn test_contrast_increase() {
let mut img = GrayImage::new(10, 10);
img.put_pixel(0, 0, Luma([100]));
img.put_pixel(1, 0, Luma([200]));
let result = adjust_contrast(&img, 2.0);
// With factor > 1, contrast increases
let diff_orig = (200 - 100) as f64;
let diff_result = (result.get_pixel(1, 0)[0] as f64) - (result.get_pixel(0, 0)[0] as f64);
// The difference after contrast adjustment should be greater than original
assert!(
diff_result.abs() > diff_orig.abs() * 0.5,
"diff_orig={}, diff_result={}",
diff_orig,
diff_result
);
}
}