Fix the bounding box coordinates system
This commit is contained in:
parent
211ebaf63f
commit
7351a4b032
6 changed files with 132 additions and 24 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
101
src/face_processing/orientation.rs
Normal file
101
src/face_processing/orientation.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue