From 24f7b86ab164a3ea8fff52bf1da79359790cf806 Mon Sep 17 00:00:00 2001 From: Arnaud_Cayrol Date: Wed, 4 Feb 2026 21:15:55 +0100 Subject: [PATCH] Crop -> crop and resize --- src/pipeline/mod.rs | 34 ++--- .../steps/{crop.rs => crop_and_resize.rs} | 88 ++++++++---- src/pipeline/steps/mod.rs | 6 +- src/pipeline/steps/resize.rs | 125 ------------------ src/pipeline/traits.rs | 2 +- 5 files changed, 78 insertions(+), 177 deletions(-) rename src/pipeline/steps/{crop.rs => crop_and_resize.rs} (53%) delete mode 100644 src/pipeline/steps/resize.rs diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs index ba2faab..7299963 100644 --- a/src/pipeline/mod.rs +++ b/src/pipeline/mod.rs @@ -92,19 +92,18 @@ impl Pipeline { /// Create the processing pipeline based on configuration. /// - /// Only enabled steps are added to the pipeline. Core steps (decode, crop, resize) + /// Only enabled steps are added to the pipeline. Core steps (decode, crop_and_resize) /// are always included. /// /// Pipeline order: /// 1. FaceResolutionStep - Validate face size from Immich metadata (if enabled) /// 2. DecodeImageStep - Load and orient the image (always) - /// 3. CropFaceStep - Extract face region with padding (always) + /// 3. CropAndResizeStep - Extract face region with padding and resize to output size (always) /// 4. BrightnessStep - Filter by luminance on cropped face (if enabled) /// 5. HeadPoseStep - Filter non-frontal faces (if enabled) /// 6. LandmarksStep - Detect 68 facial landmarks (always) /// 7. EyeFilterStep - Filter closed eyes by EAR (if enabled) - /// 8. AlignmentStep - Align face based on eye positions (always) - /// 9. ResizeStep - Final resize to output size (always) + /// 8. AlignmentStep - Align face based on eye positions and resize to final output (always) pub fn with_steps_from_config(config: &Config) -> Self { use steps::*; @@ -118,8 +117,8 @@ impl Pipeline { // Core: Always decode the image pipeline.add_step(Box::new(DecodeImageStep)); - // Core: Always crop the face - pipeline.add_step(Box::new(CropFaceStep)); + // Core: Always crop and resize the face + pipeline.add_step(Box::new(CropAndResizeStep)); // Optional: Brightness validation (on cropped face region) if config.processing.brightness.enabled { @@ -139,12 +138,9 @@ impl Pipeline { pipeline.add_step(Box::new(EyeFilterStep)); } - // Core: Always align face based on eye positions + // Core: Always align face based on eye positions (also performs final resize) pipeline.add_step(Box::new(AlignmentStep)); - // Core: Always resize to output size - pipeline.add_step(Box::new(ResizeStep)); - pipeline } @@ -156,13 +152,12 @@ impl Pipeline { let mut pipeline = Self::new(); pipeline.add_step(Box::new(FaceResolutionStep)); pipeline.add_step(Box::new(DecodeImageStep)); - pipeline.add_step(Box::new(CropFaceStep)); + pipeline.add_step(Box::new(CropAndResizeStep)); pipeline.add_step(Box::new(BrightnessStep)); pipeline.add_step(Box::new(HeadPoseStep)); pipeline.add_step(Box::new(LandmarksStep)); pipeline.add_step(Box::new(EyeFilterStep)); pipeline.add_step(Box::new(AlignmentStep)); - pipeline.add_step(Box::new(ResizeStep)); pipeline } @@ -359,12 +354,11 @@ mod tests { assert!(ids.contains(&"face_resolution")); assert!(ids.contains(&"decode")); assert!(ids.contains(&"brightness")); - assert!(ids.contains(&"crop")); + assert!(ids.contains(&"crop_and_resize")); assert!(ids.contains(&"head_pose")); assert!(ids.contains(&"landmarks")); assert!(ids.contains(&"eye_filter")); assert!(ids.contains(&"alignment")); - assert!(ids.contains(&"resize")); } #[test] @@ -379,13 +373,12 @@ mod tests { let pipeline = Pipeline::with_steps_from_config(&config); let ids = pipeline.step_ids(); - // Core steps: decode, crop, landmarks, alignment, resize + // Core steps: decode, crop_and_resize, landmarks, alignment assert!(ids.contains(&"decode")); - assert!(ids.contains(&"crop")); + assert!(ids.contains(&"crop_and_resize")); assert!(ids.contains(&"landmarks")); assert!(ids.contains(&"alignment")); - assert!(ids.contains(&"resize")); - assert_eq!(ids.len(), 5); + assert_eq!(ids.len(), 4); } #[test] @@ -399,12 +392,11 @@ mod tests { let pipeline = Pipeline::with_steps_from_config(&config); let ids = pipeline.step_ids(); - // Core steps (decode, crop, landmarks, alignment, resize) plus eye_filter + // Core steps (decode, crop_and_resize, landmarks, alignment) plus eye_filter assert!(ids.contains(&"decode")); - assert!(ids.contains(&"crop")); + assert!(ids.contains(&"crop_and_resize")); assert!(ids.contains(&"landmarks")); assert!(ids.contains(&"eye_filter")); assert!(ids.contains(&"alignment")); // Alignment is now always enabled - assert!(ids.contains(&"resize")); } } diff --git a/src/pipeline/steps/crop.rs b/src/pipeline/steps/crop_and_resize.rs similarity index 53% rename from src/pipeline/steps/crop.rs rename to src/pipeline/steps/crop_and_resize.rs index 8152097..8d3a7d5 100644 --- a/src/pipeline/steps/crop.rs +++ b/src/pipeline/steps/crop_and_resize.rs @@ -1,42 +1,57 @@ -//! Face cropping step. +//! Face cropping and resizing step. //! -//! Crops the face region from the full image using the bounding box data. +//! Crops the face region from the full image using the bounding box data, +//! then resizes it to the configured output size. use crate::config::Config; use crate::pipeline::crop_face_with_intermediate; -use crate::pipeline::{computed_keys, ComputedValue, PipelineContext, ProcessingStep, StepOutcome}; +use crate::pipeline::{computed_keys, BoundingBox, ComputedValue, PipelineContext, ProcessingStep, StepOutcome}; use async_trait::async_trait; -/// Crops the face region from the full image. +/// Crops the face region from the full image and resizes it. /// /// This transformer step extracts the face region using the bounding box -/// from Immich, adding padding around the face for context. -pub struct CropFaceStep; +/// from Immich (with padding) and immediately resizes it to the configured +/// output size. +pub struct CropAndResizeStep; #[async_trait] -impl ProcessingStep for CropFaceStep { +impl ProcessingStep for CropAndResizeStep { fn id(&self) -> &'static str { - "crop" + "crop_and_resize" } fn name(&self) -> &'static str { - "Face Crop" + "Crop & Resize" } async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome { - let image = match ctx.require_image("cropping") { + let image = match ctx.require_image("cropping and resizing") { Ok(img) => img, Err(e) => return StepOutcome::Error { ctx, error: e }, }; - // Crop returns CropResult with cropped images and face rectangle in crop coordinates. - // We use the full_res_crop here and let the resize step handle the final sizing. - match crop_face_with_intermediate(image, &ctx.face_data, config.processing.output.size) { + let output_size = config.processing.output.size; + + // Crop returns CropResult with cropped images and face rectangle in crop coordinates + match crop_face_with_intermediate(image, &ctx.face_data, output_size) { Ok(crop_result) => { - // Use the full-resolution cropped image; resize step will handle final size - ctx.image = Some(crop_result.cropped); - // Store the face rectangle in crop coordinates for later steps - ctx.set_computed(computed_keys::FACE_RECT, ComputedValue::FaceRect(crop_result.face_rect)); + // Use the pre-resized image from the crop function + let cropped_size = crop_result.cropped.width(); + ctx.image = Some(crop_result.resized); + + // Scale the face rectangle to match the resized image coordinates + let scale = output_size as f32 / cropped_size as f32; + let scaled_face_rect = BoundingBox { + x1: crop_result.face_rect.x1 * scale, + y1: crop_result.face_rect.y1 * scale, + x2: crop_result.face_rect.x2 * scale, + y2: crop_result.face_rect.y2 * scale, + }; + + // Store the scaled face rectangle for later steps + ctx.set_computed(computed_keys::FACE_RECT, ComputedValue::FaceRect(scaled_face_rect)); + StepOutcome::Continue(ctx) } Err(e) => StepOutcome::Skip { @@ -46,7 +61,6 @@ impl ProcessingStep for CropFaceStep { }, } } - } #[cfg(test)] @@ -78,26 +92,29 @@ mod tests { } #[tokio::test] - async fn test_crop_success() { - let step = CropFaceStep; + async fn test_crop_and_resize_success() { + let step = CropAndResizeStep; let img = create_test_image(100, 100); let ctx = make_ctx_with_image(img); - let config = Config::default(); + + let mut config = Config::default(); + config.processing.output.size = 512; match step.execute(ctx, &config).await { StepOutcome::Continue(new_ctx) => { assert!(new_ctx.image.is_some()); - // The cropped image should be square - let cropped = new_ctx.image.unwrap(); - assert_eq!(cropped.width(), cropped.height()); + let resized = new_ctx.image.unwrap(); + // Should be resized to the configured output size + assert_eq!(resized.width(), 512); + assert_eq!(resized.height(), 512); } _ => panic!("Expected Continue"), } } #[tokio::test] - async fn test_crop_no_image() { - let step = CropFaceStep; + async fn test_crop_and_resize_no_image() { + let step = CropAndResizeStep; let face_data = FaceData { bounding_box_x1: 30.0, bounding_box_y1: 30.0, @@ -116,4 +133,23 @@ mod tests { _ => panic!("Expected Error"), } } + + #[tokio::test] + async fn test_crop_and_resize_different_sizes() { + let step = CropAndResizeStep; + let img = create_test_image(200, 200); + let ctx = make_ctx_with_image(img); + + let mut config = Config::default(); + config.processing.output.size = 256; + + match step.execute(ctx, &config).await { + StepOutcome::Continue(new_ctx) => { + let resized = new_ctx.image.unwrap(); + assert_eq!(resized.width(), 256); + assert_eq!(resized.height(), 256); + } + _ => panic!("Expected Continue"), + } + } } diff --git a/src/pipeline/steps/mod.rs b/src/pipeline/steps/mod.rs index 706c3df..28cf49a 100644 --- a/src/pipeline/steps/mod.rs +++ b/src/pipeline/steps/mod.rs @@ -5,20 +5,18 @@ mod alignment; mod brightness; -mod crop; +mod crop_and_resize; mod decode; mod eye_filter; mod face_resolution; mod head_pose; mod landmarks; -mod resize; pub use alignment::AlignmentStep; pub use brightness::BrightnessStep; -pub use crop::CropFaceStep; +pub use crop_and_resize::CropAndResizeStep; pub use decode::DecodeImageStep; pub use eye_filter::EyeFilterStep; pub use face_resolution::FaceResolutionStep; pub use head_pose::HeadPoseStep; pub use landmarks::LandmarksStep; -pub use resize::ResizeStep; diff --git a/src/pipeline/steps/resize.rs b/src/pipeline/steps/resize.rs deleted file mode 100644 index 23097f4..0000000 --- a/src/pipeline/steps/resize.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Image resize step. -//! -//! Resizes the cropped face image to the configured output size. - -use crate::config::Config; -use crate::pipeline::{PipelineContext, ProcessingStep, StepOutcome}; -use async_trait::async_trait; -use image::imageops::FilterType; - -/// Resizes the image to the configured output size. -/// -/// This transformer step is typically the last step in the pipeline, -/// producing the final output image at the configured resolution. -pub struct ResizeStep; - -#[async_trait] -impl ProcessingStep for ResizeStep { - fn id(&self) -> &'static str { - "resize" - } - - fn name(&self) -> &'static str { - "Resize" - } - - async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome { - let image = match ctx.take_image("resizing") { - Ok(img) => img, - Err(e) => return StepOutcome::Error { ctx, error: e }, - }; - - let output_size = config.processing.output.size; - - // Resize to square output - let resized = image.resize_exact(output_size, output_size, FilterType::Lanczos3); - - ctx.image = Some(resized); - StepOutcome::Continue(ctx) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::immich_api::FaceData; - use image::{DynamicImage, RgbImage, Rgb}; - - fn make_ctx_with_image(image: DynamicImage) -> PipelineContext { - let face_data = FaceData { - bounding_box_x1: 0.0, - bounding_box_y1: 0.0, - bounding_box_x2: 100.0, - bounding_box_y2: 100.0, - image_width: 100, - image_height: 100, - }; - PipelineContext::new("test".to_string(), "2024-01-01".to_string(), face_data) - .with_image(image) - } - - fn create_test_image(width: u32, height: u32) -> DynamicImage { - let img = RgbImage::from_fn(width, height, |_, _| Rgb([128, 128, 128])); - DynamicImage::ImageRgb8(img) - } - - #[tokio::test] - async fn test_resize_success() { - let step = ResizeStep; - let img = create_test_image(200, 200); - let ctx = make_ctx_with_image(img); - - let mut config = Config::default(); - config.processing.output.size = 512; - - match step.execute(ctx, &config).await { - StepOutcome::Continue(new_ctx) => { - let resized = new_ctx.image.unwrap(); - assert_eq!(resized.width(), 512); - assert_eq!(resized.height(), 512); - } - _ => panic!("Expected Continue"), - } - } - - #[tokio::test] - async fn test_resize_no_image() { - let step = ResizeStep; - let face_data = FaceData { - bounding_box_x1: 0.0, - bounding_box_y1: 0.0, - bounding_box_x2: 100.0, - bounding_box_y2: 100.0, - image_width: 100, - image_height: 100, - }; - let ctx = PipelineContext::new("test".to_string(), "2024-01-01".to_string(), face_data); - let config = Config::default(); - - match step.execute(ctx, &config).await { - StepOutcome::Error { error, .. } => { - assert!(error.contains("No image")); - } - _ => panic!("Expected Error"), - } - } - - #[tokio::test] - async fn test_resize_upscale() { - let step = ResizeStep; - let img = create_test_image(50, 50); // Small image - let ctx = make_ctx_with_image(img); - - let mut config = Config::default(); - config.processing.output.size = 256; - - match step.execute(ctx, &config).await { - StepOutcome::Continue(new_ctx) => { - let resized = new_ctx.image.unwrap(); - assert_eq!(resized.width(), 256); - assert_eq!(resized.height(), 256); - } - _ => panic!("Expected Continue"), - } - } -} diff --git a/src/pipeline/traits.rs b/src/pipeline/traits.rs index 8b9ae69..df5b328 100644 --- a/src/pipeline/traits.rs +++ b/src/pipeline/traits.rs @@ -25,7 +25,7 @@ pub mod computed_keys { pub const LANDMARKS: &str = "landmarks"; /// Head pose (yaw, pitch, roll) computed by HeadPoseStep. pub const HEAD_POSE: &str = "head_pose"; - /// Face bounding box in current image coordinates computed by CropFaceStep. + /// Face bounding box in current image coordinates computed by CropAndResizeStep. pub const FACE_RECT: &str = "face_rect"; }