Files
infra/apps/tools/backend/workers/src/scanner/shadow.rs
T
asepharyanaandKilo 67288c8723 feat(infra): add tools service with document scanner, image & PDF tools
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>
2026-07-24 13:08:09 +07:00

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);
}
}