immich-automated-selfie-tim.../src/pipeline/steps/crop.rs
2026-02-02 20:11:31 +01:00

147 lines
5.1 KiB
Rust

//! Face cropping step.
//!
//! Crops the face region from the full image using the bounding box data.
use crate::config::Config;
use crate::face_processing::crop_face_with_intermediate;
use crate::face_processing::debug::draw_crop_debug;
use crate::pipeline::{ComputedValue, PipelineContext, ProcessingStep, StepOutcome};
use async_trait::async_trait;
use image::DynamicImage;
/// Crops the face region from the full image.
///
/// This transformer step extracts the face region using the bounding box
/// from Immich, adding padding around the face for context.
pub struct CropFaceStep;
#[async_trait]
impl ProcessingStep for CropFaceStep {
fn id(&self) -> &'static str {
"crop"
}
fn name(&self) -> &'static str {
"Face Crop"
}
async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome {
let image = match &ctx.image {
Some(img) => img,
None => {
return StepOutcome::Error("No image available for cropping".to_string());
}
};
// 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) {
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("face_rect", ComputedValue::FaceRect(crop_result.face_rect));
StepOutcome::Continue(ctx)
}
Err(e) => StepOutcome::Skip {
ctx,
reason: "crop_failed".to_string(),
detail: Some(e.to_string()),
},
}
}
fn debug_visualize(&self, ctx: &PipelineContext) -> Option<DynamicImage> {
// Draw the crop region on the original image
// Note: This requires access to the original image before cropping,
// which we don't have here. For now, we'll create the debug image
// during execution if needed. This is a limitation of the current
// design that could be addressed by storing the original image.
// For now, return None and handle debug visualization in the pipeline
// execution or via a separate mechanism
ctx.raw_bytes.as_ref()?;
// If we had the original image, we could do:
// Some(draw_crop_debug(&original, &ctx.face_data))
None
}
}
/// Generates a debug visualization of the crop region.
///
/// This can be called separately before the crop step to visualize
/// what will be cropped.
#[allow(dead_code)]
pub fn generate_crop_debug(image: &DynamicImage, ctx: &PipelineContext) -> DynamicImage {
draw_crop_debug(image, &ctx.face_data)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::immich_api::FaceData;
use image::{DynamicImage, RgbImage, Rgb};
fn make_ctx_with_image(image: DynamicImage) -> PipelineContext {
// Face in the center of a 100x100 image
let face_data = FaceData {
bounding_box_x1: 30.0,
bounding_box_y1: 30.0,
bounding_box_x2: 70.0,
bounding_box_y2: 70.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, |x, y| {
// Create a pattern so we can verify cropping
Rgb([(x % 256) as u8, (y % 256) as u8, 128])
});
DynamicImage::ImageRgb8(img)
}
#[tokio::test]
async fn test_crop_success() {
let step = CropFaceStep;
let img = create_test_image(100, 100);
let ctx = make_ctx_with_image(img);
let config = Config::default();
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());
}
_ => panic!("Expected Continue"),
}
}
#[tokio::test]
async fn test_crop_no_image() {
let step = CropFaceStep;
let face_data = FaceData {
bounding_box_x1: 30.0,
bounding_box_y1: 30.0,
bounding_box_x2: 70.0,
bounding_box_y2: 70.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(msg) => {
assert!(msg.contains("No image"));
}
_ => panic!("Expected Error"),
}
}
}