LR-LIB-PEOPLE: face boxes and People close-ups follow Rotate Left/Right
Regions are stored on the upright (EXIF-oriented) photo, but the loupe's normalized frame and the close-up crop include the user's orientation. The loupe overlay now maps each box into that frame (and a resize drag back out of it), and face_job maps the face and swaps the photo's size for a quarter turn. Flips need nothing: they are applied after the normalized frame. Adds Orientation::inverse and Orientation::map_norm_rect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8c253b28ad
commit
4ded49ecd9
|
|
@ -711,7 +711,12 @@ impl crate::Session {
|
|||
/// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content.
|
||||
pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option<RenderJob> {
|
||||
let p = self.catalog.photo(id)?.clone();
|
||||
// `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the
|
||||
// user's Rotate Left/Right, so carry the box (and the photo's size) over to it.
|
||||
let orient = p.develop.orientation;
|
||||
let face = orient.map_norm_rect(face);
|
||||
let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1)));
|
||||
let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) };
|
||||
let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7;
|
||||
// finite, within the photo, and never wider than its short side (so the clamps below are valid)
|
||||
let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) };
|
||||
|
|
|
|||
|
|
@ -761,3 +761,25 @@ fn face_job_crops_a_square_inside_the_photo() {
|
|||
assert!((((r.x0 + r.x1) / 2.0) - 0.45).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.375).abs() < 1e-9, "{r:?}");
|
||||
assert!(s.face_job(lightcraft_catalog::PhotoId(u64::MAX), faces[0], 256).is_none());
|
||||
}
|
||||
|
||||
/// Regions are on the upright photo; after Rotate Right the close-up still frames the face: the box
|
||||
/// is carried into the rotated frame the crop lives in, and the crop stays square in rotated pixels.
|
||||
#[test]
|
||||
fn face_job_follows_rotate_right() {
|
||||
use lightcraft_geom::Rect;
|
||||
let mut s = demo();
|
||||
let id = s.active().unwrap();
|
||||
let (w, h) = {
|
||||
let p = s.catalog.photo(id).unwrap();
|
||||
(f64::from(p.width), f64::from(p.height))
|
||||
};
|
||||
// a small face left of centre on the upright photo
|
||||
let face = Rect { x0: 0.30, y0: 0.45, x1: 0.35, y1: 0.50 };
|
||||
s.execute("photo.rotateRight", &json!({})).unwrap();
|
||||
let r = s.face_job(id, face, 256).unwrap().settings.crop.geometry.rect;
|
||||
// a quarter turn clockwise puts the left of the photo at the top: centre (0.325, 0.475) → (0.525, 0.325)
|
||||
assert!((((r.x0 + r.x1) / 2.0) - 0.525).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.325).abs() < 1e-9, "{r:?}");
|
||||
// the rotated photo is h × w pixels
|
||||
let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w);
|
||||
assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,6 +332,23 @@ impl Orientation {
|
|||
// flipH ∘ rot(t) = rot(-t) ∘ flipH
|
||||
Orientation::from_parts(!f, (4 - t) % 4)
|
||||
}
|
||||
/// The orientation that undoes this one (`o.inverse().map(o.map(p)) == p`). Flips and half turns
|
||||
/// are their own inverses; quarter turns swap.
|
||||
pub fn inverse(self) -> Orientation {
|
||||
match self {
|
||||
Orientation::Rotate90 => Orientation::Rotate270,
|
||||
Orientation::Rotate270 => Orientation::Rotate90,
|
||||
o => o,
|
||||
}
|
||||
}
|
||||
/// Map a rectangle in normalized (0..1) coordinates of the stored image to normalized coordinates
|
||||
/// of the oriented one (e.g. a face region given on the upright photo → the frame after the user's
|
||||
/// Rotate Left/Right).
|
||||
pub fn map_norm_rect(self, r: Rect) -> Rect {
|
||||
let (ax, ay) = self.map(r.x0, r.y0, 1.0, 1.0);
|
||||
let (bx, by) = self.map(r.x1, r.y1, 1.0, 1.0);
|
||||
Rect::from_points(Point::new(ax, ay), Point::new(bx, by))
|
||||
}
|
||||
/// Map a point in stored pixel space (w×h stored dims) to oriented space.
|
||||
pub fn map(self, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) {
|
||||
let (flip, t) = self.to_parts();
|
||||
|
|
@ -417,6 +434,19 @@ mod tests {
|
|||
assert_eq!(Orientation::Rotate90.flipped_h().flipped_h(), Orientation::Rotate90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_inverse_and_norm_rect() {
|
||||
let r = Rect::new(0.1, 0.2, 0.3, 0.6);
|
||||
for v in 1..=8 {
|
||||
let o = Orientation::from_exif(v);
|
||||
let back = o.inverse().map_norm_rect(o.map_norm_rect(r));
|
||||
assert!([back.x0 - r.x0, back.y0 - r.y0, back.x1 - r.x1, back.y1 - r.y1].iter().all(|d| d.abs() < 1e-12), "{o:?}: {back:?}");
|
||||
}
|
||||
// a quarter turn clockwise: the left of the upright photo becomes its top
|
||||
let q = Orientation::Rotate90.map_norm_rect(r);
|
||||
assert!([q.x0 - 0.4, q.y0 - 0.1, q.x1 - 0.8, q.y1 - 0.3].iter().all(|d| d.abs() < 1e-12), "{q:?}");
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn affine_inverse_prop(a in -3.0f64..3.0, sx in 0.1f64..5.0, sy in 0.1f64..5.0, tx in -50.0f64..50.0, px in -20.0f64..20.0, py in -20.0f64..20.0) {
|
||||
|
|
|
|||
|
|
@ -338,7 +338,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
filter_pill(app, ui, canvas);
|
||||
}
|
||||
if app.ui.face_boxes {
|
||||
match region_overlay(ui, &p, &map, &photo) {
|
||||
match region_overlay(ui, &p, &map, &photo, d.orientation) {
|
||||
Some(RegionEdit::Remove(index)) => {
|
||||
let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index}));
|
||||
}
|
||||
|
|
@ -458,8 +458,15 @@ const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [
|
|||
/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows
|
||||
/// a × in its corner and eight resize handles. A drag previews the new box live and is reported once,
|
||||
/// on release (one undo step); the × reports the region to remove. Both are catalog-only edits:
|
||||
/// LightCraft doesn't write regions to XMP.
|
||||
fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) -> Option<RegionEdit> {
|
||||
/// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo;
|
||||
/// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes.
|
||||
fn region_overlay(
|
||||
ui: &egui::Ui,
|
||||
p: &egui::Painter,
|
||||
map: &CanvasMap,
|
||||
photo: &lightcraft_catalog::Photo,
|
||||
orient: lightcraft_geom::Orientation,
|
||||
) -> Option<RegionEdit> {
|
||||
let t = Tokens::get(p.ctx());
|
||||
let clip = p.clip_rect();
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
|
|
@ -469,7 +476,7 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig
|
|||
let mut edit = None;
|
||||
for (index, r) in photo.meta.regions.iter().enumerate() {
|
||||
let dragging = live.filter(|(i, _)| *i == index);
|
||||
let norm = dragging.map_or(r.rect, |(_, n)| n);
|
||||
let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n));
|
||||
let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1)));
|
||||
// white with a black keyline just outside it, so the box shows on any background
|
||||
p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside);
|
||||
|
|
@ -500,12 +507,14 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig
|
|||
n.max.y = pp.y.max(n.min.y + 12.0);
|
||||
}
|
||||
let (a, b) = (map.norm(n.min), map.norm(n.max));
|
||||
let new = lightcraft_geom::Rect {
|
||||
let shown = lightcraft_geom::Rect {
|
||||
x0: a.x.min(b.x).clamp(0.0, 1.0),
|
||||
y0: a.y.min(b.y).clamp(0.0, 1.0),
|
||||
x1: a.x.max(b.x).clamp(0.0, 1.0),
|
||||
y1: a.y.max(b.y).clamp(0.0, 1.0),
|
||||
};
|
||||
// back to the upright frame the region is stored in
|
||||
let new = orient.inverse().map_norm_rect(shown);
|
||||
ui.data_mut(|d| d.insert_temp(drag_key, (index, new)));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue