immich-automated-selfie-tim.../src/pipeline/steps/alignment.rs

213 lines
7.6 KiB
Rust

//! Eye-based face alignment step.
//!
//! Aligns faces based on eye positions to ensure consistent eye placement
//! across all images in the timelapse.
use crate::config::Config;
use crate::pipeline::{Point, PipelineContext, ProcessingStep, StepOutcome};
use async_trait::async_trait;
use image::{DynamicImage, GenericImageView, Rgb};
use imageproc::geometric_transformations::{rotate_about_center, Interpolation};
/// Aligns faces based on eye positions.
///
/// This step:
/// 1. Retrieves landmarks from ctx.computed["landmarks"]
/// 2. Calculates rotation angle from eye positions
/// 3. Applies affine transformation to align eyes horizontally
/// 4. Scales and crops to position eyes at configured positions
pub struct AlignmentStep;
#[async_trait]
impl ProcessingStep for AlignmentStep {
fn id(&self) -> &'static str {
"alignment"
}
fn name(&self) -> &'static str {
"Alignment"
}
async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome {
// Skip if alignment is disabled
if !config.processing.alignment.enabled {
return StepOutcome::Continue(ctx);
}
// Get landmarks from previous step
let landmarks: crate::pipeline::Landmarks = match ctx
.get_computed("landmarks")
.and_then(|v| v.as_landmarks())
{
Some(l) => l.clone(),
None => {
// If landmarks aren't available, skip alignment but continue
tracing::warn!("Landmarks not available, skipping alignment");
return StepOutcome::Continue(ctx);
}
};
let image = match ctx.take_image("alignment") {
Ok(img) => img,
Err(e) => return StepOutcome::Error(e),
};
let (width, height) = image.dimensions();
let output_size = config.processing.output.size;
// Get eye centers
let left_eye = landmarks.left_eye_center();
let right_eye = landmarks.right_eye_center();
// Calculate rotation angle to make eyes horizontal
let angle = landmarks.eye_rotation_angle();
// Calculate current inter-eye distance
let current_eye_dist = landmarks.inter_eye_distance();
// Target inter-eye distance based on config (as fraction of output width)
let target_eye_dist = output_size as f32 * config.processing.alignment.inter_eye_distance;
// Calculate scale factor
let scale = target_eye_dist / current_eye_dist;
// Target eye positions
let target_eye_y = output_size as f32 * config.processing.alignment.eye_y_position;
let target_left_eye_x = (output_size as f32 - target_eye_dist) / 2.0;
let _target_right_eye_x = target_left_eye_x + target_eye_dist;
// Eye center (midpoint between eyes)
let eye_center = Point::new(
(left_eye.x + right_eye.x) / 2.0,
(left_eye.y + right_eye.y) / 2.0,
);
// First, rotate the image to make eyes horizontal
let rgb = image.to_rgb8();
let rotated = rotate_about_center(
&rgb,
-angle, // Negative because we want to counter-rotate
Interpolation::Bilinear,
Rgb([0, 0, 0]), // Black background for rotated areas
);
// After rotation, the eye center moves. Calculate new position.
// For small angles, we can approximate that the center stays roughly the same
// For more accuracy, we'd need to transform the point through the rotation
// Calculate the new eye center after rotation
let cos_a = angle.cos();
let sin_a = angle.sin();
let cx = width as f32 / 2.0;
let cy = height as f32 / 2.0;
// Rotate eye_center around image center
let dx = eye_center.x - cx;
let dy = eye_center.y - cy;
let rotated_eye_center = Point::new(
cx + dx * cos_a + dy * sin_a,
cy - dx * sin_a + dy * cos_a,
);
// Now calculate crop region to achieve the desired scale and positioning
// We want the eye center at (output_size/2, target_eye_y)
let target_center_x = output_size as f32 / 2.0;
let _target_center_y = target_eye_y;
// Calculate crop region in the rotated image
// The crop should be (output_size / scale) pixels, centered appropriately
let crop_size = (output_size as f32 / scale) as u32;
// Crop center in source image (accounting for where we want eyes to end up)
let crop_center_x = rotated_eye_center.x - (target_center_x - output_size as f32 / 2.0) / scale;
let crop_center_y = rotated_eye_center.y + (target_eye_y - output_size as f32 / 2.0) / scale;
// Calculate crop bounds
let crop_x = (crop_center_x - crop_size as f32 / 2.0).max(0.0) as u32;
let crop_y = (crop_center_y - crop_size as f32 / 2.0).max(0.0) as u32;
// Clamp to image bounds
let (rot_width, rot_height) = (rotated.width(), rotated.height());
let crop_x = crop_x.min(rot_width.saturating_sub(crop_size));
let crop_y = crop_y.min(rot_height.saturating_sub(crop_size));
let actual_crop_size = crop_size.min(rot_width - crop_x).min(rot_height - crop_y);
// Crop and resize
let rotated_dyn = DynamicImage::ImageRgb8(rotated);
let cropped = rotated_dyn.crop_imm(crop_x, crop_y, actual_crop_size, actual_crop_size);
let aligned = cropped.resize_exact(
output_size,
output_size,
image::imageops::FilterType::Lanczos3,
);
ctx.image = Some(aligned);
tracing::trace!(
"Aligned: rotation={:.2}deg, scale={:.2}, crop={}x{} at ({},{})",
angle.to_degrees(),
scale,
actual_crop_size,
actual_crop_size,
crop_x,
crop_y
);
StepOutcome::Continue(ctx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::immich_api::FaceData;
use image::RgbImage;
fn make_test_ctx() -> 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)
}
#[tokio::test]
async fn test_disabled_skips_alignment() {
let step = AlignmentStep;
let ctx = make_test_ctx();
let mut config = Config::default();
config.processing.alignment.enabled = false;
// Create a dummy image
let img = DynamicImage::ImageRgb8(RgbImage::new(100, 100));
let ctx = ctx.with_image(img);
match step.execute(ctx, &config).await {
StepOutcome::Continue(new_ctx) => {
assert!(new_ctx.image.is_some());
}
other => panic!("Expected Continue when disabled, got {:?}", other),
}
}
#[tokio::test]
async fn test_no_landmarks_continues() {
let step = AlignmentStep;
let ctx = make_test_ctx();
let mut config = Config::default();
config.processing.alignment.enabled = true;
// Create a dummy image but no landmarks
let img = DynamicImage::ImageRgb8(RgbImage::new(100, 100));
let ctx = ctx.with_image(img);
match step.execute(ctx, &config).await {
StepOutcome::Continue(_) => {} // Expected - continues without alignment
other => panic!("Expected Continue without landmarks, got {:?}", other),
}
}
}