Implement self-hosted document scanner and media processing tools as an alternative to CamScanner/ilovepdf without third-party uploads. Backend: Rust Axum gateway + worker pool with NATS JetStream queue Frontend: Next.js 16 + shadcn/ui + Tailwind v4 + Framer Motion Pipeline: Canny edge detection -> DLT homography warp -> Sauvola binarization -> Hough deskew -> Tesseract OCR -> searchable PDF Phase 1 (MVP) delivers: - Document scanner with perspective correction and OCR - Image compress/resize/convert tools - PDF merge/split/compress tools - Real-time WebSocket progress updates - Rate limiting, auto-cleanup, Prometheus metrics - Full CI/CD pipeline with Docker multi-stage build Co-Authored-By: Kilo <kilo@kilo.ai>
161 lines
5.2 KiB
Rust
161 lines
5.2 KiB
Rust
use image::{GrayImage, Luma};
|
|
use imageproc::filter::gaussian_blur_f32;
|
|
|
|
/// Remove uneven lighting and shadows from a grayscale document image.
|
|
///
|
|
/// Algorithm:
|
|
/// 1. Large Gaussian blur to estimate background illumination
|
|
/// 2. Subtract background from original
|
|
/// 3. Apply CLAHE for local contrast normalization
|
|
pub fn remove_shadow(img: &GrayImage) -> GrayImage {
|
|
let (w, h) = (img.width(), img.height());
|
|
|
|
// 1. Large Gaussian blur for illumination estimate
|
|
let blur_radius = (w.min(h) as f64 / 50.0).max(15.0);
|
|
let background = gaussian_blur_f32(img, blur_radius as f32);
|
|
|
|
// 2. Subtract background
|
|
let bg_mean = mean_pixel(&background);
|
|
let mut corrected = GrayImage::new(w, h);
|
|
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
let orig = img.get_pixel(x, y)[0] as f32;
|
|
let bg = background.get_pixel(x, y)[0] as f32;
|
|
let corrected_val = (orig - bg + bg_mean).clamp(0.0, 255.0) as u8;
|
|
corrected.put_pixel(x, y, Luma([corrected_val]));
|
|
}
|
|
}
|
|
|
|
// 3. Apply CLAHE
|
|
apply_clahe(&corrected, 8, 4)
|
|
}
|
|
|
|
/// Compute mean pixel value of a grayscale image.
|
|
fn mean_pixel(img: &GrayImage) -> f32 {
|
|
let sum: u32 = img.iter().map(|&p| p as u32).sum();
|
|
let count = img.width() * img.height();
|
|
if count > 0 {
|
|
sum as f32 / count as f32
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
|
|
/// Contrast Limited Adaptive Histogram Equalization.
|
|
/// Divides the image into tiles and applies histogram equalization to each.
|
|
fn apply_clahe(img: &GrayImage, tile_size: u32, clip_limit: u8) -> GrayImage {
|
|
let (w, h) = (img.width(), img.height());
|
|
let tiles_x = (w + tile_size - 1) / tile_size;
|
|
let tiles_y = (h + tile_size - 1) / tile_size;
|
|
|
|
let mut output = GrayImage::new(w, h);
|
|
|
|
for ty in 0..tiles_y {
|
|
for tx in 0..tiles_x {
|
|
let start_x = tx * tile_size;
|
|
let start_y = ty * tile_size;
|
|
let end_x = (start_x + tile_size).min(w);
|
|
let end_y = (start_y + tile_size).min(h);
|
|
|
|
// Compute histogram for this tile
|
|
let mut hist = [0u32; 256];
|
|
for y in start_y..end_y {
|
|
for x in start_x..end_x {
|
|
hist[img.get_pixel(x, y)[0] as usize] += 1;
|
|
}
|
|
}
|
|
|
|
// Clip histogram
|
|
let tile_pixels = (end_x - start_x) * (end_y - start_y);
|
|
let clip_limit_count = tile_pixels as u32 * clip_limit as u32 / 255 / 10;
|
|
let mut excess = 0u32;
|
|
for count in hist.iter_mut() {
|
|
if *count > clip_limit_count {
|
|
excess += *count - clip_limit_count;
|
|
*count = clip_limit_count;
|
|
}
|
|
}
|
|
// Redistribute excess
|
|
let add_per_bin = excess / 256;
|
|
for count in hist.iter_mut() {
|
|
*count += add_per_bin;
|
|
}
|
|
|
|
// Build CDF
|
|
let mut cdf = [0u32; 256];
|
|
cdf[0] = hist[0];
|
|
for i in 1..256 {
|
|
cdf[i] = cdf[i - 1] + hist[i];
|
|
}
|
|
let cdf_min = cdf.iter().find(|&&v| v > 0).copied().unwrap_or(0);
|
|
|
|
// Apply equalization to this tile
|
|
for y in start_y..end_y {
|
|
for x in start_x..end_x {
|
|
let pixel = img.get_pixel(x, y)[0] as usize;
|
|
let equalized = if cdf_max(cdf) > cdf_min {
|
|
((cdf[pixel].saturating_sub(cdf_min)) as f64
|
|
/ (cdf_max(cdf).saturating_sub(cdf_min)) as f64
|
|
* 255.0) as u8
|
|
} else {
|
|
pixel as u8
|
|
};
|
|
output.put_pixel(x, y, Luma([equalized]));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
output
|
|
}
|
|
|
|
/// Get the maximum value in the CDF array.
|
|
fn cdf_max(cdf: [u32; 256]) -> u32 {
|
|
*cdf.iter().max().unwrap_or(&0)
|
|
}
|
|
|
|
/// Retinex-based shadow removal (alternative algorithm).
|
|
#[allow(dead_code)]
|
|
fn retinex_shadow_removal(img: &GrayImage) -> GrayImage {
|
|
let (w, h) = (img.width(), img.height());
|
|
let blurred = gaussian_blur_f32(img, 30.0);
|
|
|
|
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 f32;
|
|
let bg = blurred.get_pixel(x, y)[0] as f32;
|
|
if bg > 0.0 {
|
|
let retinex = (orig / bg).ln() * 255.0;
|
|
output.put_pixel(x, y, Luma([retinex.clamp(0.0, 255.0) as u8]));
|
|
} else {
|
|
output.put_pixel(x, y, Luma([0]));
|
|
}
|
|
}
|
|
}
|
|
output
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_shadow_removal_uniform() {
|
|
// Uniform image should remain uniform
|
|
let img = GrayImage::from_pixel(100, 100, Luma([128]));
|
|
let result = remove_shadow(&img);
|
|
assert_eq!(result.width(), 100);
|
|
assert_eq!(result.height(), 100);
|
|
// The result should have fewer dark pixels than a shadowed version
|
|
let dark_count = result.iter().filter(|&&p| p < 50).count();
|
|
assert!(dark_count < 100); // Very few dark pixels
|
|
}
|
|
|
|
#[test]
|
|
fn test_mean_pixel() {
|
|
let img = GrayImage::from_pixel(10, 10, Luma([100]));
|
|
assert!((mean_pixel(&img) - 100.0).abs() < 1.0);
|
|
}
|
|
} |