diff --git a/src/config.rs b/src/config.rs index 22654fc..a97bf29 100644 --- a/src/config.rs +++ b/src/config.rs @@ -174,9 +174,9 @@ pub struct BlurConfig { /// Whether blur detection is enabled. pub enabled: bool, - /// Minimum gradient magnitude threshold. - /// Images with gradient magnitude below this are considered blurry. - /// Uses Sobel operator for robust edge detection. + /// Minimum Laplacian variance threshold. + /// Images with Laplacian variance below this are considered blurry. + /// Typical values: < 100 = blurry, 100–500 = borderline, > 500 = sharp. pub min_sharpness: f32, } @@ -184,7 +184,7 @@ impl Default for BlurConfig { fn default() -> Self { Self { enabled: true, - min_sharpness: 20.0, + min_sharpness: 100.0, } } } diff --git a/src/pipeline/steps/blur.rs b/src/pipeline/steps/blur.rs index e789dac..50ea72f 100644 --- a/src/pipeline/steps/blur.rs +++ b/src/pipeline/steps/blur.rs @@ -1,11 +1,11 @@ //! Blur detection step. //! -//! Detects blurry faces using gradient magnitude analysis within the face region: +//! Detects blurry faces using Laplacian variance analysis within the face region: //! 1. Convert image to grayscale -//! 2. Apply Sobel operator (Sobel-X and Sobel-Y) within the face bounding box -//! 3. Compute gradient magnitude for each pixel: sqrt(gx² + gy²) -//! 4. Calculate mean gradient magnitude -//! 5. Low gradient magnitude → blurry face (weak, spread-out edges) +//! 2. Apply Laplacian operator within the face bounding box +//! 3. Compute variance of the Laplacian response across all pixels +//! 4. Low variance → blurry image (weak edge response, values clustered near zero) +//! 5. High variance → sharp image (strong edges mixed with smooth regions) use crate::config::Config; use crate::pipeline::{ @@ -18,24 +18,25 @@ use image::{DynamicImage, Rgb}; pub struct BlurStep; impl BlurStep { - /// Calculate the mean gradient magnitude of a specific region within an image. + /// Calculate the Laplacian variance of a specific region within an image. /// /// This method: /// 1. Converts the image to grayscale - /// 2. Applies Sobel-X and Sobel-Y kernels within the specified region: - /// Sobel-X: [-1 0 1] Sobel-Y: [-1 -2 -1] - /// [-2 0 2] [ 0 0 0] - /// [-1 0 1] [ 1 2 1] - /// 3. Computes gradient magnitude for each pixel: sqrt(gx² + gy²) - /// 4. Returns the mean gradient magnitude + /// 2. Applies the discrete Laplacian kernel within the specified region: + /// [ 0 1 0] + /// [ 1 -4 1] + /// [ 0 1 0] + /// 3. Computes the variance of the Laplacian response across all pixels /// - /// Higher gradient magnitude = sharper image (strong, concentrated edges) - /// Lower gradient magnitude = blurrier image (weak, spread-out edges) + /// Variance is used rather than mean because: + /// - Blurry images: Laplacian values all near zero → low variance + /// - Sharp images: strong responses at edges, near-zero on smooth skin → high variance + /// - Mean can be near-zero for both (positive and negative Laplacian values cancel out) /// /// # Arguments /// * `image` - The full image /// * `x1`, `y1`, `x2`, `y2` - Bounding box coordinates (pixels, clamped to image bounds) - fn calculate_gradient_magnitude_in_region( + fn calculate_laplacian_variance_in_region( image: &DynamicImage, x1: u32, y1: u32, @@ -56,41 +57,32 @@ impl BlurStep { return 0.0; } - // Apply Sobel filter within the region and compute gradient magnitudes - let mut sum_magnitude = 0.0f32; - let mut count = 0usize; + // Apply Laplacian kernel and collect responses + // Kernel: [0, 1, 0; 1, -4, 1; 0, 1, 0] + let mut values = Vec::with_capacity(((x2 - x1) * (y2 - y1)) as usize); for y in (y1 + 1)..(y2 - 1) { for x in (x1 + 1)..(x2 - 1) { - // Get the 3x3 neighborhood - let p00 = gray.get_pixel(x - 1, y - 1)[0] as i32; - let p01 = gray.get_pixel(x, y - 1)[0] as i32; - let p02 = gray.get_pixel(x + 1, y - 1)[0] as i32; - let p10 = gray.get_pixel(x - 1, y)[0] as i32; - let p12 = gray.get_pixel(x + 1, y)[0] as i32; - let p20 = gray.get_pixel(x - 1, y + 1)[0] as i32; - let p21 = gray.get_pixel(x, y + 1)[0] as i32; - let p22 = gray.get_pixel(x + 1, y + 1)[0] as i32; + let center = gray.get_pixel(x, y)[0] as i32; + let top = gray.get_pixel(x, y - 1)[0] as i32; + let bottom = gray.get_pixel(x, y + 1)[0] as i32; + let left = gray.get_pixel(x - 1, y)[0] as i32; + let right = gray.get_pixel(x + 1, y)[0] as i32; - // Apply Sobel-X kernel: [-1 0 1; -2 0 2; -1 0 1] - let gx = -p00 + p02 - 2 * p10 + 2 * p12 - p20 + p22; - - // Apply Sobel-Y kernel: [-1 -2 -1; 0 0 0; 1 2 1] - let gy = -p00 - 2 * p01 - p02 + p20 + 2 * p21 + p22; - - // Compute gradient magnitude: sqrt(gx² + gy²) - let magnitude = ((gx * gx + gy * gy) as f32).sqrt(); - sum_magnitude += magnitude; - count += 1; + let laplacian = (top + bottom + left + right - 4 * center) as f32; + values.push(laplacian); } } - if count == 0 { + if values.is_empty() { return 0.0; } - // Return mean gradient magnitude - sum_magnitude / count as f32 + // Compute variance: E[X²] - E[X]² + let n = values.len() as f32; + let mean = values.iter().sum::() / n; + let variance = values.iter().map(|v| (v - mean).powi(2)).sum::() / n; + variance } } @@ -119,22 +111,37 @@ impl ProcessingStep for BlurStep { let (img_width, img_height) = (image.width(), image.height()); let (x1, y1, x2, y2) = face_rect_pixels(&ctx, img_width, img_height); - let gradient_magnitude = - Self::calculate_gradient_magnitude_in_region(image, x1, y1, x2, y2); + let laplacian_variance = + Self::calculate_laplacian_variance_in_region(image, x1, y1, x2, y2); - // Store computed gradient magnitude for potential use by other steps + // Normalize for upscaling only. + // When the crop was upscaled to output_size (scale > 1), Lanczos3 smooths edges + // and artificially reduces sharpness metrics. For variance, the correction is + // scale² (variance of k·X = k²·variance(X)), so we multiply by scale². + // + // We do NOT correct for downscaling (scale < 1): downsampling doesn't + // meaningfully inflate variance, and dividing by scale² < 1 would penalize + // images where a large crop was taken, producing false blurry rejections. + let crop_scale = ctx + .get_computed(computed_keys::CROP_SCALE) + .and_then(|v| v.as_float()) + .unwrap_or(1.0); + let scale_factor = crop_scale.max(1.0); + let normalized_variance = laplacian_variance * scale_factor * scale_factor; + + // Store computed variance for potential use by other steps ctx.set_computed( computed_keys::BLUR_METRIC, - ComputedValue::Float(gradient_magnitude), + ComputedValue::Float(normalized_variance), ); - if gradient_magnitude < step_config.min_sharpness { + if normalized_variance < step_config.min_sharpness { return StepOutcome::Skip { ctx, reason: "too_blurry".to_string(), detail: Some(format!( - "gradient: {:.1} (min: {:.1})", - gradient_magnitude, step_config.min_sharpness + "laplacian_var: {:.1} (raw: {:.1}, scale: {:.2}, min: {:.1})", + normalized_variance, laplacian_variance, crop_scale, step_config.min_sharpness )), }; } @@ -211,11 +218,13 @@ impl ProcessingStep for BlurStep { debug_img.put_pixel(bar_x + bar_width - 1, y, Rgb([200, 200, 200])); } - // Fill the bar based on gradient magnitude value (scale: 0-50 maps to 0-100% bar) - let max_gradient = 50.0; - let normalized = (gradient_mag / max_gradient).clamp(0.0, 1.0); + // Fill the bar based on Laplacian variance (log scale: 1–10000 maps to 0–100%) + // Using log scale because variance spans several orders of magnitude. + let log_variance = (gradient_mag + 1.0).ln(); + let log_max = (10000.0f32 + 1.0).ln(); + let normalized = (log_variance / log_max).clamp(0.0, 1.0); let fill_width = ((bar_width - 4) as f32 * normalized) as u32; - let fill_color = gradient_to_color(gradient_mag); + let fill_color = variance_to_color(gradient_mag); for y in (outline_y + 2)..(outline_y + outline_height - 2) { for x in (bar_x + 2)..(bar_x + 2 + fill_width) { if x < width { @@ -224,25 +233,23 @@ impl ProcessingStep for BlurStep { } } - // Draw gradient magnitude text value - let text = format!("{:.1}", gradient_mag); + // Draw Laplacian variance text value + let text = format!("{:.0}", gradient_mag); draw_simple_text(&mut debug_img, 5, bar_y + 6, &text, Rgb([255, 255, 255])); Some(DynamicImage::ImageRgb8(debug_img)) } } -/// Convert gradient magnitude value to a color (red for blurry, green for sharp) -fn gradient_to_color(gradient_mag: f32) -> Rgb { - // Very low gradient (< 10) = red (blurry) - // Medium gradient (10-20) = yellow/orange - // High gradient (> 20) = green (sharp) - if gradient_mag < 10.0 { - Rgb([255, 80, 80]) // Red - } else if gradient_mag < 20.0 { - Rgb([255, 200, 80]) // Yellow/orange +/// Convert Laplacian variance to a color (red for blurry, green for sharp). +/// Typical ranges: < 100 = blurry, 100–500 = borderline, > 500 = sharp. +fn variance_to_color(variance: f32) -> Rgb { + if variance < 100.0 { + Rgb([255, 80, 80]) // Red (blurry) + } else if variance < 500.0 { + Rgb([255, 200, 80]) // Yellow/orange (borderline) } else { - Rgb([80, 255, 80]) // Green + Rgb([80, 255, 80]) // Green (sharp) } } @@ -283,37 +290,36 @@ mod tests { } #[test] - fn test_calculate_gradient_magnitude_uniform() { - // Uniform image should have very low gradient magnitude (no edges) + fn test_laplacian_variance_uniform() { + // Uniform image: Laplacian is zero everywhere → variance is zero let img = create_solid_image(128, 128, 128); - let gradient_mag = BlurStep::calculate_gradient_magnitude_in_region(&img, 0, 0, 100, 100); + let variance = BlurStep::calculate_laplacian_variance_in_region(&img, 0, 0, 100, 100); assert!( - gradient_mag < 1.0, - "Uniform image should have near-zero gradient magnitude" + variance < 1.0, + "Uniform image should have near-zero Laplacian variance, got {variance}" ); } #[test] - fn test_calculate_gradient_magnitude_sharp() { - // Checkerboard should have high gradient magnitude (many edges) + fn test_laplacian_variance_sharp() { + // Checkerboard: strong Laplacian response at edges → high variance let img = create_checkerboard_image(); - let gradient_mag = BlurStep::calculate_gradient_magnitude_in_region(&img, 0, 0, 100, 100); + let variance = BlurStep::calculate_laplacian_variance_in_region(&img, 0, 0, 100, 100); assert!( - gradient_mag > 15.0, - "Sharp checkerboard should have high gradient magnitude, got {}", - gradient_mag + variance > 100.0, + "Sharp checkerboard should have high Laplacian variance, got {variance}" ); } #[tokio::test] async fn test_blur_too_blurry() { let step = BlurStep; - let img = create_solid_image(128, 128, 128); // Very low gradient magnitude + let img = create_solid_image(128, 128, 128); // Zero Laplacian variance let ctx = make_ctx_with_image(img); let mut config = Config::default(); config.processing.blur.enabled = true; - config.processing.blur.min_sharpness = 15.0; + config.processing.blur.min_sharpness = 50.0; match step.execute(ctx, &config).await { StepOutcome::Skip { reason, .. } => { @@ -326,12 +332,12 @@ mod tests { #[tokio::test] async fn test_blur_sharp_image() { let step = BlurStep; - let img = create_checkerboard_image(); // High gradient magnitude + let img = create_checkerboard_image(); // High Laplacian variance let ctx = make_ctx_with_image(img); let mut config = Config::default(); config.processing.blur.enabled = true; - config.processing.blur.min_sharpness = 15.0; + config.processing.blur.min_sharpness = 50.0; match step.execute(ctx, &config).await { StepOutcome::Continue(_) => {} // Should pass diff --git a/src/pipeline/steps/crop_and_resize.rs b/src/pipeline/steps/crop_and_resize.rs index f076d79..71e43a7 100644 --- a/src/pipeline/steps/crop_and_resize.rs +++ b/src/pipeline/steps/crop_and_resize.rs @@ -71,6 +71,13 @@ impl ProcessingStep for CropAndResizeStep { // Scale the face rectangle to match the resized image coordinates let scale = output_size as f32 / cropped_size as f32; + + // Store scale for downstream steps (e.g. blur normalization) + ctx.set_computed( + computed_keys::CROP_SCALE, + ComputedValue::Float(scale), + ); + let scaled_face_rect = BoundingBox { x1: crop_result.face_rect.x1 * scale, y1: crop_result.face_rect.y1 * scale, diff --git a/src/pipeline/traits.rs b/src/pipeline/traits.rs index 0c90143..4c89248 100644 --- a/src/pipeline/traits.rs +++ b/src/pipeline/traits.rs @@ -31,6 +31,9 @@ pub mod computed_keys { pub const FACE_RECT: &str = "face_rect"; /// Per-edge padding fractions computed by CropAndResizeStep. pub const PADDING_EDGES: &str = "padding_edges"; + /// Scale factor applied when resizing the crop to output_size (output_size / crop_size). + /// Values > 1 mean the crop was upscaled; values < 1 mean it was downscaled. + pub const CROP_SCALE: &str = "crop_scale"; } /// Outcome of a pipeline step execution.