From 7351a4b0321052cf802b22e57a3f529627dcc3b5 Mon Sep 17 00:00:00 2001 From: Arnaud_Cayrol Date: Tue, 27 Jan 2026 17:05:46 +0100 Subject: [PATCH] Fix the bounding box coordinates system --- Cargo.toml | 1 + src/face_processing/crop.rs | 15 +++-- src/face_processing/debug.rs | 16 +++-- src/face_processing/mod.rs | 2 + src/face_processing/orientation.rs | 101 +++++++++++++++++++++++++++++ src/job/mod.rs | 21 +++--- 6 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 src/face_processing/orientation.rs diff --git a/Cargo.toml b/Cargo.toml index bfb8f5d..130f332 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ toml = "0.8" # Image processing image = { version = "0.25", features = ["jpeg", "png", "webp"] } imageproc = "0.25" +kamadak-exif = "0.5" # Error handling thiserror = "1" diff --git a/src/face_processing/crop.rs b/src/face_processing/crop.rs index 836714d..9489fee 100644 --- a/src/face_processing/crop.rs +++ b/src/face_processing/crop.rs @@ -19,11 +19,16 @@ pub fn crop_face_with_intermediate( ) -> Result<(DynamicImage, DynamicImage)> { let (img_width, img_height) = img.dimensions(); - // Convert normalized bounding box to pixel coordinates - let x1 = (face_data.bounding_box_x1 * img_width as f32) as u32; - let y1 = (face_data.bounding_box_y1 * img_height as f32) as u32; - let x2 = (face_data.bounding_box_x2 * img_width as f32) as u32; - let y2 = (face_data.bounding_box_y2 * img_height as f32) as u32; + // Scale bounding box from metadata dimensions to actual image dimensions. + // Immich stores bounding box as pixel coordinates relative to image_width/image_height, + // but the loaded image may have different dimensions (e.g., if downloaded at different resolution). + let scale_x = img_width as f32 / face_data.image_width as f32; + let scale_y = img_height as f32 / face_data.image_height as f32; + + let x1 = (face_data.bounding_box_x1 * scale_x) as u32; + let y1 = (face_data.bounding_box_y1 * scale_y) as u32; + let x2 = (face_data.bounding_box_x2 * scale_x) as u32; + let y2 = (face_data.bounding_box_y2 * scale_y) as u32; let face_width = x2.saturating_sub(x1); let face_height = y2.saturating_sub(y1); diff --git a/src/face_processing/debug.rs b/src/face_processing/debug.rs index cb3210c..e58a718 100644 --- a/src/face_processing/debug.rs +++ b/src/face_processing/debug.rs @@ -16,16 +16,20 @@ pub 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; + // Scale bounding box from metadata dimensions to actual image dimensions. + // Immich stores bounding box as pixel coordinates relative to image_width/image_height. + let scale_x = img_width as f32 / face_data.image_width as f32; + let scale_y = img_height as f32 / face_data.image_height as f32; + + let bbox_x1 = (face_data.bounding_box_x1 * scale_x) as i32; + let bbox_y1 = (face_data.bounding_box_y1 * scale_y) as i32; + let bbox_x2 = (face_data.bounding_box_x2 * scale_x) as i32; + let bbox_y2 = (face_data.bounding_box_y2 * scale_y) 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 in job/mod.rs) + // 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; diff --git a/src/face_processing/mod.rs b/src/face_processing/mod.rs index b0668c1..5e2297b 100644 --- a/src/face_processing/mod.rs +++ b/src/face_processing/mod.rs @@ -5,9 +5,11 @@ mod crop; pub mod debug; +mod orientation; mod types; pub use crop::*; +pub use orientation::load_image_with_orientation; pub use types::*; // TODO: Implement these modules diff --git a/src/face_processing/orientation.rs b/src/face_processing/orientation.rs new file mode 100644 index 0000000..344e704 --- /dev/null +++ b/src/face_processing/orientation.rs @@ -0,0 +1,101 @@ +//! EXIF orientation handling. +//! +//! Reads EXIF orientation from image bytes and applies the correct transformation +//! to ensure images are displayed in their intended orientation. + +use exif::{In, Reader, Tag}; +use image::DynamicImage; +use std::io::Cursor; + +/// EXIF orientation values. +/// See: https://exiftool.org/TagNames/EXIF.html#Orientation +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Orientation { + /// Normal (no transformation needed) + Normal, + /// Flip horizontal + FlipHorizontal, + /// Rotate 180° + Rotate180, + /// Flip vertical + FlipVertical, + /// Rotate 90° CW then flip horizontal + Rotate90CwFlipH, + /// Rotate 90° clockwise (270° counter-clockwise) + Rotate90Cw, + /// Rotate 90° CCW then flip horizontal + Rotate90CcwFlipH, + /// Rotate 90° counter-clockwise (270° clockwise) + Rotate90Ccw, +} + +impl From for Orientation { + fn from(value: u32) -> Self { + match value { + 1 => Orientation::Normal, + 2 => Orientation::FlipHorizontal, + 3 => Orientation::Rotate180, + 4 => Orientation::FlipVertical, + 5 => Orientation::Rotate90CwFlipH, + 6 => Orientation::Rotate90Cw, + 7 => Orientation::Rotate90CcwFlipH, + 8 => Orientation::Rotate90Ccw, + _ => Orientation::Normal, + } + } +} + +/// Read EXIF orientation from image bytes. +/// Returns `Orientation::Normal` if no orientation tag is found or on error. +pub fn read_orientation(image_bytes: &[u8]) -> Orientation { + let mut cursor = Cursor::new(image_bytes); + + let exif = match Reader::new().read_from_container(&mut cursor) { + Ok(exif) => exif, + Err(_) => return Orientation::Normal, + }; + + match exif.get_field(Tag::Orientation, In::PRIMARY) { + Some(field) => match field.value.get_uint(0) { + Some(value) => Orientation::from(value), + None => Orientation::Normal, + }, + None => Orientation::Normal, + } +} + +/// Apply EXIF orientation correction to an image. +/// This transforms the image so it displays correctly regardless of how the camera saved it. +pub fn apply_orientation(img: DynamicImage, orientation: Orientation) -> DynamicImage { + match orientation { + Orientation::Normal => img, + Orientation::FlipHorizontal => img.fliph(), + Orientation::Rotate180 => img.rotate180(), + Orientation::FlipVertical => img.flipv(), + Orientation::Rotate90CwFlipH => img.rotate90().fliph(), + Orientation::Rotate90Cw => img.rotate90(), + Orientation::Rotate90CcwFlipH => img.rotate270().fliph(), + Orientation::Rotate90Ccw => img.rotate270(), + } +} + +/// Load image from bytes and apply EXIF orientation correction. +/// This is the main entry point for loading images that need correct orientation. +pub fn load_image_with_orientation(image_bytes: &[u8]) -> Result { + let orientation = read_orientation(image_bytes); + let img = image::load_from_memory(image_bytes)?; + Ok(apply_orientation(img, orientation)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_orientation_from_value() { + assert_eq!(Orientation::from(1), Orientation::Normal); + assert_eq!(Orientation::from(6), Orientation::Rotate90Cw); + assert_eq!(Orientation::from(8), Orientation::Rotate90Ccw); + assert_eq!(Orientation::from(99), Orientation::Normal); // Unknown defaults to Normal + } +} diff --git a/src/job/mod.rs b/src/job/mod.rs index 6248371..9cac463 100644 --- a/src/job/mod.rs +++ b/src/job/mod.rs @@ -10,7 +10,8 @@ use crate::config::Config; use crate::error::{Error, Result}; use crate::face_processing::crop_face_with_intermediate; use crate::face_processing::debug::draw_crop_debug; -use crate::face_processing::{AssetResult, BoundingBox, ProcessedFace}; +use crate::face_processing::load_image_with_orientation; +use crate::face_processing::{AssetResult, ProcessedFace}; use crate::immich_api::{Asset, FaceData, ImmichClient}; use crate::video::compile_timelapse; use crate::web::{AppState, JobStatus, Progress}; @@ -403,16 +404,9 @@ async fn process_single_asset( ) -> AssetResult { let asset_id = &asset.id; - // Check face resolution - let bbox = BoundingBox { - x1: face_data.bounding_box_x1, - y1: face_data.bounding_box_y1, - x2: face_data.bounding_box_x2, - y2: face_data.bounding_box_y2, - }; - - let face_width = bbox.width() * face_data.image_width as f32; - let face_height = bbox.height() * face_data.image_height as f32; + // Check face resolution (bounding box coordinates are already in pixels) + let face_width = face_data.bounding_box_x2 - face_data.bounding_box_x1; + let face_height = face_data.bounding_box_y2 - face_data.bounding_box_y1; let face_size = face_width.min(face_height) as u32; if face_size < config.processing.face_resolution_threshold { @@ -474,8 +468,9 @@ async fn process_single_asset( }; } - // Decode image - let img = match image::load_from_memory(&image_bytes) { + // Decode image and apply EXIF orientation correction. + // This ensures bounding box coordinates from Immich align with the image pixels. + let img = match load_image_with_orientation(&image_bytes) { Ok(img) => img, Err(e) => { return AssetResult::Error {