Fix clippy warnings and apply auto formatting.
This commit is contained in:
parent
06098add58
commit
6ba592a84e
21 changed files with 239 additions and 153 deletions
|
|
@ -330,12 +330,14 @@ impl AlignmentConfig {
|
|||
}
|
||||
if self.eye_y_position < 0.2 || self.eye_y_position > 0.5 {
|
||||
return Err(Error::Config(
|
||||
"Alignment eye_y_position should be between 0.2 and 0.5 for best results".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(),
|
||||
"Alignment inter_eye_distance must be greater than 0 to prevent division by zero"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if self.inter_eye_distance >= 1.0 {
|
||||
|
|
@ -345,7 +347,8 @@ impl AlignmentConfig {
|
|||
}
|
||||
if self.inter_eye_distance < 0.2 || self.inter_eye_distance > 0.5 {
|
||||
return Err(Error::Config(
|
||||
"Alignment inter_eye_distance should be between 0.2 and 0.5 for best results".to_string(),
|
||||
"Alignment inter_eye_distance should be between 0.2 and 0.5 for best results"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -509,8 +512,7 @@ impl Config {
|
|||
/// Load configuration from a TOML file.
|
||||
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let config: Config =
|
||||
toml::from_str(&content).map_err(|e| Error::Config(e.to_string()))?;
|
||||
let config: Config = toml::from_str(&content).map_err(|e| Error::Config(e.to_string()))?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
|
@ -708,7 +710,9 @@ mod tests {
|
|||
let temp_dir = std::env::temp_dir();
|
||||
let temp_path = temp_dir.join("test_config_new.toml");
|
||||
|
||||
config.save_to_file(&temp_path).expect("Failed to save config");
|
||||
config
|
||||
.save_to_file(&temp_path)
|
||||
.expect("Failed to save config");
|
||||
|
||||
// Verify file was created and contains expected content
|
||||
let content = std::fs::read_to_string(&temp_path).expect("Failed to read config");
|
||||
|
|
|
|||
10
src/main.rs
10
src/main.rs
|
|
@ -38,7 +38,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
// Pre-load ML models to avoid loading during processing
|
||||
match DlibLandmarks::init() {
|
||||
Ok(_) => tracing::info!("Dlib landmarks model loaded"),
|
||||
Err(e) => tracing::warn!("Dlib landmarks model not available: {} - landmark detection will be skipped", e),
|
||||
Err(e) => tracing::warn!(
|
||||
"Dlib landmarks model not available: {} - landmark detection will be skipped",
|
||||
e
|
||||
),
|
||||
}
|
||||
|
||||
// Create application state
|
||||
|
|
@ -69,7 +72,10 @@ fn load_config() -> anyhow::Result<Config> {
|
|||
|
||||
// Validation is optional at startup - API key might be set via web UI later
|
||||
if let Err(e) = config.validate() {
|
||||
tracing::warn!("Configuration incomplete: {} - some features may not work", e);
|
||||
tracing::warn!(
|
||||
"Configuration incomplete: {} - some features may not work",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
|
|
|
|||
|
|
@ -129,9 +129,8 @@ impl DlibLandmarks {
|
|||
.map(|p| Point::new(p.x() as f32, p.y() as f32))
|
||||
.collect();
|
||||
|
||||
Landmarks::new(points).ok_or_else(|| {
|
||||
Error::Model("Could not detect 68 facial landmarks".to_string())
|
||||
})
|
||||
Landmarks::new(points)
|
||||
.ok_or_else(|| Error::Model("Could not detect 68 facial landmarks".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,12 @@ pub fn crop_face_with_intermediate(
|
|||
///
|
||||
/// When the crop region extends past the image borders, edge pixels are repeated
|
||||
/// (e.g., column -1 uses column 0, column -2 uses column 0, etc.).
|
||||
fn crop_with_replicate_fill(img: &DynamicImage, x_offset: i32, y_offset: i32, size: u32) -> DynamicImage {
|
||||
fn crop_with_replicate_fill(
|
||||
img: &DynamicImage,
|
||||
x_offset: i32,
|
||||
y_offset: i32,
|
||||
size: u32,
|
||||
) -> DynamicImage {
|
||||
let rgb = img.to_rgb8();
|
||||
let (w, h) = (rgb.width() as i32, rgb.height() as i32);
|
||||
|
||||
|
|
|
|||
|
|
@ -36,31 +36,81 @@ pub fn draw_simple_text(img: &mut RgbImage, x: u32, y: u32, text: &str, color: R
|
|||
/// 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],
|
||||
'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,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
//! across all images in the timelapse.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::pipeline::{computed_keys, Point, PipelineContext, ProcessingStep, StepOutcome};
|
||||
use crate::pipeline::{computed_keys, PipelineContext, Point, ProcessingStep, StepOutcome};
|
||||
use async_trait::async_trait;
|
||||
use image::{DynamicImage, GenericImageView, Rgb};
|
||||
use imageproc::geometric_transformations::{rotate_about_center, Interpolation};
|
||||
|
|
@ -39,7 +39,8 @@ impl ProcessingStep for AlignmentStep {
|
|||
// Landmarks should always be available since LandmarksStep is mandatory
|
||||
return StepOutcome::Error {
|
||||
ctx,
|
||||
error: "Landmarks not available for alignment - pipeline misconfigured".to_string(),
|
||||
error: "Landmarks not available for alignment - pipeline misconfigured"
|
||||
.to_string(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -101,10 +102,8 @@ impl ProcessingStep for AlignmentStep {
|
|||
// 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,
|
||||
);
|
||||
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)
|
||||
|
|
@ -116,8 +115,10 @@ impl ProcessingStep for AlignmentStep {
|
|||
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;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
//! Calculates average image brightness and skips images that are too dark or too bright.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::pipeline::{computed_keys, draw_simple_text, 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};
|
||||
|
||||
|
|
@ -173,7 +175,7 @@ fn brightness_to_color(brightness: f32) -> Rgb<u8> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::immich_api::FaceData;
|
||||
use image::{DynamicImage, RgbImage, Rgb};
|
||||
use image::{DynamicImage, Rgb, RgbImage};
|
||||
|
||||
fn make_ctx_with_image(image: DynamicImage) -> PipelineContext {
|
||||
let face_data = FaceData {
|
||||
|
|
@ -225,7 +227,8 @@ 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(computed_keys::BRIGHTNESS)
|
||||
let brightness = new_ctx
|
||||
.get_computed(computed_keys::BRIGHTNESS)
|
||||
.and_then(|v| v.as_float())
|
||||
.unwrap();
|
||||
assert!(brightness > 0.45 && brightness < 0.55);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
|
||||
use crate::config::Config;
|
||||
use crate::pipeline::crop_face_with_intermediate;
|
||||
use crate::pipeline::{computed_keys, BoundingBox, ComputedValue, PipelineContext, ProcessingStep, StepOutcome};
|
||||
use crate::pipeline::{
|
||||
computed_keys, BoundingBox, ComputedValue, PipelineContext, ProcessingStep, StepOutcome,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Crops the face region from the full image and resizes it.
|
||||
|
|
@ -50,7 +52,10 @@ impl ProcessingStep for CropAndResizeStep {
|
|||
};
|
||||
|
||||
// Store the scaled face rectangle for later steps
|
||||
ctx.set_computed(computed_keys::FACE_RECT, ComputedValue::FaceRect(scaled_face_rect));
|
||||
ctx.set_computed(
|
||||
computed_keys::FACE_RECT,
|
||||
ComputedValue::FaceRect(scaled_face_rect),
|
||||
);
|
||||
|
||||
StepOutcome::Continue(ctx)
|
||||
}
|
||||
|
|
@ -67,7 +72,7 @@ impl ProcessingStep for CropAndResizeStep {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::immich_api::FaceData;
|
||||
use image::{DynamicImage, RgbImage, Rgb};
|
||||
use image::{DynamicImage, Rgb, RgbImage};
|
||||
|
||||
fn make_ctx_with_image(image: DynamicImage) -> PipelineContext {
|
||||
// Face in the center of a 100x100 image
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ impl ProcessingStep for DecodeImageStep {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bytes::Bytes;
|
||||
use crate::immich_api::FaceData;
|
||||
use bytes::Bytes;
|
||||
use image::{DynamicImage, RgbImage};
|
||||
use std::io::Cursor;
|
||||
|
||||
|
|
@ -63,7 +63,9 @@ mod tests {
|
|||
let dynamic = DynamicImage::ImageRgb8(img);
|
||||
|
||||
let mut buffer = Cursor::new(Vec::new());
|
||||
dynamic.write_to(&mut buffer, image::ImageFormat::Jpeg).unwrap();
|
||||
dynamic
|
||||
.write_to(&mut buffer, image::ImageFormat::Jpeg)
|
||||
.unwrap();
|
||||
|
||||
Bytes::from(buffer.into_inner())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
//! from facial landmarks.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::pipeline::{computed_keys, draw_simple_text, 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 +35,10 @@ impl ProcessingStep for EyeFilterStep {
|
|||
}
|
||||
|
||||
// Get EAR from computed values (set by LandmarksStep)
|
||||
let avg_ear = match ctx.get_computed(computed_keys::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
|
||||
|
|
@ -85,8 +90,7 @@ impl ProcessingStep for EyeFilterStep {
|
|||
Rgb([255, 0, 0]) // Red - closed
|
||||
};
|
||||
|
||||
for i in 36..42 {
|
||||
let point = &points[i];
|
||||
for point in points.iter().skip(36).take(6) {
|
||||
draw_cross(&mut debug_img, point.x as u32, point.y as u32, left_color);
|
||||
}
|
||||
|
||||
|
|
@ -97,16 +101,25 @@ impl ProcessingStep for EyeFilterStep {
|
|||
Rgb([255, 0, 0]) // Red - closed
|
||||
};
|
||||
|
||||
for i in 42..48 {
|
||||
let point = &points[i];
|
||||
for point in points.iter().skip(42).take(6) {
|
||||
draw_cross(&mut debug_img, point.x as u32, point.y as u32, right_color);
|
||||
}
|
||||
|
||||
// Draw eye centers
|
||||
let left_eye = landmarks.left_eye_center();
|
||||
let right_eye = landmarks.right_eye_center();
|
||||
draw_marker(&mut debug_img, left_eye.x as u32, left_eye.y as u32, Rgb([0, 255, 255]));
|
||||
draw_marker(&mut debug_img, right_eye.x as u32, right_eye.y as u32, Rgb([0, 255, 255]));
|
||||
draw_marker(
|
||||
&mut debug_img,
|
||||
left_eye.x as u32,
|
||||
left_eye.y as u32,
|
||||
Rgb([0, 255, 255]),
|
||||
);
|
||||
draw_marker(
|
||||
&mut debug_img,
|
||||
right_eye.x as u32,
|
||||
right_eye.y as u32,
|
||||
Rgb([0, 255, 255]),
|
||||
);
|
||||
|
||||
// Draw info bar at bottom
|
||||
let bar_height = 20u32;
|
||||
|
|
@ -120,10 +133,7 @@ impl ProcessingStep for EyeFilterStep {
|
|||
}
|
||||
|
||||
// Draw EAR values
|
||||
let text = format!(
|
||||
"L:{:.2} R:{:.2} Avg:{:.2}",
|
||||
ear.left, ear.right, avg_ear
|
||||
);
|
||||
let text = format!("L:{:.2} R:{:.2} Avg:{:.2}", ear.left, ear.right, avg_ear);
|
||||
draw_simple_text(&mut debug_img, 5, bar_y + 6, &text, Rgb([255, 255, 255]));
|
||||
|
||||
Some(DynamicImage::ImageRgb8(debug_img))
|
||||
|
|
|
|||
|
|
@ -96,7 +96,9 @@ mod tests {
|
|||
StepOutcome::Continue(new_ctx) => {
|
||||
// Should have stored face_size
|
||||
assert_eq!(
|
||||
new_ctx.get_computed(computed_keys::FACE_SIZE).and_then(|v| v.as_int()),
|
||||
new_ctx
|
||||
.get_computed(computed_keys::FACE_SIZE)
|
||||
.and_then(|v| v.as_int()),
|
||||
Some(100)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,9 +85,9 @@ fn draw_pose_axes(
|
|||
// Y axis (green) - points down (image coordinates)
|
||||
// Z axis (blue) - points out of screen (towards camera)
|
||||
let axes = [
|
||||
(Point3D::new(axis_length, 0.0, 0.0), Rgb([255, 0, 0])), // X - red
|
||||
(Point3D::new(0.0, axis_length, 0.0), Rgb([0, 255, 0])), // Y - green
|
||||
(Point3D::new(0.0, 0.0, -axis_length), Rgb([0, 0, 255])), // Z - blue (negative = towards camera)
|
||||
(Point3D::new(axis_length, 0.0, 0.0), Rgb([255, 0, 0])), // X - red
|
||||
(Point3D::new(0.0, axis_length, 0.0), Rgb([0, 255, 0])), // Y - green
|
||||
(Point3D::new(0.0, 0.0, -axis_length), Rgb([0, 0, 255])), // Z - blue (negative = towards camera)
|
||||
];
|
||||
|
||||
// Negate roll and pitch to convert from model convention to image coordinates
|
||||
|
|
@ -333,7 +333,15 @@ impl ProcessingStep for HeadPoseStep {
|
|||
let cx = (x1 + x2) as f32 / 2.0;
|
||||
let cy = (y1 + y2) as f32 / 2.0;
|
||||
let axis_length = ((x2 - x1).max(y2 - y1) as f32) * 0.6;
|
||||
draw_pose_axes(&mut debug_img, cx, cy, axis_length, pose.yaw, pose.pitch, pose.roll);
|
||||
draw_pose_axes(
|
||||
&mut debug_img,
|
||||
cx,
|
||||
cy,
|
||||
axis_length,
|
||||
pose.yaw,
|
||||
pose.pitch,
|
||||
pose.roll,
|
||||
);
|
||||
}
|
||||
|
||||
// Draw text background bar at bottom for pose values
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
use crate::config::Config;
|
||||
use crate::models::DlibLandmarks;
|
||||
use crate::pipeline::{computed_keys, ComputedValue, Landmarks, PipelineContext, ProcessingStep, StepOutcome};
|
||||
use crate::pipeline::{
|
||||
computed_keys, ComputedValue, Landmarks, PipelineContext, ProcessingStep, StepOutcome,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use tokio::task;
|
||||
|
||||
|
|
@ -90,7 +92,10 @@ impl ProcessingStep for LandmarksStep {
|
|||
ctx.set_computed(computed_keys::EAR, ComputedValue::Float(avg_ear));
|
||||
|
||||
// Store landmarks
|
||||
ctx.set_computed(computed_keys::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}",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
//! extensible image processing pipelines.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::pipeline::types::{BoundingBox, HeadPose, Landmarks};
|
||||
use crate::immich_api::FaceData;
|
||||
use crate::pipeline::types::{BoundingBox, HeadPose, Landmarks};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use image::DynamicImage;
|
||||
|
|
@ -41,10 +41,7 @@ pub enum StepOutcome {
|
|||
detail: Option<String>,
|
||||
},
|
||||
/// An error occurred during processing. Context is preserved for debug visualization.
|
||||
Error {
|
||||
ctx: PipelineContext,
|
||||
error: String,
|
||||
},
|
||||
Error { ctx: PipelineContext, error: String },
|
||||
}
|
||||
|
||||
/// Values computed by pipeline steps that can be shared with subsequent steps.
|
||||
|
|
@ -232,8 +229,14 @@ impl PipelineContext {
|
|||
/// * `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));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -328,21 +331,20 @@ mod tests {
|
|||
image_height: 1080,
|
||||
};
|
||||
|
||||
let mut ctx = PipelineContext::new(
|
||||
"asset123".to_string(),
|
||||
"2024-01-15".to_string(),
|
||||
face_data,
|
||||
);
|
||||
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()),
|
||||
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()),
|
||||
ctx.get_computed(computed_keys::FACE_SIZE)
|
||||
.and_then(|v| v.as_int()),
|
||||
Some(150)
|
||||
);
|
||||
assert!(ctx.get_computed("nonexistent").is_none());
|
||||
|
|
|
|||
15
src/utils.rs
15
src/utils.rs
|
|
@ -19,10 +19,7 @@ pub fn sanitize_folder_name(name: Option<&str>, id: &str) -> String {
|
|||
let base = name.filter(|n| !n.is_empty()).unwrap_or(id);
|
||||
|
||||
// Remove accents by decomposing to NFD and filtering combining marks
|
||||
let without_accents: String = base
|
||||
.nfd()
|
||||
.filter(|c| !is_combining_mark(*c))
|
||||
.collect();
|
||||
let without_accents: String = base.nfd().filter(|c| !is_combining_mark(*c)).collect();
|
||||
|
||||
// Convert to lowercase and replace unsafe characters with underscores
|
||||
let sanitized: String = without_accents
|
||||
|
|
@ -71,10 +68,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_sanitize_folder_name_trims_whitespace() {
|
||||
assert_eq!(
|
||||
sanitize_folder_name(Some(" John Doe "), "id"),
|
||||
"john_doe"
|
||||
);
|
||||
assert_eq!(sanitize_folder_name(Some(" John Doe "), "id"), "john_doe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -99,6 +93,9 @@ mod tests {
|
|||
assert_eq!(sanitize_folder_name(Some("Peña"), "id"), "pena");
|
||||
|
||||
// Mixed accents
|
||||
assert_eq!(sanitize_folder_name(Some("Élève Français"), "id"), "eleve_francais");
|
||||
assert_eq!(
|
||||
sanitize_folder_name(Some("Élève Français"), "id"),
|
||||
"eleve_francais"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,12 +94,9 @@ where
|
|||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
Error::FFmpeg(format!(
|
||||
"Failed to spawn ffmpeg (is it installed?): {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| Error::FFmpeg(format!("Failed to spawn ffmpeg (is it installed?): {}", e)))?;
|
||||
|
||||
// Capture stdout for progress and stderr for errors
|
||||
let stdout = child
|
||||
|
|
@ -149,8 +146,7 @@ where
|
|||
|
||||
return Err(Error::FFmpeg(format!(
|
||||
"ffmpeg exited with status {}\n{}",
|
||||
status,
|
||||
error_detail
|
||||
status, error_detail
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -192,11 +192,7 @@ fn validate_video_config(vid: &VideoConfigUpdate) -> Result<(), ValidationError>
|
|||
if !valid_codecs.contains(&v.as_str()) {
|
||||
return Err(ValidationError::new(
|
||||
"video.codec",
|
||||
format!(
|
||||
"must be one of: {}, got '{}'",
|
||||
valid_codecs.join(", "),
|
||||
v
|
||||
),
|
||||
format!("must be one of: {}, got '{}'", valid_codecs.join(", "), v),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -395,11 +395,7 @@ pub async fn delete_images_bulk(
|
|||
let mut remaining_images = 0u32;
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&images_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if entry
|
||||
.path()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext == "jpg")
|
||||
{
|
||||
if entry.path().extension().is_some_and(|ext| ext == "jpg") {
|
||||
remaining_images += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -448,11 +444,7 @@ pub async fn compile_folder_video(
|
|||
let mut image_count = 0u32;
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&images_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if entry
|
||||
.path()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext == "jpg")
|
||||
{
|
||||
if entry.path().extension().is_some_and(|ext| ext == "jpg") {
|
||||
image_count += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -490,29 +482,34 @@ pub async fn compile_folder_video(
|
|||
|
||||
// Spawn the compilation job in the background
|
||||
tokio::spawn(async move {
|
||||
let result = crate::video::compile_timelapse(&images_dir, &output_path, &video_config, |current, total| {
|
||||
// Check for cancellation
|
||||
if cancel_token.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
let result = crate::video::compile_timelapse(
|
||||
&images_dir,
|
||||
&output_path,
|
||||
&video_config,
|
||||
|current, total| {
|
||||
// Check for cancellation
|
||||
if cancel_token.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update progress (fire and forget since we're in sync callback)
|
||||
let state_clone = job_state.clone();
|
||||
let folder_clone = folder_name_clone.clone();
|
||||
tokio::spawn(async move {
|
||||
state_clone
|
||||
.update_progress(Progress {
|
||||
status: JobStatus::CompilingVideo,
|
||||
completed: current,
|
||||
total,
|
||||
message: Some(format!("Compiling video for {}...", folder_clone)),
|
||||
skip_stats: SkipStats::default(),
|
||||
person_id: None,
|
||||
person_name: Some(folder_clone),
|
||||
})
|
||||
.await;
|
||||
});
|
||||
})
|
||||
// Update progress (fire and forget since we're in sync callback)
|
||||
let state_clone = job_state.clone();
|
||||
let folder_clone = folder_name_clone.clone();
|
||||
tokio::spawn(async move {
|
||||
state_clone
|
||||
.update_progress(Progress {
|
||||
status: JobStatus::CompilingVideo,
|
||||
completed: current,
|
||||
total,
|
||||
message: Some(format!("Compiling video for {}...", folder_clone)),
|
||||
skip_stats: SkipStats::default(),
|
||||
person_id: None,
|
||||
person_name: Some(folder_clone),
|
||||
})
|
||||
.await;
|
||||
});
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// Update final status
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@
|
|||
|
||||
use crate::job::{run_job, JobParams};
|
||||
use crate::web::state::{AppState, JobStatus, Progress, SkipStats};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
};
|
||||
use axum::{extract::State, http::StatusCode, response::Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,7 @@ use axum::{
|
|||
use futures_util::{stream::StreamExt, SinkExt};
|
||||
|
||||
/// WebSocket upgrade handler.
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||
ws.on_upgrade(|socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ impl JobStatus {
|
|||
/// Returns true if this is a terminal status (job has finished).
|
||||
/// Terminal statuses should not be overwritten by non-terminal statuses.
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(self, JobStatus::Completed | JobStatus::Cancelled | JobStatus::Error(_))
|
||||
matches!(
|
||||
self,
|
||||
JobStatus::Completed | JobStatus::Cancelled | JobStatus::Error(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -221,8 +224,7 @@ impl AppState {
|
|||
// Update progress to show cancelling state immediately.
|
||||
// Only change to Cancelling if in an active (non-terminal) state.
|
||||
let mut progress = self.progress.write().await;
|
||||
if progress.status == JobStatus::Running
|
||||
|| progress.status == JobStatus::CompilingVideo
|
||||
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo
|
||||
{
|
||||
progress.status = JobStatus::Cancelling;
|
||||
progress.message = Some("Cancelling...".to_string());
|
||||
|
|
@ -273,7 +275,10 @@ impl std::error::Error for JobRunningError {}
|
|||
/// Validate that a path component (folder name or filename) is safe.
|
||||
///
|
||||
/// Returns an error if the path contains traversal sequences or separators.
|
||||
pub fn validate_path_component(name: &str, component_type: &str) -> Result<(), PathValidationError> {
|
||||
pub fn validate_path_component(
|
||||
name: &str,
|
||||
component_type: &str,
|
||||
) -> Result<(), PathValidationError> {
|
||||
if name.contains("..") || name.contains('/') || name.contains('\\') {
|
||||
Err(PathValidationError {
|
||||
component_type: component_type.to_string(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue