Code cleanup in the pipeline module, clean up duplicated code.

This commit is contained in:
Arnaud_Cayrol 2026-02-04 19:14:13 +01:00
parent 1767096d00
commit 9554c6d2fb
14 changed files with 331 additions and 215 deletions

View file

@ -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(),
));
}
}

View file

@ -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

View file

@ -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<u8>) {
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));
}
}

View file

@ -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, &timestamp, &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"));
}
}

View file

@ -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();

View file

@ -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<DynamicImage> {
// 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<u8> {
}
}
/// Draw simple text using a basic 5x7 pixel font.
fn draw_simple_text(img: &mut RgbImage, x: u32, y: u32, text: &str, color: Rgb<u8>) {
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);

View file

@ -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"),
}

View file

@ -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"),
}

View file

@ -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<DynamicImage> {
// 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<u8>) {
}
}
/// Draw simple text using a basic 5x7 pixel font.
fn draw_simple_text(img: &mut RgbImage, x: u32, y: u32, text: &str, color: Rgb<u8>) {
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;

View file

@ -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)
);
}

View file

@ -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<DynamicImage> {
// 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<u8>) {
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),
}

View file

@ -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),
}

View file

@ -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"),
}

View file

@ -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<String>,
},
/// 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<DynamicImage> {
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());