Fix the bounding box coordinates system

This commit is contained in:
Arnaud_Cayrol 2026-01-27 17:05:46 +01:00
parent 211ebaf63f
commit 7351a4b032
6 changed files with 132 additions and 24 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<u32> 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<DynamicImage, image::ImageError> {
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
}
}

View file

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