Crop -> crop and resize
This commit is contained in:
parent
1afcf2de21
commit
24f7b86ab1
5 changed files with 78 additions and 177 deletions
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue