Use repeat padding in crop, rework debug vis for face orientation detection.

This commit is contained in:
Arnaud_Cayrol 2026-02-05 21:44:06 +01:00
parent 24f7b86ab1
commit 06098add58
2 changed files with 89 additions and 94 deletions

View file

@ -6,7 +6,7 @@ use crate::error::{Error, Result};
use crate::immich_api::FaceData; use crate::immich_api::FaceData;
use crate::pipeline::BoundingBox; use crate::pipeline::BoundingBox;
use image::imageops::FilterType; use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView}; use image::{DynamicImage, GenericImageView, RgbImage};
/// Result of cropping a face from an image. /// Result of cropping a face from an image.
pub struct CropResult { pub struct CropResult {
@ -60,34 +60,27 @@ pub fn crop_face_with_intermediate(
let center_x = (x1 + x2) / 2; let center_x = (x1 + x2) / 2;
let center_y = (y1 + y2) / 2; let center_y = (y1 + y2) / 2;
// Calculate crop bounds, clamped to image dimensions if crop_size < 10 {
let crop_x1 = center_x
.saturating_sub(crop_size / 2)
.min(img_width.saturating_sub(crop_size));
let crop_y1 = center_y
.saturating_sub(crop_size / 2)
.min(img_height.saturating_sub(crop_size));
// Ensure we don't exceed image bounds
let actual_crop_size = crop_size.min(img_width - crop_x1).min(img_height - crop_y1);
if actual_crop_size < 10 {
return Err(Error::ImageProcessing("Crop area too small".to_string())); return Err(Error::ImageProcessing("Crop area too small".to_string()));
} }
// Crop the face region (full resolution) // Ideal crop region centered on the face (may extend outside image bounds)
let cropped = img.crop_imm(crop_x1, crop_y1, actual_crop_size, actual_crop_size); let ideal_x1 = center_x as i32 - crop_size as i32 / 2;
let ideal_y1 = center_y as i32 - crop_size as i32 / 2;
// Crop with replicate-fill: always keeps the face centered by extending
// edge pixels at image borders instead of shifting the crop window.
let cropped = crop_with_replicate_fill(img, ideal_x1, ideal_y1, crop_size);
// Resize to output size // Resize to output size
let resized = cropped.resize_exact(output_size, output_size, FilterType::Lanczos3); let resized = cropped.resize_exact(output_size, output_size, FilterType::Lanczos3);
// Calculate the face bounding box in crop coordinates // Face coordinates relative to the crop origin (face is always centered)
// These are the original face coordinates relative to the crop origin
let face_rect = BoundingBox { let face_rect = BoundingBox {
x1: x1.saturating_sub(crop_x1) as f32, x1: (x1 as i32 - ideal_x1) as f32,
y1: y1.saturating_sub(crop_y1) as f32, y1: (y1 as i32 - ideal_y1) as f32,
x2: x2.saturating_sub(crop_x1).min(actual_crop_size) as f32, x2: (x2 as i32 - ideal_x1) as f32,
y2: y2.saturating_sub(crop_y1).min(actual_crop_size) as f32, y2: (y2 as i32 - ideal_y1) as f32,
}; };
Ok(CropResult { Ok(CropResult {
@ -96,3 +89,33 @@ pub fn crop_face_with_intermediate(
face_rect, face_rect,
}) })
} }
/// Crop a region from an image, filling out-of-bounds areas with replicated edge pixels.
///
/// 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 {
let rgb = img.to_rgb8();
let (w, h) = (rgb.width() as i32, rgb.height() as i32);
let out = RgbImage::from_fn(size, size, |px, py| {
let sx = replicate_coord(x_offset + px as i32, w);
let sy = replicate_coord(y_offset + py as i32, h);
*rgb.get_pixel(sx as u32, sy as u32)
});
DynamicImage::ImageRgb8(out)
}
/// Clamp a coordinate into the valid range [0, len) using replicate/edge boundary.
///
/// For a dimension of length `len`:
/// - Coordinates in `[0, len)` map to themselves.
/// - Negative coordinates clamp to 0.
/// - Coordinates >= len clamp to len-1.
fn replicate_coord(c: i32, len: i32) -> i32 {
if len <= 1 {
return 0;
}
c.clamp(0, len - 1)
}

View file

@ -69,87 +69,53 @@ impl Point3D {
} }
} }
/// Draw a 3D cube where the back face is the face bounding box /// Draw 3D pose axes (RGB = XYZ) from face center, rotated by head pose.
/// and the cube projects forward based on head pose /// This is the standard visualization for head pose estimation.
fn draw_3d_cube( fn draw_pose_axes(
img: &mut RgbImage, img: &mut RgbImage,
x1: i32, cx: f32,
y1: i32, cy: f32,
x2: i32, axis_length: f32,
y2: i32,
yaw: f32, yaw: f32,
pitch: f32, pitch: f32,
roll: f32, roll: f32,
) { ) {
// Calculate face center and size // Define axis endpoints (origin at 0,0,0)
let cx = ((x1 + x2) / 2) as f32; // X axis (red) - points right
let cy = ((y1 + y2) / 2) as f32; // Y axis (green) - points down (image coordinates)
let width = (x2 - x1) as f32; // Z axis (blue) - points out of screen (towards camera)
let height = (y2 - y1) as f32; let axes = [
let half_w = width / 2.0; (Point3D::new(axis_length, 0.0, 0.0), Rgb([255, 0, 0])), // X - red
let half_h = height / 2.0; (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)
// Depth of the cube (how far it projects forward)
let depth = width.max(height) * 0.8;
// Define 8 vertices of the cube
// Back face = face bounding box (at z=0)
// Front face = projected forward (at z=-depth, negative means towards camera)
let vertices = [
// Front face (closer to camera)
Point3D::new(-half_w, -half_h, -depth), // 0: top-left-front
Point3D::new(half_w, -half_h, -depth), // 1: top-right-front
Point3D::new(half_w, half_h, -depth), // 2: bottom-right-front
Point3D::new(-half_w, half_h, -depth), // 3: bottom-left-front
// Back face (face bounding box)
Point3D::new(-half_w, -half_h, 0.0), // 4: top-left-back
Point3D::new(half_w, -half_h, 0.0), // 5: top-right-back
Point3D::new(half_w, half_h, 0.0), // 6: bottom-right-back
Point3D::new(-half_w, half_h, 0.0), // 7: bottom-left-back
]; ];
// Apply rotations (order: yaw -> pitch -> roll) and project to 2D // Negate roll and pitch to convert from model convention to image coordinates
let focal_length = width.max(height) * 2.0; // (image Y-axis points down, model assumes Y-axis points up)
let rotated: Vec<(i32, i32)> = vertices let focal_length = axis_length * 2.0;
.iter() let origin = Point3D::new(0.0, 0.0, 0.0)
.map(|&v| { .rotate_y(yaw)
v.rotate_y(yaw) .rotate_x(-pitch)
.rotate_x(pitch) .rotate_z(-roll)
.rotate_z(roll) .project(cx, cy, focal_length);
.project(cx, cy, focal_length)
})
.collect();
// Define edges (pairs of vertex indices) for (endpoint, color) in axes {
let edges = [ let rotated = endpoint
// Front face (cyan - closer to camera) .rotate_y(yaw)
(0, 1), (1, 2), (2, 3), (3, 0), .rotate_x(-pitch)
// Back face (green - face bounding box) .rotate_z(-roll)
(4, 5), (5, 6), (6, 7), (7, 4), .project(cx, cy, focal_length);
// Connecting edges (yellow)
(0, 4), (1, 5), (2, 6), (3, 7),
];
// Draw front face in cyan (brighter) draw_line(img, origin.0, origin.1, rotated.0, rotated.1, color);
for &(i, j) in &edges[0..4] {
let (x0, y0) = rotated[i];
let (x1, y1) = rotated[j];
draw_line(img, x0, y0, x1, y1, Rgb([0, 255, 255]));
} }
}
// Draw back face in green (face bounding box) /// Draw an axis-aligned bounding box (no rotation applied).
for &(i, j) in &edges[4..8] { fn draw_axis_aligned_rect(img: &mut RgbImage, x1: i32, y1: i32, x2: i32, y2: i32, color: Rgb<u8>) {
let (x0, y0) = rotated[i]; draw_line(img, x1, y1, x2, y1, color); // top
let (x1, y1) = rotated[j]; draw_line(img, x2, y1, x2, y2, color); // right
draw_line(img, x0, y0, x1, y1, Rgb([0, 255, 0])); draw_line(img, x2, y2, x1, y2, color); // bottom
} draw_line(img, x1, y2, x1, y1, color); // left
// Draw connecting edges in yellow
for &(i, j) in &edges[8..12] {
let (x0, y0) = rotated[i];
let (x1, y1) = rotated[j];
draw_line(img, x0, y0, x1, y1, Rgb([255, 255, 0]));
}
} }
/// Draw a line using Bresenham's algorithm. /// Draw a line using Bresenham's algorithm.
@ -353,15 +319,21 @@ impl ProcessingStep for HeadPoseStep {
.get_computed(computed_keys::FACE_RECT) .get_computed(computed_keys::FACE_RECT)
.and_then(|v| v.as_face_rect()); .and_then(|v| v.as_face_rect());
// Draw 3D cube where the back face is the face bounding box // Draw axis-aligned face bounding box and 3D pose axes
if let Some(rect) = face_rect { if let Some(rect) = face_rect {
let x1 = rect.x1 as i32; let x1 = rect.x1 as i32;
let y1 = rect.y1 as i32; let y1 = rect.y1 as i32;
let x2 = rect.x2 as i32; let x2 = rect.x2 as i32;
let y2 = rect.y2 as i32; let y2 = rect.y2 as i32;
// Draw 3D cube with face box as back face // Draw axis-aligned bounding box in cyan
draw_3d_cube(&mut debug_img, x1, y1, x2, y2, pose.yaw, pose.pitch, pose.roll); draw_axis_aligned_rect(&mut debug_img, x1, y1, x2, y2, Rgb([0, 255, 255]));
// Draw 3D pose axes from face center
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 text background bar at bottom for pose values // Draw text background bar at bottom for pose values