immich-automated-selfie-tim.../src/pipeline/traits.rs
2026-02-12 20:20:12 +01:00

354 lines
11 KiB
Rust

//! Pipeline traits and core types.
//!
//! Defines the `ProcessingStep` trait and associated types for building
//! extensible image processing pipelines.
use crate::config::Config;
use crate::immich_api::FaceData;
use crate::pipeline::types::{BoundingBox, HeadPose, Landmarks};
use async_trait::async_trait;
use bytes::Bytes;
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";
/// Blur metric (gradient magnitude) computed by BlurStep.
pub const BLUR_METRIC: &str = "blur_metric";
/// 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 CropAndResizeStep.
pub const FACE_RECT: &str = "face_rect";
}
/// Outcome of a pipeline step execution.
#[derive(Debug)]
pub enum StepOutcome {
/// Continue to the next step with the updated context.
Continue(PipelineContext),
/// Skip this image with the given reason. Context is returned for debug visualization.
Skip {
ctx: PipelineContext,
reason: String,
detail: Option<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.
#[derive(Debug, Clone)]
pub enum ComputedValue {
/// A floating-point value (e.g., brightness, EAR).
Float(f32),
/// An integer value (e.g., face size in pixels).
Int(i32),
/// A boolean value (e.g., eyes_open).
Bool(bool),
/// A string value.
String(String),
/// Head pose estimation result (yaw, pitch, roll).
HeadPose(HeadPose),
/// Facial landmarks (68 points).
Landmarks(Box<Landmarks>),
/// Face bounding box in current image coordinates.
FaceRect(BoundingBox),
}
impl ComputedValue {
/// Get as f32 if this is a Float variant.
pub fn as_float(&self) -> Option<f32> {
match self {
ComputedValue::Float(v) => Some(*v),
_ => None,
}
}
/// Get as i32 if this is an Int variant.
pub fn as_int(&self) -> Option<i32> {
match self {
ComputedValue::Int(v) => Some(*v),
_ => None,
}
}
/// Get as bool if this is a Bool variant.
pub fn as_bool(&self) -> Option<bool> {
match self {
ComputedValue::Bool(v) => Some(*v),
_ => None,
}
}
/// Get as &str if this is a String variant.
pub fn as_str(&self) -> Option<&str> {
match self {
ComputedValue::String(v) => Some(v),
_ => None,
}
}
/// Get as HeadPose if this is a HeadPose variant.
pub fn as_head_pose(&self) -> Option<&HeadPose> {
match self {
ComputedValue::HeadPose(v) => Some(v),
_ => None,
}
}
/// Get as Landmarks if this is a Landmarks variant.
pub fn as_landmarks(&self) -> Option<&Landmarks> {
match self {
ComputedValue::Landmarks(v) => Some(v),
_ => None,
}
}
/// Get as BoundingBox if this is a FaceRect variant.
pub fn as_face_rect(&self) -> Option<&BoundingBox> {
match self {
ComputedValue::FaceRect(v) => Some(v),
_ => None,
}
}
}
/// A debug image with metadata about whether the step passed or failed.
#[derive(Debug, Clone)]
pub struct DebugImage {
/// The debug visualization image.
pub image: DynamicImage,
/// Whether the step that generated this image passed (true) or failed/skipped (false).
pub passed: bool,
}
impl DebugImage {
/// Create a new debug image.
pub fn new(image: DynamicImage, passed: bool) -> Self {
Self { image, passed }
}
}
/// Context passed through the pipeline, carrying data between steps.
#[derive(Debug)]
pub struct PipelineContext {
/// The asset ID from Immich.
pub asset_id: String,
/// Timestamp for sorting/naming output files.
pub timestamp: String,
/// Raw image bytes (before decoding).
pub raw_bytes: Option<Bytes>,
/// Decoded image (after decode step).
pub image: Option<DynamicImage>,
/// Face bounding box and metadata from Immich.
pub face_data: FaceData,
/// Values computed by previous steps (e.g., brightness, landmarks).
pub computed: HashMap<String, ComputedValue>,
/// Debug images generated by steps (step_id -> debug image with pass/fail status).
pub debug_images: HashMap<String, DebugImage>,
}
impl PipelineContext {
/// Create a new pipeline context.
pub fn new(asset_id: String, timestamp: String, face_data: FaceData) -> Self {
Self {
asset_id,
timestamp,
raw_bytes: None,
image: None,
face_data,
computed: HashMap::new(),
debug_images: HashMap::new(),
}
}
/// Set the raw image bytes.
pub fn with_bytes(mut self, bytes: Bytes) -> Self {
self.raw_bytes = Some(bytes);
self
}
/// Set the decoded image.
pub fn with_image(mut self, image: DynamicImage) -> Self {
self.image = Some(image);
self
}
/// Add a computed value.
pub fn set_computed(&mut self, key: impl Into<String>, value: ComputedValue) {
self.computed.insert(key.into(), value);
}
/// Get a computed value.
pub fn get_computed(&self, key: &str) -> Option<&ComputedValue> {
self.computed.get(key)
}
/// Get a reference to the image, returning an error message if not available.
///
/// Use this in pipeline steps that need to read the image without modifying it.
///
/// # Example
/// ```ignore
/// let image = ctx.require_image("brightness check")?;
/// ```
pub fn require_image(&self, step_name: &str) -> Result<&DynamicImage, String> {
self.image
.as_ref()
.ok_or_else(|| format!("No image available for {}", step_name))
}
/// Take ownership of the image, returning an error message if not available.
///
/// Use this in pipeline steps that need to transform the image (alignment, resize).
/// The step should set `ctx.image` to the transformed result before returning.
///
/// # Example
/// ```ignore
/// let image = ctx.take_image("alignment")?;
/// // ... transform image ...
/// ctx.image = Some(transformed);
/// ```
pub fn take_image(&mut self, step_name: &str) -> Result<DynamicImage, String> {
self.image
.take()
.ok_or_else(|| format!("No image available for {}", step_name))
}
/// Add a debug image for a step.
///
/// # Arguments
/// * `step_id` - The identifier of the step that generated this image
/// * `image` - The debug visualization image
/// * `passed` - Whether the step passed (true) or failed/skipped (false)
pub fn add_debug_image(
&mut self,
step_id: impl Into<String>,
image: DynamicImage,
passed: bool,
) {
self.debug_images
.insert(step_id.into(), DebugImage::new(image, passed));
}
}
/// A single step in the processing pipeline.
///
/// Steps can be:
/// - **Validators**: Skip the image if a condition fails (return `StepOutcome::Skip`)
/// - **Transformers**: Modify the image (update `ctx.image`)
/// - **Computers**: Calculate values for later steps (update `ctx.computed`)
#[async_trait]
pub trait ProcessingStep: Send + Sync {
/// Unique identifier for this step (used in skip stats and debug output).
fn id(&self) -> &'static str;
/// 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.
/// These are informational only; the pipeline does not currently enforce them.
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.
/// These are informational only; the pipeline does not currently enforce them.
fn provides(&self) -> Vec<&'static str> {
vec![]
}
/// Execute this step on the given context.
///
/// Returns:
/// - `StepOutcome::Continue(ctx)` to proceed to the next step
/// - `StepOutcome::Skip { reason, detail }` to skip this image
/// - `StepOutcome::Error(msg)` if processing failed
async fn execute(&self, ctx: PipelineContext, config: &Config) -> StepOutcome;
/// Generate a debug visualization for this step (optional).
///
/// Called after `execute()` if `keep_intermediates` is enabled and the step
/// returned `Continue` or `Skip`. The returned image is saved to the debug folder.
///
/// 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
}
}
impl fmt::Debug for dyn ProcessingStep {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProcessingStep")
.field("id", &self.id())
.field("name", &self.name())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_computed_value_conversions() {
let float_val = ComputedValue::Float(0.5);
assert_eq!(float_val.as_float(), Some(0.5));
assert_eq!(float_val.as_int(), None);
let int_val = ComputedValue::Int(100);
assert_eq!(int_val.as_int(), Some(100));
assert_eq!(int_val.as_float(), None);
let bool_val = ComputedValue::Bool(true);
assert_eq!(bool_val.as_bool(), Some(true));
let str_val = ComputedValue::String("test".to_string());
assert_eq!(str_val.as_str(), Some("test"));
}
#[test]
fn test_pipeline_context_computed_values() {
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: 1920,
image_height: 1080,
};
let mut ctx =
PipelineContext::new("asset123".to_string(), "2024-01-15".to_string(), face_data);
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(computed_keys::BRIGHTNESS)
.and_then(|v| v.as_float()),
Some(0.65)
);
assert_eq!(
ctx.get_computed(computed_keys::FACE_SIZE)
.and_then(|v| v.as_int()),
Some(150)
);
assert!(ctx.get_computed("nonexistent").is_none());
}
}