diff --git a/frontend/src/lib/components/SettingsPanel.svelte b/frontend/src/lib/components/SettingsPanel.svelte index 71d3d43..100ec55 100644 --- a/frontend/src/lib/components/SettingsPanel.svelte +++ b/frontend/src/lib/components/SettingsPanel.svelte @@ -45,6 +45,10 @@ enabled: config.processing.face_resolution.enabled, min_size: Number(config.processing.face_resolution.min_size), }, + crop: { + enabled: config.processing.crop.enabled, + max_padding_percent: Number(config.processing.crop.max_padding_percent), + }, brightness: { enabled: config.processing.brightness.enabled, min_brightness: Number(config.processing.brightness.min_brightness), @@ -203,6 +207,38 @@ {/if} + +
+
+ Face Too Close to Edge + +
+
Faces too close to the edge of the photo are often distorted and require too much padding
+ + {#if config.processing.crop.enabled} +
+ +
+ + {config.processing.crop.max_padding_percent}% +
+
+ {/if} +
+
@@ -793,6 +829,12 @@ color: #e0e0e0; } + .section-hint { + font-size: 0.75rem; + color: #666; + margin-bottom: 0.5rem; + } + .section-header input[type='checkbox'] { width: 1.25rem; height: 1.25rem; diff --git a/src/config.rs b/src/config.rs index a48bc60..22654fc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -201,6 +201,41 @@ impl BlurConfig { } } +/// Crop padding filter configuration. +/// +/// Skips images where too much of the crop region falls outside the original +/// image bounds (filled by edge-pixel replication). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CropConfig { + /// Whether crop padding filtering is enabled. + pub enabled: bool, + + /// Maximum allowed padding percentage (0.0-100.0). + /// Images where the replicated-fill area exceeds this will be skipped. + pub max_padding_percent: f32, +} + +impl Default for CropConfig { + fn default() -> Self { + Self { + enabled: false, + max_padding_percent: 30.0, + } + } +} + +impl CropConfig { + /// Validate the configuration values. + pub fn validate(&self) -> Result<()> { + if self.enabled && !(0.0..=100.0).contains(&self.max_padding_percent) { + return Err(Error::Config( + "Crop max_padding_percent must be between 0.0 and 100.0".to_string(), + )); + } + Ok(()) + } +} + /// Output/resize configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OutputConfig { @@ -508,6 +543,10 @@ pub struct ProcessingConfig { #[serde(default)] pub face_resolution: FaceResolutionConfig, + /// Crop padding filter settings. + #[serde(default)] + pub crop: CropConfig, + /// Blur detection settings. #[serde(default)] pub blur: BlurConfig, @@ -547,6 +586,7 @@ impl Default for ProcessingConfig { max_workers: 1, use_preview: true, face_resolution: FaceResolutionConfig::default(), + crop: CropConfig::default(), blur: BlurConfig::default(), brightness: BrightnessConfig::default(), head_pose: HeadPoseConfig::default(), @@ -568,6 +608,7 @@ impl ProcessingConfig { )); } self.face_resolution.validate()?; + self.crop.validate()?; self.blur.validate()?; self.brightness.validate()?; self.head_pose.validate()?; diff --git a/src/pipeline/crop_utils.rs b/src/pipeline/crop_utils.rs index ee39a6a..f2ad927 100644 --- a/src/pipeline/crop_utils.rs +++ b/src/pipeline/crop_utils.rs @@ -9,6 +9,25 @@ use image::imageops::FilterType; use image::{DynamicImage, GenericImageView, RgbImage}; use std::borrow::Cow; +/// Per-edge padding fractions (0.0-1.0) relative to crop size. +#[derive(Debug, Clone, Copy)] +pub struct PaddingEdges { + pub top: f32, + pub bottom: f32, + pub left: f32, + pub right: f32, +} + +impl PaddingEdges { + /// Total fraction of crop area that is padding. + pub fn total_fraction(&self) -> f32 { + // Area = 1 - (1-left-right)*(1-top-bottom) + let inner_w = (1.0 - self.left - self.right).max(0.0); + let inner_h = (1.0 - self.top - self.bottom).max(0.0); + 1.0 - inner_w * inner_h + } +} + /// Result of cropping a face from an image. pub struct CropResult { /// The cropped image at full resolution. @@ -17,6 +36,10 @@ pub struct CropResult { pub resized: DynamicImage, /// The face bounding box in crop coordinates. pub face_rect: BoundingBox, + /// Fraction of the crop area that falls outside the original image (0.0-1.0). + pub padding_fraction: f32, + /// Per-edge padding fractions. + pub padding_edges: PaddingEdges, } /// Crop and resize the face from an image using bounding box. @@ -70,6 +93,25 @@ pub fn crop_face_with_intermediate( let ideal_x1 = center_x as i32 - crop_size as i32 / 2; let ideal_y1 = center_y as i32 - crop_size as i32 / 2; + // Calculate what fraction of the crop area falls outside the image bounds. + let overlap_x1 = (ideal_x1).max(0) as u64; + let overlap_y1 = (ideal_y1).max(0) as u64; + let overlap_x2 = ((ideal_x1 + crop_size as i32) as u64).min(img_width as u64); + let overlap_y2 = ((ideal_y1 + crop_size as i32) as u64).min(img_height as u64); + let overlap_area = + overlap_x2.saturating_sub(overlap_x1) * overlap_y2.saturating_sub(overlap_y1); + let total_area = crop_size as u64 * crop_size as u64; + let padding_fraction = 1.0 - (overlap_area as f32 / total_area as f32); + + // Per-edge padding as fraction of crop size + let cs = crop_size as f32; + let padding_edges = PaddingEdges { + left: (-ideal_x1).max(0) as f32 / cs, + top: (-ideal_y1).max(0) as f32 / cs, + right: ((ideal_x1 + crop_size as i32) - img_width as i32).max(0) as f32 / cs, + bottom: ((ideal_y1 + crop_size as i32) - img_height as i32).max(0) as f32 / cs, + }; + // Crop with replicate-fill: always keeps the face centered by extending // edge pixels at image borders instead of shifting the crop window. let cropped = crop_with_replicate_fill(img, ideal_x1, ideal_y1, crop_size); @@ -89,6 +131,8 @@ pub fn crop_face_with_intermediate( cropped, resized, face_rect, + padding_fraction, + padding_edges, }) } diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs index d13f068..5f5d562 100644 --- a/src/pipeline/mod.rs +++ b/src/pipeline/mod.rs @@ -20,7 +20,7 @@ pub mod steps; mod traits; mod types; -pub use crop_utils::{crop_face_with_intermediate, CropResult}; +pub use crop_utils::{crop_face_with_intermediate, CropResult, PaddingEdges}; pub use debug_utils::{draw_simple_text, face_rect_pixels}; pub use orientation::load_image_with_orientation; pub use traits::*; diff --git a/src/pipeline/steps/crop_and_resize.rs b/src/pipeline/steps/crop_and_resize.rs index 54f1aeb..d09abe5 100644 --- a/src/pipeline/steps/crop_and_resize.rs +++ b/src/pipeline/steps/crop_and_resize.rs @@ -5,10 +5,12 @@ use crate::config::Config; use crate::pipeline::crop_face_with_intermediate; +use crate::pipeline::debug_utils::draw_simple_text; use crate::pipeline::{ computed_keys, BoundingBox, ComputedValue, PipelineContext, ProcessingStep, StepOutcome, }; use async_trait::async_trait; +use image::{DynamicImage, Rgb}; /// Crops the face region from the full image and resizes it. /// @@ -39,6 +41,30 @@ impl ProcessingStep for CropAndResizeStep { // Crop returns CropResult with cropped images and face rectangle in crop coordinates match crop_face_with_intermediate(image, &ctx.face_data, output_size, eye_distance) { Ok(crop_result) => { + // Store padding info for debug visualization + ctx.set_computed( + computed_keys::PADDING_EDGES, + ComputedValue::PaddingEdges(crop_result.padding_edges), + ); + + // Check if too much of the crop falls outside the image + let crop_config = &config.processing.crop; + if crop_config.enabled { + let padding_pct = crop_result.padding_fraction * 100.0; + if padding_pct > crop_config.max_padding_percent { + // Set image so debug_visualize can use it + ctx.image = Some(crop_result.resized); + return StepOutcome::Skip { + ctx, + reason: "excessive_padding".to_string(), + detail: Some(format!( + "padding {:.1}% exceeds max {:.1}%", + padding_pct, crop_config.max_padding_percent + )), + }; + } + } + // Use the pre-resized image from the crop function let cropped_size = crop_result.cropped.width(); ctx.image = Some(crop_result.resized); @@ -67,6 +93,117 @@ impl ProcessingStep for CropAndResizeStep { }, } } + + fn debug_visualize(&self, ctx: &PipelineContext, config: &Config) -> Option { + let edges = ctx + .get_computed(computed_keys::PADDING_EDGES) + .and_then(|v| v.as_padding_edges())?; + let image = ctx.image.as_ref()?; + let rgb = image.to_rgb8(); + let (width, height) = (rgb.width(), rgb.height()); + + let mut debug_img = rgb.clone(); + + // Tint padded regions with a semi-transparent red overlay + let tint = |pixel: &Rgb| -> Rgb { + Rgb([ + (pixel[0] as u16 / 2 + 127).min(255) as u8, + pixel[1] / 2, + pixel[2] / 2, + ]) + }; + + let left_px = (edges.left * width as f32).round() as u32; + let right_px = (edges.right * width as f32).round() as u32; + let top_px = (edges.top * height as f32).round() as u32; + let bottom_px = (edges.bottom * height as f32).round() as u32; + + // Tint left edge + for y in 0..height { + for x in 0..left_px.min(width) { + debug_img.put_pixel(x, y, tint(debug_img.get_pixel(x, y))); + } + } + // Tint right edge + for y in 0..height { + for x in width.saturating_sub(right_px)..width { + debug_img.put_pixel(x, y, tint(debug_img.get_pixel(x, y))); + } + } + // Tint top edge (only the non-corner part to avoid double-tinting) + for y in 0..top_px.min(height) { + for x in left_px.min(width)..width.saturating_sub(right_px) { + debug_img.put_pixel(x, y, tint(debug_img.get_pixel(x, y))); + } + } + // Tint bottom edge (only the non-corner part) + for y in height.saturating_sub(bottom_px)..height { + for x in left_px.min(width)..width.saturating_sub(right_px) { + debug_img.put_pixel(x, y, tint(debug_img.get_pixel(x, y))); + } + } + + // Draw padding percentage bar at the bottom + let total_pct = edges.total_fraction() * 100.0; + let max_pct = config.processing.crop.max_padding_percent; + + let bar_height = 20u32; + let bar_y = height.saturating_sub(bar_height); + let bar_width = (width as f32 * 0.8) as u32; + let bar_x = (width - bar_width) / 2; + + // Background + for y in bar_y..height { + for x in 0..width { + debug_img.put_pixel(x, y, Rgb([40, 40, 40])); + } + } + + // Bar outline + let outline_y = bar_y + 4; + let outline_height = bar_height - 8; + for x in bar_x..bar_x + bar_width { + debug_img.put_pixel(x, outline_y, Rgb([200, 200, 200])); + debug_img.put_pixel(x, outline_y + outline_height - 1, Rgb([200, 200, 200])); + } + for y in outline_y..outline_y + outline_height { + debug_img.put_pixel(bar_x, y, Rgb([200, 200, 200])); + debug_img.put_pixel(bar_x + bar_width - 1, y, Rgb([200, 200, 200])); + } + + // Fill bar (scale: 0-50% maps to full bar) + let max_scale = 50.0_f32; + let normalized = (total_pct / max_scale).clamp(0.0, 1.0); + let fill_width = ((bar_width - 4) as f32 * normalized) as u32; + let fill_color = if total_pct > max_pct { + Rgb([255, 80, 80]) // Red - exceeds threshold + } else if total_pct > max_pct * 0.7 { + Rgb([255, 200, 80]) // Yellow - approaching threshold + } else { + Rgb([80, 255, 80]) // Green - well within threshold + }; + 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 { + debug_img.put_pixel(x, y, fill_color); + } + } + } + + // Draw threshold marker on the bar + let threshold_x = bar_x + 2 + ((bar_width - 4) as f32 * (max_pct / max_scale).clamp(0.0, 1.0)) as u32; + if threshold_x < bar_x + bar_width { + for y in outline_y..(outline_y + outline_height) { + debug_img.put_pixel(threshold_x, y, Rgb([255, 255, 255])); + } + } + + // Text label + let text = format!("{:.1}%", total_pct); + draw_simple_text(&mut debug_img, 5, bar_y + 6, &text, Rgb([255, 255, 255])); + + Some(DynamicImage::ImageRgb8(debug_img)) + } } #[cfg(test)] diff --git a/src/pipeline/traits.rs b/src/pipeline/traits.rs index 6681c73..0c90143 100644 --- a/src/pipeline/traits.rs +++ b/src/pipeline/traits.rs @@ -29,6 +29,8 @@ pub mod computed_keys { pub const HEAD_POSE: &str = "head_pose"; /// Face bounding box in current image coordinates computed by CropAndResizeStep. pub const FACE_RECT: &str = "face_rect"; + /// Per-edge padding fractions computed by CropAndResizeStep. + pub const PADDING_EDGES: &str = "padding_edges"; } /// Outcome of a pipeline step execution. @@ -63,6 +65,8 @@ pub enum ComputedValue { Landmarks(Box), /// Face bounding box in current image coordinates. FaceRect(BoundingBox), + /// Per-edge padding fractions from crop step. + PaddingEdges(super::PaddingEdges), } impl ComputedValue { @@ -121,6 +125,14 @@ impl ComputedValue { _ => None, } } + + /// Get as PaddingEdges if this is a PaddingEdges variant. + pub fn as_padding_edges(&self) -> Option<&super::PaddingEdges> { + match self { + ComputedValue::PaddingEdges(v) => Some(v), + _ => None, + } + } } /// A debug image with metadata about whether the step passed or failed. diff --git a/src/web/handlers/config.rs b/src/web/handlers/config.rs index 95b7370..7bd4656 100644 --- a/src/web/handlers/config.rs +++ b/src/web/handlers/config.rs @@ -1,9 +1,9 @@ //! Configuration endpoints. use crate::config::{ - AlignmentConfig, BlurConfig, BrightnessConfig, EyeFilterConfig, FaceResolutionConfig, - HeadPoseConfig, OutputConfig, ProcessingConfig, TimeIntervalConfig, TimestampConfig, - VideoConfig, CONFIG_PATH, + AlignmentConfig, BlurConfig, BrightnessConfig, CropConfig, EyeFilterConfig, + FaceResolutionConfig, HeadPoseConfig, OutputConfig, ProcessingConfig, TimeIntervalConfig, + TimestampConfig, VideoConfig, CONFIG_PATH, }; use crate::web::state::AppState; use axum::{extract::State, http::StatusCode, response::Json}; @@ -48,6 +48,7 @@ pub struct ConfigUpdateRequest { pub struct ProcessingConfigUpdate { pub max_workers: Option, pub face_resolution: Option, + pub crop: Option, pub blur: Option, pub brightness: Option, pub head_pose: Option, @@ -108,6 +109,18 @@ fn validate_processing_config(proc: &ProcessingConfigUpdate) -> Result<(), Valid } } + if let Some(ref cr) = proc.crop { + if cr.enabled && !(0.0..=100.0).contains(&cr.max_padding_percent) { + return Err(ValidationError::new( + "processing.crop.max_padding_percent", + format!( + "must be between 0.0 and 100.0, got {}", + cr.max_padding_percent + ), + )); + } + } + if let Some(ref bl) = proc.blur { if bl.enabled && bl.min_sharpness < 0.0 { return Err(ValidationError::new( @@ -268,6 +281,9 @@ pub async fn update_config( if let Some(v) = proc.face_resolution { config.processing.face_resolution = v; } + if let Some(v) = proc.crop { + config.processing.crop = v; + } if let Some(v) = proc.blur { config.processing.blur = v; }