Working on the output folder structure + debug visualization

This commit is contained in:
Arnaud_Cayrol 2026-01-26 19:51:42 +01:00
parent 3fd9f747d7
commit 95a935ee1f
5 changed files with 356 additions and 58 deletions

View file

@ -37,6 +37,11 @@ right_eye_pos = [0.65, 0.4]
# Number of parallel workers (defaults to CPU count)
# max_workers = 4
# Save debug visualizations showing processing steps
# Saves to: output/{person}/debug/crop/ (image with bounding box and crop region overlayed)
# Future: debug/landmarks/, debug/alignment/
keep_intermediates = false
[video]
# Output video framerate
framerate = 15

View file

@ -18,6 +18,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
person_id: personId,
person_name: personName || null,
date_from: dateFrom || null,
date_to: dateTo || null,
}),

View file

@ -91,6 +91,9 @@ pub struct ProcessingConfig {
/// Number of parallel workers for processing.
pub max_workers: usize,
/// Keep intermediate images (original, cropped) for inspection.
pub keep_intermediates: bool,
}
impl Default for ProcessingConfig {
@ -103,6 +106,7 @@ impl Default for ProcessingConfig {
left_eye_pos: (0.35, 0.4),
right_eye_pos: (0.65, 0.4),
max_workers: num_cpus(),
keep_intermediates: false,
}
}
}

View file

@ -14,9 +14,11 @@ use crate::video::compile_timelapse;
use crate::web::{AppState, JobStatus, Progress};
use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView, ImageFormat};
use image::{DynamicImage, GenericImageView, ImageFormat, Rgb};
use imageproc::drawing::{draw_hollow_rect_mut, draw_line_segment_mut};
use imageproc::rect::Rect;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tokio::sync::Semaphore;
@ -26,10 +28,60 @@ use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone)]
pub struct JobParams {
pub person_id: String,
pub person_name: Option<String>,
pub date_from: Option<String>,
pub date_to: Option<String>,
}
/// Output directories for a processing job.
#[derive(Debug, Clone)]
struct OutputDirs {
/// Directory for final processed images (used for video)
images: PathBuf,
/// Path for output video
video: PathBuf,
/// Optional debug directories for visualizing processing stages.
debug: Option<DebugDirs>,
}
/// Debug output directories for visualizing each processing stage.
/// Each folder contains images with debug overlays showing what happened at that step.
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
struct DebugDirs {
/// Original image with bounding box and crop region overlayed
crop: Option<PathBuf>,
/// Face with landmark points drawn (future)
landmarks: Option<PathBuf>,
/// Before/after alignment visualization (future)
alignment: Option<PathBuf>,
}
/// Create a safe folder name from person name or ID.
fn sanitize_folder_name(name: Option<&str>, id: &str) -> String {
let base = name.filter(|n| !n.is_empty()).unwrap_or(id);
// Replace unsafe characters with underscores
let sanitized: String = base
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' {
c
} else {
'_'
}
})
.collect();
// Trim whitespace and limit length
let trimmed = sanitized.trim();
if trimmed.len() > 50 {
trimmed[..50].to_string()
} else {
trimmed.to_string()
}
}
/// Run the complete processing pipeline.
///
/// This is the main entry point for background job processing.
@ -83,10 +135,42 @@ async fn run_job_inner(
) -> Result<PathBuf> {
let config = state.config.read().await.clone();
// Create person-specific output directory
let folder_name = sanitize_folder_name(params.person_name.as_deref(), &params.person_id);
let person_dir = config.output_dir.join(&folder_name);
// Create output directories
let images_dir = config.output_dir.join("images");
let images_dir = person_dir.join("images");
tokio::fs::create_dir_all(&images_dir).await?;
// Create debug directories if enabled
let debug = if config.processing.keep_intermediates {
let debug_base = person_dir.join("debug");
let crop_dir = debug_base.join("crop");
tokio::fs::create_dir_all(&crop_dir).await?;
// Future directories (created on-demand when those features are implemented):
// - debug_base.join("landmarks") - face with landmark points
// - debug_base.join("alignment") - before/after alignment
Some(DebugDirs {
crop: Some(crop_dir),
landmarks: None,
alignment: None,
})
} else {
None
};
let output_dirs = OutputDirs {
images: images_dir.clone(),
video: person_dir.join("timelapse.mp4"),
debug,
};
tracing::info!("Output directory: {}", person_dir.display());
// Create Immich client
let client = ImmichClient::new(&config.api)?;
@ -155,7 +239,7 @@ async fn run_job_inner(
let semaphore = Arc::new(Semaphore::new(config.processing.max_workers));
let client = Arc::new(client);
let config = Arc::new(config);
let images_dir = Arc::new(images_dir);
let output_dirs = Arc::new(output_dirs);
let mut handles = Vec::with_capacity(assets_with_faces.len());
@ -168,7 +252,7 @@ async fn run_job_inner(
let permit = semaphore.clone().acquire_owned().await.unwrap();
let client = client.clone();
let config = config.clone();
let images_dir = images_dir.clone();
let output_dirs = output_dirs.clone();
let completed = completed.clone();
let state = state.clone();
let task_cancel_token = cancel_token.clone();
@ -188,7 +272,7 @@ async fn run_job_inner(
&config,
&asset,
&face_data,
&images_dir,
&output_dirs,
&task_cancel_token,
)
.await;
@ -272,12 +356,11 @@ async fn run_job_inner(
})
.await;
let output_video = config.output_dir.join("timelapse.mp4");
let state_clone = state.clone();
compile_timelapse(
&images_dir,
&output_video,
&output_dirs.images,
&output_dirs.video,
&config.video,
move |frame, total| {
// Note: This callback is sync, so we can't easily update state here.
@ -288,9 +371,9 @@ async fn run_job_inner(
)
.await?;
Ok(output_video)
Ok(output_dirs.video.clone())
} else {
Ok(images_dir.to_path_buf())
Ok(output_dirs.images.clone())
}
}
@ -316,7 +399,7 @@ async fn process_single_asset(
config: &Config,
asset: &Asset,
face_data: &FaceData,
output_dir: &Path,
output_dirs: &OutputDirs,
cancel_token: &CancellationToken,
) -> AssetResult {
let asset_id = &asset.id;
@ -362,44 +445,6 @@ async fn process_single_asset(
}
};
// Check after download, before CPU-intensive processing
if cancel_token.is_cancelled() {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
reason: "Cancelled".to_string(),
};
}
// Decode image
let img = match image::load_from_memory(&image_bytes) {
Ok(img) => img,
Err(e) => {
return AssetResult::Error {
asset_id: asset_id.clone(),
error: format!("Failed to decode image: {}", e),
};
}
};
// Crop and resize face
let cropped = match crop_face(&img, face_data, config.processing.resize_size) {
Ok(cropped) => cropped,
Err(e) => {
return AssetResult::Error {
asset_id: asset_id.clone(),
error: format!("Failed to crop face: {}", e),
};
}
};
// Check before file I/O
if cancel_token.is_cancelled() {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
reason: "Cancelled".to_string(),
};
}
// Generate timestamp-based filename for sorting
let timestamp = asset
.file_created_at
@ -420,11 +465,66 @@ async fn process_single_asset(
})
.collect();
let output_path = output_dir.join(format!("{}_{}.jpg", safe_timestamp, asset_id));
let filename = format!("{}_{}.jpg", safe_timestamp, asset_id);
// Encode and save
// Check after download, before CPU-intensive processing
if cancel_token.is_cancelled() {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
reason: "Cancelled".to_string(),
};
}
// Decode image
let img = match image::load_from_memory(&image_bytes) {
Ok(img) => img,
Err(e) => {
return AssetResult::Error {
asset_id: asset_id.clone(),
error: format!("Failed to decode image: {}", e),
};
}
};
// Crop face
let (_cropped_full, final_image) =
match crop_face_with_intermediate(&img, face_data, config.processing.resize_size) {
Ok(result) => result,
Err(e) => {
return AssetResult::Error {
asset_id: asset_id.clone(),
error: format!("Failed to crop face: {}", e),
};
}
};
// Save debug visualization if enabled
if let Some(ref debug) = output_dirs.debug {
if let Some(ref crop_dir) = debug.crop {
let debug_img = draw_crop_debug(&img, face_data);
let debug_path = crop_dir.join(&filename);
let mut buffer = Cursor::new(Vec::new());
if let Err(e) = debug_img.write_to(&mut buffer, ImageFormat::Jpeg) {
tracing::warn!("Failed to encode debug image: {}", e);
} else if let Err(e) = tokio::fs::write(&debug_path, buffer.into_inner()).await {
tracing::warn!("Failed to save debug image: {}", e);
}
}
}
// Check before file I/O
if cancel_token.is_cancelled() {
return AssetResult::Skipped {
asset_id: asset_id.clone(),
reason: "Cancelled".to_string(),
};
}
let output_path = output_dirs.images.join(&filename);
// Encode and save final image
let mut buffer = Cursor::new(Vec::new());
if let Err(e) = cropped.write_to(&mut buffer, ImageFormat::Jpeg) {
if let Err(e) = final_image.write_to(&mut buffer, ImageFormat::Jpeg) {
return AssetResult::Error {
asset_id: asset_id.clone(),
error: format!("Failed to encode image: {}", e),
@ -446,10 +546,15 @@ async fn process_single_asset(
}
/// Crop and resize the face from an image using bounding box.
/// Returns (cropped_full_res, resized_final) for intermediate saving.
///
/// This is a simplified version that just uses the bounding box.
/// A full implementation would use facial landmarks for alignment.
fn crop_face(img: &DynamicImage, face_data: &FaceData, output_size: u32) -> Result<DynamicImage> {
fn crop_face_with_intermediate(
img: &DynamicImage,
face_data: &FaceData,
output_size: u32,
) -> Result<(DynamicImage, DynamicImage)> {
let (img_width, img_height) = img.dimensions();
// Convert normalized bounding box to pixel coordinates
@ -492,13 +597,79 @@ fn crop_face(img: &DynamicImage, face_data: &FaceData, output_size: u32) -> Resu
return Err(Error::ImageProcessing("Crop area too small".to_string()));
}
// Crop the face region
// Crop the face region (full resolution)
let cropped = img.crop_imm(crop_x1, crop_y1, actual_crop_size, actual_crop_size);
// Resize to output size
let resized = cropped.resize_exact(output_size, output_size, FilterType::Lanczos3);
Ok(resized)
Ok((cropped, resized))
}
/// Draw debug visualization showing bounding box and crop region.
/// - Red rectangle: face bounding box from Immich
/// - Green rectangle: expanded crop region used for processing
fn draw_crop_debug(img: &DynamicImage, face_data: &FaceData) -> DynamicImage {
let (img_width, img_height) = img.dimensions();
let mut rgb_img = img.to_rgb8();
// Face bounding box in pixel coordinates
let bbox_x1 = (face_data.bounding_box_x1 * img_width as f32) as i32;
let bbox_y1 = (face_data.bounding_box_y1 * img_height as f32) as i32;
let bbox_x2 = (face_data.bounding_box_x2 * img_width as f32) as i32;
let bbox_y2 = (face_data.bounding_box_y2 * img_height as f32) as i32;
let face_width = (bbox_x2 - bbox_x1) as u32;
let face_height = (bbox_y2 - bbox_y1) as u32;
// Calculate crop region (same logic as crop_face_with_intermediate)
let face_size = face_width.max(face_height);
let padding = face_size / 2;
let crop_size = face_size + padding * 2;
let center_x = (bbox_x1 + bbox_x2) / 2;
let center_y = (bbox_y1 + bbox_y2) / 2;
let crop_x1 = (center_x - crop_size as i32 / 2).max(0) as u32;
let crop_y1 = (center_y - crop_size as i32 / 2).max(0) as u32;
let crop_x1 = crop_x1.min(img_width.saturating_sub(crop_size));
let crop_y1 = crop_y1.min(img_height.saturating_sub(crop_size));
let actual_crop_size = crop_size.min(img_width - crop_x1).min(img_height - crop_y1);
// Colors
let red = Rgb([255u8, 0, 0]);
let green = Rgb([0u8, 255, 0]);
// Draw face bounding box (red) - draw multiple times for thickness
for offset in 0..3i32 {
let rect = Rect::at(bbox_x1 - offset, bbox_y1 - offset)
.of_size(face_width + offset as u32 * 2, face_height + offset as u32 * 2);
draw_hollow_rect_mut(&mut rgb_img, rect, red);
}
// Draw crop region (green) - draw multiple times for thickness
for offset in 0..3i32 {
let rect = Rect::at(crop_x1 as i32 - offset, crop_y1 as i32 - offset)
.of_size(actual_crop_size + offset as u32 * 2, actual_crop_size + offset as u32 * 2);
draw_hollow_rect_mut(&mut rgb_img, rect, green);
}
// Draw crosshair at face center
let cross_size = 20i32;
draw_line_segment_mut(
&mut rgb_img,
((center_x - cross_size) as f32, center_y as f32),
((center_x + cross_size) as f32, center_y as f32),
red,
);
draw_line_segment_mut(
&mut rgb_img,
(center_x as f32, (center_y - cross_size) as f32),
(center_x as f32, (center_y + cross_size) as f32),
red,
);
DynamicImage::ImageRgb8(rgb_img)
}
#[cfg(test)]

View file

@ -8,7 +8,7 @@ use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::{Html, IntoResponse, Json, Response},
routing::{get, post},
routing::{delete, get, post},
Router,
};
use serde::{Deserialize, Serialize};
@ -32,6 +32,8 @@ pub fn create_router(state: AppState) -> Router {
.route("/api/progress", get(get_progress))
.route("/api/start", post(start_processing))
.route("/api/cancel", post(cancel_processing))
.route("/api/output", delete(cleanup_all_output))
.route("/api/output/{folder_name}", delete(cleanup_output_folder))
// Serve output files (video, images)
.nest_service("/output", ServeDir::new(output_dir))
// Serve frontend static files (fallback to index.html for SPA routing)
@ -207,9 +209,9 @@ async fn get_progress(State(state): State<AppState>) -> Json<ProgressResponse> {
#[derive(Deserialize)]
struct StartRequest {
person_id: String,
person_name: Option<String>,
date_from: Option<String>,
date_to: Option<String>,
// Processing options can be added here
}
#[derive(Serialize)]
@ -256,6 +258,7 @@ async fn start_processing(
// Spawn the processing job in the background
let job_params = JobParams {
person_id: request.person_id,
person_name: request.person_name,
date_from: request.date_from,
date_to: request.date_to,
};
@ -288,3 +291,117 @@ async fn cancel_processing(State(state): State<AppState>) -> Json<StartResponse>
})
}
}
/// Clean up all output folders.
async fn cleanup_all_output(
State(state): State<AppState>,
) -> Result<Json<StartResponse>, (StatusCode, String)> {
let config = state.config.read().await;
let output_dir = &config.output_dir;
// Check if a job is running
{
let progress = state.progress.read().await;
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
return Err((
StatusCode::CONFLICT,
"Cannot cleanup while a job is running".to_string(),
));
}
}
// Remove all contents of output directory
if output_dir.exists() {
let mut entries = tokio::fs::read_dir(output_dir).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read output directory: {}", e),
)
})?;
let mut deleted_count = 0;
while let Some(entry) = entries.next_entry().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read directory entry: {}", e),
)
})? {
let path = entry.path();
if path.is_dir() {
tokio::fs::remove_dir_all(&path).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to remove directory {}: {}", path.display(), e),
)
})?;
deleted_count += 1;
}
}
tracing::info!("Cleaned up {} output folders", deleted_count);
Ok(Json(StartResponse {
success: true,
message: format!("Deleted {} output folders", deleted_count),
}))
} else {
Ok(Json(StartResponse {
success: true,
message: "Output directory does not exist".to_string(),
}))
}
}
/// Clean up a specific output folder by name.
async fn cleanup_output_folder(
State(state): State<AppState>,
Path(folder_name): Path<String>,
) -> Result<Json<StartResponse>, (StatusCode, String)> {
let config = state.config.read().await;
// Check if a job is running
{
let progress = state.progress.read().await;
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
return Err((
StatusCode::CONFLICT,
"Cannot cleanup while a job is running".to_string(),
));
}
}
// Sanitize folder name to prevent path traversal
if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') {
return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string()));
}
let folder_path = config.output_dir.join(&folder_name);
if !folder_path.exists() {
return Err((
StatusCode::NOT_FOUND,
format!("Folder '{}' not found", folder_name),
));
}
if !folder_path.is_dir() {
return Err((
StatusCode::BAD_REQUEST,
format!("'{}' is not a directory", folder_name),
));
}
tokio::fs::remove_dir_all(&folder_path).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to remove folder: {}", e),
)
})?;
tracing::info!("Cleaned up output folder: {}", folder_name);
Ok(Json(StartResponse {
success: true,
message: format!("Deleted folder '{}'", folder_name),
}))
}