Working on blury image detection

This commit is contained in:
Arnaud_Cayrol 2026-02-15 20:59:45 +01:00
parent 5aea6ab176
commit 49385782af
4 changed files with 98 additions and 82 deletions

View file

@ -174,9 +174,9 @@ pub struct BlurConfig {
/// Whether blur detection is enabled. /// Whether blur detection is enabled.
pub enabled: bool, pub enabled: bool,
/// Minimum gradient magnitude threshold. /// Minimum Laplacian variance threshold.
/// Images with gradient magnitude below this are considered blurry. /// Images with Laplacian variance below this are considered blurry.
/// Uses Sobel operator for robust edge detection. /// Typical values: < 100 = blurry, 100500 = borderline, > 500 = sharp.
pub min_sharpness: f32, pub min_sharpness: f32,
} }
@ -184,7 +184,7 @@ impl Default for BlurConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: true, enabled: true,
min_sharpness: 20.0, min_sharpness: 100.0,
} }
} }
} }

View file

@ -1,11 +1,11 @@
//! Blur detection step. //! 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 //! 1. Convert image to grayscale
//! 2. Apply Sobel operator (Sobel-X and Sobel-Y) within the face bounding box //! 2. Apply Laplacian operator within the face bounding box
//! 3. Compute gradient magnitude for each pixel: sqrt(gx² + gy²) //! 3. Compute variance of the Laplacian response across all pixels
//! 4. Calculate mean gradient magnitude //! 4. Low variance → blurry image (weak edge response, values clustered near zero)
//! 5. Low gradient magnitude → blurry face (weak, spread-out edges) //! 5. High variance → sharp image (strong edges mixed with smooth regions)
use crate::config::Config; use crate::config::Config;
use crate::pipeline::{ use crate::pipeline::{
@ -18,24 +18,25 @@ use image::{DynamicImage, Rgb};
pub struct BlurStep; pub struct BlurStep;
impl 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: /// This method:
/// 1. Converts the image to grayscale /// 1. Converts the image to grayscale
/// 2. Applies Sobel-X and Sobel-Y kernels within the specified region: /// 2. Applies the discrete Laplacian kernel within the specified region:
/// Sobel-X: [-1 0 1] Sobel-Y: [-1 -2 -1] /// [ 0 1 0]
/// [-2 0 2] [ 0 0 0] /// [ 1 -4 1]
/// [-1 0 1] [ 1 2 1] /// [ 0 1 0]
/// 3. Computes gradient magnitude for each pixel: sqrt(gx² + gy²) /// 3. Computes the variance of the Laplacian response across all pixels
/// 4. Returns the mean gradient magnitude
/// ///
/// Higher gradient magnitude = sharper image (strong, concentrated edges) /// Variance is used rather than mean because:
/// Lower gradient magnitude = blurrier image (weak, spread-out edges) /// - 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 /// # Arguments
/// * `image` - The full image /// * `image` - The full image
/// * `x1`, `y1`, `x2`, `y2` - Bounding box coordinates (pixels, clamped to image bounds) /// * `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, image: &DynamicImage,
x1: u32, x1: u32,
y1: u32, y1: u32,
@ -56,41 +57,32 @@ impl BlurStep {
return 0.0; return 0.0;
} }
// Apply Sobel filter within the region and compute gradient magnitudes // Apply Laplacian kernel and collect responses
let mut sum_magnitude = 0.0f32; // Kernel: [0, 1, 0; 1, -4, 1; 0, 1, 0]
let mut count = 0usize; let mut values = Vec::with_capacity(((x2 - x1) * (y2 - y1)) as usize);
for y in (y1 + 1)..(y2 - 1) { for y in (y1 + 1)..(y2 - 1) {
for x in (x1 + 1)..(x2 - 1) { for x in (x1 + 1)..(x2 - 1) {
// Get the 3x3 neighborhood let center = gray.get_pixel(x, y)[0] as i32;
let p00 = gray.get_pixel(x - 1, y - 1)[0] as i32; let top = gray.get_pixel(x, y - 1)[0] as i32;
let p01 = gray.get_pixel(x, y - 1)[0] as i32; let bottom = gray.get_pixel(x, y + 1)[0] as i32;
let p02 = gray.get_pixel(x + 1, y - 1)[0] as i32; let left = gray.get_pixel(x - 1, y)[0] as i32;
let p10 = gray.get_pixel(x - 1, y)[0] as i32; let right = 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;
// Apply Sobel-X kernel: [-1 0 1; -2 0 2; -1 0 1] let laplacian = (top + bottom + left + right - 4 * center) as f32;
let gx = -p00 + p02 - 2 * p10 + 2 * p12 - p20 + p22; values.push(laplacian);
// 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;
} }
} }
if count == 0 { if values.is_empty() {
return 0.0; return 0.0;
} }
// Return mean gradient magnitude // Compute variance: E[X²] - E[X]²
sum_magnitude / count as f32 let n = values.len() as f32;
let mean = values.iter().sum::<f32>() / n;
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / n;
variance
} }
} }
@ -119,22 +111,37 @@ impl ProcessingStep for BlurStep {
let (img_width, img_height) = (image.width(), image.height()); let (img_width, img_height) = (image.width(), image.height());
let (x1, y1, x2, y2) = face_rect_pixels(&ctx, img_width, img_height); let (x1, y1, x2, y2) = face_rect_pixels(&ctx, img_width, img_height);
let gradient_magnitude = let laplacian_variance =
Self::calculate_gradient_magnitude_in_region(image, x1, y1, x2, y2); 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( ctx.set_computed(
computed_keys::BLUR_METRIC, 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 { return StepOutcome::Skip {
ctx, ctx,
reason: "too_blurry".to_string(), reason: "too_blurry".to_string(),
detail: Some(format!( detail: Some(format!(
"gradient: {:.1} (min: {:.1})", "laplacian_var: {:.1} (raw: {:.1}, scale: {:.2}, min: {:.1})",
gradient_magnitude, step_config.min_sharpness 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])); 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) // Fill the bar based on Laplacian variance (log scale: 110000 maps to 0100%)
let max_gradient = 50.0; // Using log scale because variance spans several orders of magnitude.
let normalized = (gradient_mag / max_gradient).clamp(0.0, 1.0); 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_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 y in (outline_y + 2)..(outline_y + outline_height - 2) {
for x in (bar_x + 2)..(bar_x + 2 + fill_width) { for x in (bar_x + 2)..(bar_x + 2 + fill_width) {
if x < width { if x < width {
@ -224,25 +233,23 @@ impl ProcessingStep for BlurStep {
} }
} }
// Draw gradient magnitude text value // Draw Laplacian variance text value
let text = format!("{:.1}", gradient_mag); let text = format!("{:.0}", gradient_mag);
draw_simple_text(&mut debug_img, 5, bar_y + 6, &text, Rgb([255, 255, 255])); draw_simple_text(&mut debug_img, 5, bar_y + 6, &text, Rgb([255, 255, 255]));
Some(DynamicImage::ImageRgb8(debug_img)) Some(DynamicImage::ImageRgb8(debug_img))
} }
} }
/// Convert gradient magnitude value to a color (red for blurry, green for sharp) /// Convert Laplacian variance to a color (red for blurry, green for sharp).
fn gradient_to_color(gradient_mag: f32) -> Rgb<u8> { /// Typical ranges: < 100 = blurry, 100500 = borderline, > 500 = sharp.
// Very low gradient (< 10) = red (blurry) fn variance_to_color(variance: f32) -> Rgb<u8> {
// Medium gradient (10-20) = yellow/orange if variance < 100.0 {
// High gradient (> 20) = green (sharp) Rgb([255, 80, 80]) // Red (blurry)
if gradient_mag < 10.0 { } else if variance < 500.0 {
Rgb([255, 80, 80]) // Red Rgb([255, 200, 80]) // Yellow/orange (borderline)
} else if gradient_mag < 20.0 {
Rgb([255, 200, 80]) // Yellow/orange
} else { } else {
Rgb([80, 255, 80]) // Green Rgb([80, 255, 80]) // Green (sharp)
} }
} }
@ -283,37 +290,36 @@ mod tests {
} }
#[test] #[test]
fn test_calculate_gradient_magnitude_uniform() { fn test_laplacian_variance_uniform() {
// Uniform image should have very low gradient magnitude (no edges) // Uniform image: Laplacian is zero everywhere → variance is zero
let img = create_solid_image(128, 128, 128); 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!( assert!(
gradient_mag < 1.0, variance < 1.0,
"Uniform image should have near-zero gradient magnitude" "Uniform image should have near-zero Laplacian variance, got {variance}"
); );
} }
#[test] #[test]
fn test_calculate_gradient_magnitude_sharp() { fn test_laplacian_variance_sharp() {
// Checkerboard should have high gradient magnitude (many edges) // Checkerboard: strong Laplacian response at edges → high variance
let img = create_checkerboard_image(); 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!( assert!(
gradient_mag > 15.0, variance > 100.0,
"Sharp checkerboard should have high gradient magnitude, got {}", "Sharp checkerboard should have high Laplacian variance, got {variance}"
gradient_mag
); );
} }
#[tokio::test] #[tokio::test]
async fn test_blur_too_blurry() { async fn test_blur_too_blurry() {
let step = BlurStep; 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 ctx = make_ctx_with_image(img);
let mut config = Config::default(); let mut config = Config::default();
config.processing.blur.enabled = true; 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 { match step.execute(ctx, &config).await {
StepOutcome::Skip { reason, .. } => { StepOutcome::Skip { reason, .. } => {
@ -326,12 +332,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_blur_sharp_image() { async fn test_blur_sharp_image() {
let step = BlurStep; 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 ctx = make_ctx_with_image(img);
let mut config = Config::default(); let mut config = Config::default();
config.processing.blur.enabled = true; 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 { match step.execute(ctx, &config).await {
StepOutcome::Continue(_) => {} // Should pass StepOutcome::Continue(_) => {} // Should pass

View file

@ -71,6 +71,13 @@ impl ProcessingStep for CropAndResizeStep {
// Scale the face rectangle to match the resized image coordinates // Scale the face rectangle to match the resized image coordinates
let scale = output_size as f32 / cropped_size as f32; 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 { let scaled_face_rect = BoundingBox {
x1: crop_result.face_rect.x1 * scale, x1: crop_result.face_rect.x1 * scale,
y1: crop_result.face_rect.y1 * scale, y1: crop_result.face_rect.y1 * scale,

View file

@ -31,6 +31,9 @@ pub mod computed_keys {
pub const FACE_RECT: &str = "face_rect"; pub const FACE_RECT: &str = "face_rect";
/// Per-edge padding fractions computed by CropAndResizeStep. /// Per-edge padding fractions computed by CropAndResizeStep.
pub const PADDING_EDGES: &str = "padding_edges"; 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. /// Outcome of a pipeline step execution.