Files
lightcraft/crates/geom/src/crop.rs
T

144 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The crop model.
//!
//! The image (w × h pixels after orientation) is rotated by `angle` about its centre. The crop is an
//! axis-aligned rectangle in that rotated frame, stored *normalized* to the image size
//! (`x / w`, `y / h`), so it is independent of preview resolution. The crop must stay inside the
//! rotated image (Lightroom's "Constrain to image").
use serde::{Deserialize, Serialize};
use crate::{Affine, Point, Rect, Vec2};
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct CropGeometry {
/// Normalized crop rectangle in the rotated frame (0..1 when angle = 0).
pub rect: Rect,
/// Straighten angle in degrees, clockwise positive, −45..45.
pub angle: f64,
}
impl Default for CropGeometry {
fn default() -> Self {
Self { rect: Rect::UNIT, angle: 0.0 }
}
}
impl CropGeometry {
pub fn is_identity(&self) -> bool {
self.angle == 0.0 && self.rect == Rect::UNIT
}
/// Crop rectangle in pixels of the rotated frame for an image of `w × h`.
pub fn rect_px(&self, w: f64, h: f64) -> Rect {
self.rect.scale(w, h)
}
/// Transform from *output* (cropped) pixel coordinates to *source* (oriented image) pixel
/// coordinates, for an output of `out_w × out_h` pixels rendered from a `w × h` source.
pub fn output_to_source(&self, w: f64, h: f64, out_w: f64, out_h: f64) -> Affine {
let r = self.rect_px(w, h);
// output -> crop rect in rotated frame
let to_rot = Affine::translate(Vec2::new(r.x0, r.y0)) * Affine::scale(r.width() / out_w, r.height() / out_h);
// rotated frame -> source: inverse rotation about the image centre
let c = Point::new(w / 2.0, h / 2.0);
Affine::rotate_about(-self.angle.to_radians(), c) * to_rot
}
/// Whether every crop corner lies within the rotated image.
pub fn is_within_image(&self, w: f64, h: f64) -> bool {
let r = self.rect_px(w, h);
let back = Affine::rotate_about(-self.angle.to_radians(), Point::new(w / 2.0, h / 2.0));
r.corners().iter().all(|p| {
let q = back.apply(*p);
q.x >= -1e-6 * w && q.y >= -1e-6 * h && q.x <= w * (1.0 + 1e-6) && q.y <= h * (1.0 + 1e-6)
})
}
/// Shrink the crop (about its centre, keeping aspect) and then shift it until it fits inside the
/// rotated image.
pub fn constrained(&self, w: f64, h: f64) -> CropGeometry {
if self.is_within_image(w, h) {
return *self;
}
let r = self.rect_px(w, h);
// Try moving towards the image centre first.
let c = Point::new(w / 2.0, h / 2.0);
let mut best = *self;
for i in 1..=32 {
let t = i as f64 / 32.0;
let nc = r.center().lerp(c, t);
let cand = CropGeometry { rect: Rect::from_center(nc, r.width(), r.height()).scale(1.0 / w, 1.0 / h), angle: self.angle };
if cand.is_within_image(w, h) {
return cand;
}
best = cand;
}
// Then shrink around the centre.
let s = max_inscribed_scale(w, h, self.angle, r.width(), r.height());
let nr = Rect::from_center(c, r.width() * s, r.height() * s);
let _ = best;
CropGeometry { rect: nr.scale(1.0 / w, 1.0 / h), angle: self.angle }
}
}
/// Largest scale `s` such that a centred `cw·s × ch·s` rectangle fits inside a `w × h` image rotated
/// by `angle_deg` about its centre.
pub fn max_inscribed_scale(w: f64, h: f64, angle_deg: f64, cw: f64, ch: f64) -> f64 {
let a = angle_deg.to_radians().abs();
let (s, c) = a.sin_cos();
// Half extents of the crop must satisfy, in the image frame (rotated back):
// |x| c + |y| s <= w/2 and |x| s + |y| c <= h/2 for the crop corner (x, y) = (cw/2, ch/2)·scale.
let (hx, hy) = (cw / 2.0, ch / 2.0);
let s1 = (w / 2.0) / (hx * c + hy * s);
let s2 = (h / 2.0) / (hx * s + hy * c);
s1.min(s2)
}
/// The biggest crop of the image's own aspect (or `aspect` if given) that fits at `angle_deg`.
pub fn crop_fit_angle(w: f64, h: f64, angle_deg: f64, aspect: Option<f64>) -> CropGeometry {
let a = aspect.unwrap_or(w / h);
let base = Rect::new(0.0, 0.0, w, h).fit_aspect(a);
let s = max_inscribed_scale(w, h, angle_deg, base.width(), base.height()).min(1.0);
let r = Rect::from_center(Point::new(w / 2.0, h / 2.0), base.width() * s, base.height() * s);
CropGeometry { rect: r.scale(1.0 / w, 1.0 / h), angle: angle_deg }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_maps_pixels() {
let c = CropGeometry::default();
let t = c.output_to_source(400.0, 300.0, 400.0, 300.0);
let p = t.apply(Point::new(10.0, 20.0));
assert!((p.x - 10.0).abs() < 1e-9 && (p.y - 20.0).abs() < 1e-9);
}
#[test]
fn rotated_fit_is_inside() {
for ang in [-45.0, -10.0, 0.0, 3.5, 30.0, 45.0] {
let c = crop_fit_angle(600.0, 400.0, ang, None);
assert!(c.is_within_image(600.0, 400.0), "{ang}");
// and maximal: 1% bigger does not fit (except angle 0 where it equals the image)
let big = CropGeometry { rect: Rect::from_center(c.rect.center(), c.rect.width() * 1.01, c.rect.height() * 1.01), angle: ang };
assert!(!big.is_within_image(600.0, 400.0), "{ang}");
}
}
#[test]
fn constrain_pulls_crop_inside() {
let c = CropGeometry { rect: Rect::new(0.5, 0.5, 1.4, 1.2), angle: 12.0 };
let k = c.constrained(300.0, 200.0);
assert!(k.is_within_image(300.0, 200.0));
}
#[test]
fn half_crop_maps_to_centre_quadrant() {
let c = CropGeometry { rect: Rect::new(0.25, 0.25, 0.75, 0.75), angle: 0.0 };
let t = c.output_to_source(100.0, 100.0, 50.0, 50.0);
let p = t.apply(Point::new(0.0, 0.0));
assert!((p.x - 25.0).abs() < 1e-9 && (p.y - 25.0).abs() < 1e-9);
}
}