diff --git a/src/config.rs b/src/config.rs index f3fac89..76a9273 100644 --- a/src/config.rs +++ b/src/config.rs @@ -326,14 +326,29 @@ impl AlignmentConfig { /// Validate the configuration values. pub fn validate(&self) -> Result<()> { if self.enabled { + if self.eye_y_position <= 0.0 || self.eye_y_position >= 1.0 { + return Err(Error::Config( + "Alignment eye_y_position must be between 0.0 and 1.0 (exclusive)".to_string(), + )); + } if self.eye_y_position < 0.2 || self.eye_y_position > 0.5 { return Err(Error::Config( - "Alignment eye_y_position must be between 0.2 and 0.5".to_string(), + "Alignment eye_y_position should be between 0.2 and 0.5 for best results".to_string(), + )); + } + if self.inter_eye_distance <= 0.0 { + return Err(Error::Config( + "Alignment inter_eye_distance must be greater than 0 to prevent division by zero".to_string(), + )); + } + if self.inter_eye_distance >= 1.0 { + return Err(Error::Config( + "Alignment inter_eye_distance must be less than 1.0".to_string(), )); } if self.inter_eye_distance < 0.2 || self.inter_eye_distance > 0.5 { return Err(Error::Config( - "Alignment inter_eye_distance must be between 0.2 and 0.5".to_string(), + "Alignment inter_eye_distance should be between 0.2 and 0.5 for best results".to_string(), )); } } diff --git a/src/job/mod.rs b/src/job/mod.rs index e74961c..66ecb28 100644 --- a/src/job/mod.rs +++ b/src/job/mod.rs @@ -192,8 +192,8 @@ async fn run_job_inner( }) .await; - // Create the processing pipeline - let pipeline = Arc::new(Pipeline::with_default_steps()); + // Create the processing pipeline based on config + let pipeline = Arc::new(Pipeline::with_steps_from_config(&config)); tracing::debug!("Pipeline steps: {:?}", pipeline.step_ids()); // Process images in parallel with concurrency limit diff --git a/src/pipeline/debug_utils.rs b/src/pipeline/debug_utils.rs new file mode 100644 index 0000000..b610fed --- /dev/null +++ b/src/pipeline/debug_utils.rs @@ -0,0 +1,87 @@ +//! Debug visualization utilities. +//! +//! Shared functions for generating debug visualizations in pipeline steps. + +use image::{Rgb, RgbImage}; + +/// Draw simple text using a basic 5x7 pixel font. +/// +/// # Arguments +/// * `img` - The image to draw on +/// * `x` - X coordinate of the top-left corner +/// * `y` - Y coordinate of the top-left corner +/// * `text` - The text to draw +/// * `color` - The color to use +pub fn draw_simple_text(img: &mut RgbImage, x: u32, y: u32, text: &str, color: Rgb) { + let (width, height) = (img.width(), img.height()); + let mut cursor_x = x; + + for ch in text.chars() { + let pattern = get_char_pattern(ch); + for (row_idx, row) in pattern.iter().enumerate() { + for col in 0..5 { + if (row >> (4 - col)) & 1 == 1 { + let px = cursor_x + col; + let py = y + row_idx as u32; + if px < width && py < height { + img.put_pixel(px, py, color); + } + } + } + } + cursor_x += 6; // 5 pixels wide + 1 pixel spacing + } +} + +/// Get a 5x7 pixel pattern for a character. +fn get_char_pattern(ch: char) -> [u8; 7] { + match ch { + '0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], + '1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], + '2' => [0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111], + '3' => [0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110], + '4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], + '5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], + '6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], + '7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], + '8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], + '9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100], + 'A' => [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001], + 'B' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110], + 'E' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111], + 'H' => [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001], + 'L' => [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111], + 'P' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000], + 'R' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001], + 'Y' => [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100], + ':' => [0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000], + '.' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100], + '=' => [0b00000, 0b00000, 0b11111, 0b00000, 0b11111, 0b00000, 0b00000], + '-' => [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000], + '°' => [0b00110, 0b01001, 0b01001, 0b00110, 0b00000, 0b00000, 0b00000], + ' ' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000], + _ => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use image::RgbImage; + + #[test] + fn test_draw_simple_text() { + let mut img = RgbImage::new(100, 20); + draw_simple_text(&mut img, 5, 5, "Test", Rgb([255, 255, 255])); + // Just verify it doesn't panic and draws something + assert_eq!(img.width(), 100); + } + + #[test] + fn test_get_char_pattern() { + let pattern = get_char_pattern('0'); + assert_eq!(pattern.len(), 7); + // Verify it's not all zeros + assert!(pattern.iter().any(|&x| x != 0)); + } +} diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs index 753354e..0c3b3e5 100644 --- a/src/pipeline/mod.rs +++ b/src/pipeline/mod.rs @@ -14,12 +14,14 @@ //! ``` mod crop_utils; +mod debug_utils; mod orientation; mod traits; mod types; pub mod steps; pub use crop_utils::{crop_face_with_intermediate, CropResult}; +pub use debug_utils::draw_simple_text; pub use orientation::load_image_with_orientation; pub use traits::*; pub use types::*; @@ -90,22 +92,74 @@ impl Pipeline { Self { steps: Vec::new() } } - /// Create the default processing pipeline with standard steps. + /// Create the processing pipeline based on configuration. + /// + /// Only enabled steps are added to the pipeline. Core steps (decode, crop, resize) + /// are always included. /// /// Pipeline order: - /// 1. FaceResolutionStep - Validate face size from Immich metadata (Validator) - /// 2. DecodeImageStep - Load and orient the image (Transform) - /// 3. BrightnessStep - Filter by luminance (Validator) - /// 4. CropFaceStep - Extract face region with padding (Transform) - /// 5. HeadPoseStep - Filter non-frontal faces (Validator) - /// 6. LandmarksStep - Detect 68 facial landmarks (Detector) - /// 7. EyeFilterStep - Filter closed eyes by EAR (Validator) - /// 8. AlignmentStep - Align face based on eye positions (Transform) - /// 9. ResizeStep - Final resize to output size (Transform) - /// - /// Debug visualizations are only generated for validator steps: - /// BrightnessStep, HeadPoseStep, EyeFilterStep - pub fn with_default_steps() -> Self { + /// 1. FaceResolutionStep - Validate face size from Immich metadata (if enabled) + /// 2. DecodeImageStep - Load and orient the image (always) + /// 3. BrightnessStep - Filter by luminance (if enabled) + /// 4. CropFaceStep - Extract face region with padding (always) + /// 5. HeadPoseStep - Filter non-frontal faces (if enabled) + /// 6. LandmarksStep - Detect 68 facial landmarks (if eye_filter or alignment enabled) + /// 7. EyeFilterStep - Filter closed eyes by EAR (if enabled) + /// 8. AlignmentStep - Align face based on eye positions (if enabled) + /// 9. ResizeStep - Final resize to output size (always) + pub fn with_steps_from_config(config: &Config) -> Self { + use steps::*; + + let mut pipeline = Self::new(); + + // Optional: Face resolution validation + if config.processing.face_resolution.enabled { + pipeline.add_step(Box::new(FaceResolutionStep)); + } + + // Core: Always decode the image + pipeline.add_step(Box::new(DecodeImageStep)); + + // Optional: Brightness validation + if config.processing.brightness.enabled { + pipeline.add_step(Box::new(BrightnessStep)); + } + + // Core: Always crop the face + pipeline.add_step(Box::new(CropFaceStep)); + + // Optional: Head pose validation + if config.processing.head_pose.enabled { + pipeline.add_step(Box::new(HeadPoseStep)); + } + + // Landmarks are needed if eye_filter or alignment is enabled + let needs_landmarks = config.processing.eye_filter.enabled + || config.processing.alignment.enabled; + + if needs_landmarks { + pipeline.add_step(Box::new(LandmarksStep)); + } + + // Optional: Eye filter (requires landmarks) + if config.processing.eye_filter.enabled { + pipeline.add_step(Box::new(EyeFilterStep)); + } + + // Optional: Alignment (requires landmarks) + if config.processing.alignment.enabled { + pipeline.add_step(Box::new(AlignmentStep)); + } + + // Core: Always resize to output size + pipeline.add_step(Box::new(ResizeStep)); + + pipeline + } + + /// Create pipeline with all steps (for testing). + #[cfg(test)] + pub fn with_all_steps() -> Self { use steps::*; let mut pipeline = Self::new(); @@ -198,7 +252,7 @@ impl Pipeline { debug_images, }; } - StepOutcome::Error(error) => { + StepOutcome::Error { ctx, error } => { tracing::warn!( "Asset {} error at step '{}': {}", asset_id, @@ -206,6 +260,14 @@ impl Pipeline { error ); + // Collect debug images from error context + let debug_images: Vec<(String, DebugImage)> = ctx.debug_images.into_iter().collect(); + + // Save debug images if enabled + if let Some(debug_base) = debug_dir { + save_debug_images(debug_base, &debug_images, ×tamp, &asset_id).await; + } + return PipelineResult::Error { asset_id, error }; } } @@ -284,10 +346,11 @@ async fn save_debug_images( #[cfg(test)] mod tests { use super::*; + use crate::config::Config; #[test] - fn test_pipeline_step_ids() { - let pipeline = Pipeline::with_default_steps(); + fn test_pipeline_with_all_steps() { + let pipeline = Pipeline::with_all_steps(); let ids = pipeline.step_ids(); assert!(ids.contains(&"face_resolution")); @@ -300,4 +363,45 @@ mod tests { assert!(ids.contains(&"alignment")); assert!(ids.contains(&"resize")); } + + #[test] + fn test_pipeline_from_config_minimal() { + // Config with all optional steps disabled + let mut config = Config::default(); + config.processing.face_resolution.enabled = false; + config.processing.brightness.enabled = false; + config.processing.head_pose.enabled = false; + config.processing.eye_filter.enabled = false; + config.processing.alignment.enabled = false; + + let pipeline = Pipeline::with_steps_from_config(&config); + let ids = pipeline.step_ids(); + + // Only core steps should be present + assert!(ids.contains(&"decode")); + assert!(ids.contains(&"crop")); + assert!(ids.contains(&"resize")); + assert_eq!(ids.len(), 3); + } + + #[test] + fn test_pipeline_from_config_with_eye_filter() { + let mut config = Config::default(); + config.processing.face_resolution.enabled = false; + config.processing.brightness.enabled = false; + config.processing.head_pose.enabled = false; + config.processing.eye_filter.enabled = true; + config.processing.alignment.enabled = false; + + let pipeline = Pipeline::with_steps_from_config(&config); + let ids = pipeline.step_ids(); + + // Should include landmarks (dependency) and eye_filter + assert!(ids.contains(&"decode")); + assert!(ids.contains(&"crop")); + assert!(ids.contains(&"landmarks")); + assert!(ids.contains(&"eye_filter")); + assert!(ids.contains(&"resize")); + assert!(!ids.contains(&"alignment")); + } } diff --git a/src/pipeline/steps/alignment.rs b/src/pipeline/steps/alignment.rs index 845cb00..e1ceeb8 100644 --- a/src/pipeline/steps/alignment.rs +++ b/src/pipeline/steps/alignment.rs @@ -4,7 +4,7 @@ //! across all images in the timelapse. use crate::config::Config; -use crate::pipeline::{Point, PipelineContext, ProcessingStep, StepOutcome}; +use crate::pipeline::{computed_keys, Point, PipelineContext, ProcessingStep, StepOutcome}; use async_trait::async_trait; use image::{DynamicImage, GenericImageView, Rgb}; use imageproc::geometric_transformations::{rotate_about_center, Interpolation}; @@ -36,7 +36,7 @@ impl ProcessingStep for AlignmentStep { // Get landmarks from previous step let landmarks: crate::pipeline::Landmarks = match ctx - .get_computed("landmarks") + .get_computed(computed_keys::LANDMARKS) .and_then(|v| v.as_landmarks()) { Some(l) => l.clone(), @@ -49,7 +49,7 @@ impl ProcessingStep for AlignmentStep { let image = match ctx.take_image("alignment") { Ok(img) => img, - Err(e) => return StepOutcome::Error(e), + Err(e) => return StepOutcome::Error { ctx, error: e }, }; let (width, height) = image.dimensions(); diff --git a/src/pipeline/steps/brightness.rs b/src/pipeline/steps/brightness.rs index b97fe30..8eaa8dd 100644 --- a/src/pipeline/steps/brightness.rs +++ b/src/pipeline/steps/brightness.rs @@ -3,9 +3,9 @@ //! Calculates average image brightness and skips images that are too dark or too bright. use crate::config::Config; -use crate::pipeline::{ComputedValue, PipelineContext, ProcessingStep, StepOutcome}; +use crate::pipeline::{computed_keys, draw_simple_text, ComputedValue, PipelineContext, ProcessingStep, StepOutcome}; use async_trait::async_trait; -use image::{DynamicImage, Rgb, RgbImage}; +use image::{DynamicImage, Rgb}; /// Validates image brightness and skips images outside acceptable range. /// @@ -52,18 +52,22 @@ impl ProcessingStep for BrightnessStep { "Brightness Check" } + fn provides(&self) -> Vec<&'static str> { + vec![computed_keys::BRIGHTNESS] + } + async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome { let step_config = &config.processing.brightness; let image = match ctx.require_image("brightness check") { Ok(img) => img, - Err(e) => return StepOutcome::Error(e), + Err(e) => return StepOutcome::Error { ctx, error: e }, }; let brightness = Self::calculate_brightness(image); // Store computed brightness for potential use by other steps - ctx.set_computed("brightness", ComputedValue::Float(brightness)); + ctx.set_computed(computed_keys::BRIGHTNESS, ComputedValue::Float(brightness)); // Skip validation if disabled if !step_config.enabled { @@ -98,7 +102,7 @@ impl ProcessingStep for BrightnessStep { fn debug_visualize(&self, ctx: &PipelineContext, _config: &Config) -> Option { // Get brightness from computed values let brightness = ctx - .get_computed("brightness") + .get_computed(computed_keys::BRIGHTNESS) .and_then(|v| v.as_float())?; // Get the current image to draw on @@ -165,49 +169,6 @@ fn brightness_to_color(brightness: f32) -> Rgb { } } -/// Draw simple text using a basic 5x7 pixel font. -fn draw_simple_text(img: &mut RgbImage, x: u32, y: u32, text: &str, color: Rgb) { - let (width, height) = (img.width(), img.height()); - let mut cursor_x = x; - - for ch in text.chars() { - let pattern = get_char_pattern(ch); - for (row_idx, row) in pattern.iter().enumerate() { - for col in 0..5 { - if (row >> (4 - col)) & 1 == 1 { - let px = cursor_x + col; - let py = y + row_idx as u32; - if px < width && py < height { - img.put_pixel(px, py, color); - } - } - } - } - cursor_x += 6; // 5 pixels wide + 1 pixel spacing - } -} - -/// Get a 5x7 pixel pattern for a character. -fn get_char_pattern(ch: char) -> [u8; 7] { - match ch { - '0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], - '1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], - '2' => [0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111], - '3' => [0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110], - '4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], - '5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], - '6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], - '7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], - '8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], - '9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100], - 'B' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110], - ':' => [0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000], - '.' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100], - ' ' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000], - _ => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000], - } -} - #[cfg(test)] mod tests { use super::*; @@ -264,7 +225,7 @@ mod tests { match step.execute(ctx, &config).await { StepOutcome::Continue(new_ctx) => { // Should still compute brightness even when disabled - let brightness = new_ctx.get_computed("brightness") + let brightness = new_ctx.get_computed(computed_keys::BRIGHTNESS) .and_then(|v| v.as_float()) .unwrap(); assert!(brightness > 0.45 && brightness < 0.55); diff --git a/src/pipeline/steps/crop.rs b/src/pipeline/steps/crop.rs index ffe00fd..8152097 100644 --- a/src/pipeline/steps/crop.rs +++ b/src/pipeline/steps/crop.rs @@ -4,7 +4,7 @@ use crate::config::Config; use crate::pipeline::crop_face_with_intermediate; -use crate::pipeline::{ComputedValue, PipelineContext, ProcessingStep, StepOutcome}; +use crate::pipeline::{computed_keys, ComputedValue, PipelineContext, ProcessingStep, StepOutcome}; use async_trait::async_trait; /// Crops the face region from the full image. @@ -26,7 +26,7 @@ impl ProcessingStep for CropFaceStep { async fn execute(&self, mut ctx: PipelineContext, config: &Config) -> StepOutcome { let image = match ctx.require_image("cropping") { Ok(img) => img, - Err(e) => return StepOutcome::Error(e), + Err(e) => return StepOutcome::Error { ctx, error: e }, }; // Crop returns CropResult with cropped images and face rectangle in crop coordinates. @@ -36,7 +36,7 @@ impl ProcessingStep for CropFaceStep { // 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)); + ctx.set_computed(computed_keys::FACE_RECT, ComputedValue::FaceRect(crop_result.face_rect)); StepOutcome::Continue(ctx) } Err(e) => StepOutcome::Skip { @@ -110,8 +110,8 @@ mod tests { let config = Config::default(); match step.execute(ctx, &config).await { - StepOutcome::Error(msg) => { - assert!(msg.contains("No image")); + StepOutcome::Error { error, .. } => { + assert!(error.contains("No image")); } _ => panic!("Expected Error"), } diff --git a/src/pipeline/steps/decode.rs b/src/pipeline/steps/decode.rs index cffd852..e80f1ae 100644 --- a/src/pipeline/steps/decode.rs +++ b/src/pipeline/steps/decode.rs @@ -27,9 +27,10 @@ impl ProcessingStep for DecodeImageStep { let raw_bytes = match &ctx.raw_bytes { Some(bytes) => bytes, None => { - return StepOutcome::Error( - "No raw bytes available for decoding".to_string(), - ); + return StepOutcome::Error { + ctx, + error: "No raw bytes available for decoding".to_string(), + }; } }; @@ -125,8 +126,8 @@ mod tests { let config = Config::default(); match step.execute(ctx, &config).await { - StepOutcome::Error(msg) => { - assert!(msg.contains("No raw bytes")); + StepOutcome::Error { error, .. } => { + assert!(error.contains("No raw bytes")); } _ => panic!("Expected Error"), } diff --git a/src/pipeline/steps/eye_filter.rs b/src/pipeline/steps/eye_filter.rs index d9508c9..f4913a1 100644 --- a/src/pipeline/steps/eye_filter.rs +++ b/src/pipeline/steps/eye_filter.rs @@ -4,7 +4,7 @@ //! from facial landmarks. use crate::config::Config; -use crate::pipeline::{Landmarks, PipelineContext, ProcessingStep, StepOutcome}; +use crate::pipeline::{computed_keys, draw_simple_text, Landmarks, PipelineContext, ProcessingStep, StepOutcome}; use async_trait::async_trait; use image::{DynamicImage, Rgb, RgbImage}; @@ -33,7 +33,7 @@ impl ProcessingStep for EyeFilterStep { } // Get EAR from computed values (set by LandmarksStep) - let avg_ear = match ctx.get_computed("ear").and_then(|v| v.as_float()) { + let avg_ear = match ctx.get_computed(computed_keys::EAR).and_then(|v| v.as_float()) { Some(ear) => ear, None => { // No EAR available - landmarks step must have been skipped @@ -60,7 +60,7 @@ impl ProcessingStep for EyeFilterStep { fn debug_visualize(&self, ctx: &PipelineContext, _config: &Config) -> Option { // Get landmarks for eye visualization let landmarks: &Landmarks = ctx - .get_computed("landmarks") + .get_computed(computed_keys::LANDMARKS) .and_then(|v| v.as_landmarks())?; // Get EAR values @@ -170,53 +170,6 @@ fn draw_marker(img: &mut RgbImage, x: u32, y: u32, color: Rgb) { } } -/// Draw simple text using a basic 5x7 pixel font. -fn draw_simple_text(img: &mut RgbImage, x: u32, y: u32, text: &str, color: Rgb) { - let (width, height) = (img.width(), img.height()); - let mut cursor_x = x; - - for ch in text.chars() { - let pattern = get_char_pattern(ch); - for (row_idx, row) in pattern.iter().enumerate() { - for col in 0..5 { - if (row >> (4 - col)) & 1 == 1 { - let px = cursor_x + col; - let py = y + row_idx as u32; - if px < width && py < height { - img.put_pixel(px, py, color); - } - } - } - } - cursor_x += 6; // 5 pixels wide + 1 pixel spacing - } -} - -/// Get a 5x7 pixel pattern for a character. -fn get_char_pattern(ch: char) -> [u8; 7] { - match ch { - '0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], - '1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], - '2' => [0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111], - '3' => [0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110], - '4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], - '5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], - '6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], - '7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], - '8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], - '9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100], - 'L' => [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111], - 'R' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001], - 'A' => [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001], - 'v' => [0b00000, 0b00000, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100], - 'g' => [0b00000, 0b00000, 0b01111, 0b10001, 0b01111, 0b00001, 0b01110], - ':' => [0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000], - '.' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100], - ' ' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000], - _ => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000], - } -} - #[cfg(test)] mod tests { use super::*; @@ -239,7 +192,7 @@ mod tests { async fn test_disabled_skips_check() { let step = EyeFilterStep; let mut ctx = make_test_ctx(); - ctx.set_computed("ear", ComputedValue::Float(0.1)); // Below threshold + ctx.set_computed(computed_keys::EAR, ComputedValue::Float(0.1)); // Below threshold let mut config = Config::default(); config.processing.eye_filter.enabled = false; @@ -253,7 +206,7 @@ mod tests { async fn test_below_threshold_skips() { let step = EyeFilterStep; let mut ctx = make_test_ctx(); - ctx.set_computed("ear", ComputedValue::Float(0.1)); // Below default 0.2 threshold + ctx.set_computed(computed_keys::EAR, ComputedValue::Float(0.1)); // Below default 0.2 threshold let mut config = Config::default(); config.processing.eye_filter.enabled = true; config.processing.eye_filter.min_ear = 0.2; @@ -270,7 +223,7 @@ mod tests { async fn test_above_threshold_continues() { let step = EyeFilterStep; let mut ctx = make_test_ctx(); - ctx.set_computed("ear", ComputedValue::Float(0.3)); // Above threshold + ctx.set_computed(computed_keys::EAR, ComputedValue::Float(0.3)); // Above threshold let mut config = Config::default(); config.processing.eye_filter.enabled = true; config.processing.eye_filter.min_ear = 0.2; diff --git a/src/pipeline/steps/face_resolution.rs b/src/pipeline/steps/face_resolution.rs index d86db31..add16e4 100644 --- a/src/pipeline/steps/face_resolution.rs +++ b/src/pipeline/steps/face_resolution.rs @@ -3,7 +3,7 @@ //! Skips images where the detected face is too small. use crate::config::Config; -use crate::pipeline::{ComputedValue, PipelineContext, ProcessingStep, StepOutcome}; +use crate::pipeline::{computed_keys, ComputedValue, PipelineContext, ProcessingStep, StepOutcome}; use async_trait::async_trait; /// Validates that the face bounding box meets minimum size requirements. @@ -38,7 +38,7 @@ impl ProcessingStep for FaceResolutionStep { let face_size = face_width.min(face_height) as i32; // Store computed value for later steps or debugging - ctx.set_computed("face_size", ComputedValue::Int(face_size)); + ctx.set_computed(computed_keys::FACE_SIZE, ComputedValue::Int(face_size)); let threshold = step_config.min_size as i32; @@ -96,7 +96,7 @@ mod tests { StepOutcome::Continue(new_ctx) => { // Should have stored face_size assert_eq!( - new_ctx.get_computed("face_size").and_then(|v| v.as_int()), + new_ctx.get_computed(computed_keys::FACE_SIZE).and_then(|v| v.as_int()), Some(100) ); } diff --git a/src/pipeline/steps/head_pose.rs b/src/pipeline/steps/head_pose.rs index 6ff3509..d4f28cc 100644 --- a/src/pipeline/steps/head_pose.rs +++ b/src/pipeline/steps/head_pose.rs @@ -5,7 +5,7 @@ use crate::config::Config; use crate::models::DMHeadModel; -use crate::pipeline::{ComputedValue, PipelineContext, ProcessingStep, StepOutcome}; +use crate::pipeline::{computed_keys, draw_simple_text, ComputedValue, PipelineContext, ProcessingStep, StepOutcome}; use async_trait::async_trait; use image::{DynamicImage, GenericImageView, Rgb, RgbImage}; @@ -35,7 +35,7 @@ impl ProcessingStep for HeadPoseStep { let image = match ctx.require_image("head pose estimation") { Ok(img) => img, - Err(e) => return StepOutcome::Error(e), + Err(e) => return StepOutcome::Error { ctx, error: e }, }; // Load the DMHead model @@ -51,7 +51,7 @@ impl ProcessingStep for HeadPoseStep { // Extract a tighter face crop if we have the face rectangle // DMHead works better with tight face crops centered on the face let face_image: DynamicImage = if let Some(face_rect) = ctx - .get_computed("face_rect") + .get_computed(computed_keys::FACE_RECT) .and_then(|v| v.as_face_rect()) { // Use the face rectangle to extract a tighter crop @@ -83,12 +83,15 @@ impl ProcessingStep for HeadPoseStep { let pose = match model.estimate(&face_image) { Ok(p) => p, Err(e) => { - return StepOutcome::Error(format!("Head pose estimation failed: {}", e)); + return StepOutcome::Error { + ctx, + error: format!("Head pose estimation failed: {}", e), + }; } }; // Store pose in computed values - ctx.set_computed("head_pose", ComputedValue::HeadPose(pose)); + ctx.set_computed(computed_keys::HEAD_POSE, ComputedValue::HeadPose(pose)); // Check against thresholds let head_pose_config = &config.processing.head_pose; @@ -139,7 +142,7 @@ impl ProcessingStep for HeadPoseStep { fn debug_visualize(&self, ctx: &PipelineContext, _config: &Config) -> Option { // Get head pose from computed values let pose = ctx - .get_computed("head_pose") + .get_computed(computed_keys::HEAD_POSE) .and_then(|v| v.as_head_pose())?; // Get the current image to draw on @@ -243,54 +246,6 @@ fn draw_line(img: &mut RgbImage, x0: i32, y0: i32, x1: i32, y1: i32, color: Rgb< } } -/// Draw simple text using a basic 5x7 pixel font. -/// Only supports basic ASCII characters needed for pose display. -fn draw_simple_text(img: &mut RgbImage, x: u32, y: u32, text: &str, color: Rgb) { - let (width, height) = (img.width(), img.height()); - let mut cursor_x = x; - - for ch in text.chars() { - let pattern = get_char_pattern(ch); - for (row_idx, row) in pattern.iter().enumerate() { - for col in 0..5 { - if (row >> (4 - col)) & 1 == 1 { - let px = cursor_x + col; - let py = y + row_idx as u32; - if px < width && py < height { - img.put_pixel(px, py, color); - } - } - } - } - cursor_x += 6; // 5 pixels wide + 1 pixel spacing - } -} - -/// Get a 5x7 pixel pattern for a character. -fn get_char_pattern(ch: char) -> [u8; 7] { - match ch { - '0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], - '1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], - '2' => [0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111], - '3' => [0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110], - '4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], - '5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], - '6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], - '7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], - '8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], - '9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100], - 'Y' => [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100], - 'P' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000], - 'R' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001], - ':' => [0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000], - '+' => [0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000], - '-' => [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000], - ' ' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000], - '.' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100], - _ => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000], - } -} - #[cfg(test)] mod tests { use super::*; @@ -334,8 +289,8 @@ mod tests { config.processing.head_pose.enabled = true; match step.execute(ctx, &config).await { - StepOutcome::Error(msg) => { - assert!(msg.contains("No image")); + StepOutcome::Error { error, .. } => { + assert!(error.contains("No image")); } other => panic!("Expected Error, got {:?}", other), } diff --git a/src/pipeline/steps/landmarks.rs b/src/pipeline/steps/landmarks.rs index 6d182b0..bc6f891 100644 --- a/src/pipeline/steps/landmarks.rs +++ b/src/pipeline/steps/landmarks.rs @@ -4,7 +4,7 @@ use crate::config::Config; use crate::models::DlibLandmarks; -use crate::pipeline::{ComputedValue, Landmarks, PipelineContext, ProcessingStep, StepOutcome}; +use crate::pipeline::{computed_keys, ComputedValue, Landmarks, PipelineContext, ProcessingStep, StepOutcome}; use async_trait::async_trait; use tokio::task; @@ -39,7 +39,7 @@ impl ProcessingStep for LandmarksStep { let image = match ctx.require_image("landmark detection") { Ok(img) => img, - Err(e) => return StepOutcome::Error(e), + Err(e) => return StepOutcome::Error { ctx, error: e }, }; // Get the global landmark predictor (loaded once, reused for all images) @@ -63,7 +63,7 @@ impl ProcessingStep for LandmarksStep { // Get the face rectangle if available let face_rect: Option<(i64, i64, i64, i64)> = ctx - .get_computed("face_rect") + .get_computed(computed_keys::FACE_RECT) .and_then(|v| v.as_face_rect()) .map(|r| (r.x1 as i64, r.y1 as i64, r.x2 as i64, r.y2 as i64)); @@ -84,17 +84,20 @@ impl ProcessingStep for LandmarksStep { }; } Err(e) => { - return StepOutcome::Error(format!("Landmark detection task failed: {}", e)); + return StepOutcome::Error { + ctx, + error: format!("Landmark detection task failed: {}", e), + }; } }; // Compute and store EAR let ear = landmarks.eye_aspect_ratio(); let avg_ear = (ear.left + ear.right) / 2.0; - ctx.set_computed("ear", ComputedValue::Float(avg_ear)); + ctx.set_computed(computed_keys::EAR, ComputedValue::Float(avg_ear)); // Store landmarks - ctx.set_computed("landmarks", ComputedValue::Landmarks(Box::new(landmarks))); + ctx.set_computed(computed_keys::LANDMARKS, ComputedValue::Landmarks(Box::new(landmarks))); tracing::trace!( "Landmarks detected: EAR left={:.3}, right={:.3}, avg={:.3}", @@ -151,8 +154,8 @@ mod tests { config.processing.alignment.enabled = true; match step.execute(ctx, &config).await { - StepOutcome::Error(msg) => { - assert!(msg.contains("No image")); + StepOutcome::Error { error, .. } => { + assert!(error.contains("No image")); } other => panic!("Expected Error, got {:?}", other), } diff --git a/src/pipeline/steps/resize.rs b/src/pipeline/steps/resize.rs index 4248737..23097f4 100644 --- a/src/pipeline/steps/resize.rs +++ b/src/pipeline/steps/resize.rs @@ -26,7 +26,7 @@ impl ProcessingStep for ResizeStep { 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(e), + Err(e) => return StepOutcome::Error { ctx, error: e }, }; let output_size = config.processing.output.size; @@ -97,8 +97,8 @@ mod tests { let config = Config::default(); match step.execute(ctx, &config).await { - StepOutcome::Error(msg) => { - assert!(msg.contains("No image")); + StepOutcome::Error { error, .. } => { + assert!(error.contains("No image")); } _ => panic!("Expected Error"), } diff --git a/src/pipeline/traits.rs b/src/pipeline/traits.rs index aabfa50..8b9ae69 100644 --- a/src/pipeline/traits.rs +++ b/src/pipeline/traits.rs @@ -12,6 +12,23 @@ use image::DynamicImage; use std::collections::HashMap; use std::fmt; +/// Keys for computed values stored in `PipelineContext`. +/// Use these constants to avoid typos and make dependencies explicit. +pub mod computed_keys { + /// Brightness value (0.0 - 1.0) computed by BrightnessStep. + pub const BRIGHTNESS: &str = "brightness"; + /// Face size in pixels computed by FaceResolutionStep. + pub const FACE_SIZE: &str = "face_size"; + /// Eye Aspect Ratio computed by LandmarksStep. + pub const EAR: &str = "ear"; + /// Facial landmarks (68 points) computed by LandmarksStep. + 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. + pub const FACE_RECT: &str = "face_rect"; +} + /// Outcome of a pipeline step execution. #[derive(Debug)] pub enum StepOutcome { @@ -23,8 +40,11 @@ pub enum StepOutcome { reason: String, detail: Option, }, - /// An error occurred during processing. - Error(String), + /// An error occurred during processing. Context is preserved for debug visualization. + Error { + ctx: PipelineContext, + error: String, + }, } /// Values computed by pipeline steps that can be shared with subsequent steps. @@ -231,6 +251,22 @@ pub trait ProcessingStep: Send + Sync { /// Human-readable name for display. fn name(&self) -> &'static str; + /// Computed value keys that this step depends on (must be present in `ctx.computed`). + /// + /// Return a list of keys from `computed_keys` that must be available for this step. + /// The pipeline will verify these dependencies are satisfied before execution. + fn dependencies(&self) -> Vec<&'static str> { + vec![] + } + + /// Computed value keys that this step provides (sets in `ctx.computed`). + /// + /// Return a list of keys from `computed_keys` that this step will set. + /// This is used for documentation and dependency verification. + fn provides(&self) -> Vec<&'static str> { + vec![] + } + /// Execute this step on the given context. /// /// Returns: @@ -241,10 +277,11 @@ pub trait ProcessingStep: Send + Sync { /// Generate a debug visualization for this step (optional). /// - /// Called after `execute()` if debug mode is enabled and the step - /// returned `Continue`. The returned image is saved to the debug folder. + /// Called after `execute()` if `keep_intermediates` is enabled and the step + /// returned `Continue` or `Skip`. The returned image is saved to the debug folder. /// - /// Steps should return `None` if they are disabled (check config.processing.X.enabled). + /// Only steps included in the pipeline (via `Pipeline::with_steps_from_config`) + /// will have their debug images generated - disabled steps are not in the pipeline. fn debug_visualize(&self, _ctx: &PipelineContext, _config: &Config) -> Option { None } @@ -297,15 +334,15 @@ mod tests { face_data, ); - ctx.set_computed("brightness", ComputedValue::Float(0.65)); - ctx.set_computed("face_size", ComputedValue::Int(150)); + ctx.set_computed(computed_keys::BRIGHTNESS, ComputedValue::Float(0.65)); + ctx.set_computed(computed_keys::FACE_SIZE, ComputedValue::Int(150)); assert_eq!( - ctx.get_computed("brightness").and_then(|v| v.as_float()), + ctx.get_computed(computed_keys::BRIGHTNESS).and_then(|v| v.as_float()), Some(0.65) ); assert_eq!( - ctx.get_computed("face_size").and_then(|v| v.as_int()), + ctx.get_computed(computed_keys::FACE_SIZE).and_then(|v| v.as_int()), Some(150) ); assert!(ctx.get_computed("nonexistent").is_none());