mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 11:09:23 -06:00
Dragging a crop handle past the image edge used to hand the raw rectangle to CropGeometry::constrained, which slid the whole frame toward the centre and moved the anchored corner. drag_crop owns the drag instead: the opposite edges stay put, a locked ratio holds on all eight handles (edges grow about their midline, sliding along a blocked side), the frame stops at the image edge and slides along it, and it keeps a minimum size. Hostile numbers return the start crop. Covered by Given/When/Then scenarios and property tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
469 lines
16 KiB
Rust
469 lines
16 KiB
Rust
//! Geometry for LightCraft: points, rectangles, affine and projective transforms, and the crop model.
|
||
//!
|
||
//! Conventions: image coordinates are y-down. *Normalized* coordinates map the full (uncropped,
|
||
//! oriented) image to `0..1` on both axes, so tools and settings are resolution independent.
|
||
#![forbid(unsafe_code)]
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
|
||
mod crop;
|
||
mod crop_drag;
|
||
mod homography;
|
||
mod real;
|
||
|
||
pub use crop::{CropGeometry, crop_fit_angle, max_inscribed_scale};
|
||
pub use crop_drag::{CropHandle, MAX_RATIO, MIN_CROP_FRACTION, drag_crop};
|
||
pub use homography::Homography;
|
||
pub use real::{Interval, Real};
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
pub struct Point {
|
||
pub x: f64,
|
||
pub y: f64,
|
||
}
|
||
|
||
impl Point {
|
||
pub const ZERO: Point = Point { x: 0.0, y: 0.0 };
|
||
pub const fn new(x: f64, y: f64) -> Self {
|
||
Self { x, y }
|
||
}
|
||
pub fn dist(self, o: Point) -> f64 {
|
||
(self - o).len()
|
||
}
|
||
pub fn lerp(self, o: Point, t: f64) -> Point {
|
||
Point::new(self.x + (o.x - self.x) * t, self.y + (o.y - self.y) * t)
|
||
}
|
||
pub fn to_vec(self) -> Vec2 {
|
||
Vec2::new(self.x, self.y)
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
pub struct Vec2 {
|
||
pub x: f64,
|
||
pub y: f64,
|
||
}
|
||
|
||
impl Vec2 {
|
||
pub const fn new(x: f64, y: f64) -> Self {
|
||
Self { x, y }
|
||
}
|
||
pub fn len(self) -> f64 {
|
||
self.x.hypot(self.y)
|
||
}
|
||
pub fn dot(self, o: Vec2) -> f64 {
|
||
self.x * o.x + self.y * o.y
|
||
}
|
||
pub fn cross(self, o: Vec2) -> f64 {
|
||
self.x * o.y - self.y * o.x
|
||
}
|
||
pub fn normalized(self) -> Vec2 {
|
||
let l = self.len();
|
||
if l > 0.0 { Vec2::new(self.x / l, self.y / l) } else { self }
|
||
}
|
||
pub fn angle(self) -> f64 {
|
||
self.y.atan2(self.x)
|
||
}
|
||
pub fn rotate(self, a: f64) -> Vec2 {
|
||
let (s, c) = a.sin_cos();
|
||
Vec2::new(self.x * c - self.y * s, self.x * s + self.y * c)
|
||
}
|
||
}
|
||
|
||
impl std::ops::Sub for Point {
|
||
type Output = Vec2;
|
||
fn sub(self, o: Point) -> Vec2 {
|
||
Vec2::new(self.x - o.x, self.y - o.y)
|
||
}
|
||
}
|
||
impl std::ops::Add<Vec2> for Point {
|
||
type Output = Point;
|
||
fn add(self, v: Vec2) -> Point {
|
||
Point::new(self.x + v.x, self.y + v.y)
|
||
}
|
||
}
|
||
impl std::ops::Sub<Vec2> for Point {
|
||
type Output = Point;
|
||
fn sub(self, v: Vec2) -> Point {
|
||
Point::new(self.x - v.x, self.y - v.y)
|
||
}
|
||
}
|
||
impl std::ops::Add for Vec2 {
|
||
type Output = Vec2;
|
||
fn add(self, o: Vec2) -> Vec2 {
|
||
Vec2::new(self.x + o.x, self.y + o.y)
|
||
}
|
||
}
|
||
impl std::ops::Sub for Vec2 {
|
||
type Output = Vec2;
|
||
fn sub(self, o: Vec2) -> Vec2 {
|
||
Vec2::new(self.x - o.x, self.y - o.y)
|
||
}
|
||
}
|
||
impl std::ops::Mul<f64> for Vec2 {
|
||
type Output = Vec2;
|
||
fn mul(self, s: f64) -> Vec2 {
|
||
Vec2::new(self.x * s, self.y * s)
|
||
}
|
||
}
|
||
impl std::ops::Neg for Vec2 {
|
||
type Output = Vec2;
|
||
fn neg(self) -> Vec2 {
|
||
Vec2::new(-self.x, -self.y)
|
||
}
|
||
}
|
||
|
||
/// Axis-aligned rectangle `[x0, x1) × [y0, y1)`.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
pub struct Rect {
|
||
pub x0: f64,
|
||
pub y0: f64,
|
||
pub x1: f64,
|
||
pub y1: f64,
|
||
}
|
||
|
||
impl Rect {
|
||
pub const UNIT: Rect = Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 };
|
||
pub const fn new(x0: f64, y0: f64, x1: f64, y1: f64) -> Self {
|
||
Self { x0, y0, x1, y1 }
|
||
}
|
||
pub fn from_xywh(x: f64, y: f64, w: f64, h: f64) -> Self {
|
||
Self::new(x, y, x + w, y + h)
|
||
}
|
||
pub fn from_center(c: Point, w: f64, h: f64) -> Self {
|
||
Self::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0)
|
||
}
|
||
pub fn from_points(a: Point, b: Point) -> Self {
|
||
Self::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y))
|
||
}
|
||
pub fn width(&self) -> f64 {
|
||
self.x1 - self.x0
|
||
}
|
||
pub fn height(&self) -> f64 {
|
||
self.y1 - self.y0
|
||
}
|
||
pub fn area(&self) -> f64 {
|
||
self.width().max(0.0) * self.height().max(0.0)
|
||
}
|
||
pub fn center(&self) -> Point {
|
||
Point::new((self.x0 + self.x1) / 2.0, (self.y0 + self.y1) / 2.0)
|
||
}
|
||
pub fn aspect(&self) -> f64 {
|
||
if self.height() != 0.0 { self.width() / self.height() } else { 0.0 }
|
||
}
|
||
pub fn contains(&self, p: Point) -> bool {
|
||
p.x >= self.x0 && p.x < self.x1 && p.y >= self.y0 && p.y < self.y1
|
||
}
|
||
pub fn is_empty(&self) -> bool {
|
||
self.x1 <= self.x0 || self.y1 <= self.y0
|
||
}
|
||
pub fn intersect(&self, o: &Rect) -> Rect {
|
||
Rect::new(self.x0.max(o.x0), self.y0.max(o.y0), self.x1.min(o.x1), self.y1.min(o.y1))
|
||
}
|
||
pub fn union(&self, o: &Rect) -> Rect {
|
||
Rect::new(self.x0.min(o.x0), self.y0.min(o.y0), self.x1.max(o.x1), self.y1.max(o.y1))
|
||
}
|
||
pub fn inflate(&self, dx: f64, dy: f64) -> Rect {
|
||
Rect::new(self.x0 - dx, self.y0 - dy, self.x1 + dx, self.y1 + dy)
|
||
}
|
||
pub fn scale(&self, sx: f64, sy: f64) -> Rect {
|
||
Rect::new(self.x0 * sx, self.y0 * sy, self.x1 * sx, self.y1 * sy)
|
||
}
|
||
pub fn translate(&self, v: Vec2) -> Rect {
|
||
Rect::new(self.x0 + v.x, self.y0 + v.y, self.x1 + v.x, self.y1 + v.y)
|
||
}
|
||
pub fn corners(&self) -> [Point; 4] {
|
||
[Point::new(self.x0, self.y0), Point::new(self.x1, self.y0), Point::new(self.x1, self.y1), Point::new(self.x0, self.y1)]
|
||
}
|
||
/// Largest rectangle of aspect `a` (w/h) centred in `self`.
|
||
pub fn fit_aspect(&self, a: f64) -> Rect {
|
||
if a <= 0.0 || self.is_empty() {
|
||
return *self;
|
||
}
|
||
let (w, h) = (self.width(), self.height());
|
||
let (nw, nh) = if w / h > a { (h * a, h) } else { (w, w / a) };
|
||
Rect::from_center(self.center(), nw, nh)
|
||
}
|
||
/// Round outward to integer pixel bounds.
|
||
pub fn round_out(&self) -> Rect {
|
||
Rect::new(self.x0.floor(), self.y0.floor(), self.x1.ceil(), self.y1.ceil())
|
||
}
|
||
}
|
||
|
||
/// 2-D affine transform `[a b c d e f]`: x' = a·x + c·y + e, y' = b·x + d·y + f.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct Affine(pub [f64; 6]);
|
||
|
||
impl Default for Affine {
|
||
fn default() -> Self {
|
||
Affine::IDENTITY
|
||
}
|
||
}
|
||
|
||
impl Affine {
|
||
pub const IDENTITY: Affine = Affine([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
|
||
pub fn translate(v: Vec2) -> Affine {
|
||
Affine([1.0, 0.0, 0.0, 1.0, v.x, v.y])
|
||
}
|
||
pub fn scale(sx: f64, sy: f64) -> Affine {
|
||
Affine([sx, 0.0, 0.0, sy, 0.0, 0.0])
|
||
}
|
||
pub fn rotate(a: f64) -> Affine {
|
||
let (s, c) = a.sin_cos();
|
||
Affine([c, s, -s, c, 0.0, 0.0])
|
||
}
|
||
pub fn rotate_about(a: f64, p: Point) -> Affine {
|
||
Affine::translate(p.to_vec()) * Affine::rotate(a) * Affine::translate(-p.to_vec())
|
||
}
|
||
pub fn apply(&self, p: Point) -> Point {
|
||
let (x, y) = self.apply_real(p.x, p.y);
|
||
Point::new(x, y)
|
||
}
|
||
/// [`Affine::apply`] for any [`Real`] (e.g. [`Interval`] bounds over many points).
|
||
pub fn apply_real<T: Real>(&self, x: T, y: T) -> (T, T) {
|
||
let [a, b, c, d, e, f] = self.0;
|
||
(x * a + y * c + e, x * b + y * d + f)
|
||
}
|
||
pub fn apply_vec(&self, v: Vec2) -> Vec2 {
|
||
let [a, b, c, d, ..] = self.0;
|
||
Vec2::new(a * v.x + c * v.y, b * v.x + d * v.y)
|
||
}
|
||
pub fn determinant(&self) -> f64 {
|
||
self.0[0] * self.0[3] - self.0[1] * self.0[2]
|
||
}
|
||
pub fn inverse(&self) -> Option<Affine> {
|
||
let det = self.determinant();
|
||
if det.abs() < 1e-300 {
|
||
return None;
|
||
}
|
||
let [a, b, c, d, e, f] = self.0;
|
||
let inv = 1.0 / det;
|
||
Some(Affine([d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv]))
|
||
}
|
||
/// Bounding box of a transformed rectangle.
|
||
pub fn transform_rect_bbox(&self, r: &Rect) -> Rect {
|
||
let pts = r.corners().map(|p| self.apply(p));
|
||
let mut out = Rect::new(pts[0].x, pts[0].y, pts[0].x, pts[0].y);
|
||
for p in &pts[1..] {
|
||
out = out.union(&Rect::new(p.x, p.y, p.x, p.y));
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
impl std::ops::Mul for Affine {
|
||
type Output = Affine;
|
||
/// `self * o` applies `o` first, then `self`.
|
||
fn mul(self, o: Affine) -> Affine {
|
||
let [a, b, c, d, e, f] = self.0;
|
||
let [a2, b2, c2, d2, e2, f2] = o.0;
|
||
Affine([a * a2 + c * b2, b * a2 + d * b2, a * c2 + c * d2, b * c2 + d * d2, a * e2 + c * f2 + e, b * e2 + d * f2 + f])
|
||
}
|
||
}
|
||
|
||
/// EXIF orientation (1..=8) as a transform of the stored pixel grid.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
pub enum Orientation {
|
||
#[default]
|
||
Normal,
|
||
FlipH,
|
||
Rotate180,
|
||
FlipV,
|
||
Transpose,
|
||
Rotate90,
|
||
Transverse,
|
||
Rotate270,
|
||
}
|
||
|
||
impl Orientation {
|
||
pub fn from_exif(v: u16) -> Orientation {
|
||
match v {
|
||
2 => Orientation::FlipH,
|
||
3 => Orientation::Rotate180,
|
||
4 => Orientation::FlipV,
|
||
5 => Orientation::Transpose,
|
||
6 => Orientation::Rotate90,
|
||
7 => Orientation::Transverse,
|
||
8 => Orientation::Rotate270,
|
||
_ => Orientation::Normal,
|
||
}
|
||
}
|
||
pub fn to_exif(self) -> u16 {
|
||
match self {
|
||
Orientation::Normal => 1,
|
||
Orientation::FlipH => 2,
|
||
Orientation::Rotate180 => 3,
|
||
Orientation::FlipV => 4,
|
||
Orientation::Transpose => 5,
|
||
Orientation::Rotate90 => 6,
|
||
Orientation::Transverse => 7,
|
||
Orientation::Rotate270 => 8,
|
||
}
|
||
}
|
||
/// Whether width and height swap.
|
||
pub fn swaps_axes(self) -> bool {
|
||
matches!(self, Orientation::Transpose | Orientation::Rotate90 | Orientation::Transverse | Orientation::Rotate270)
|
||
}
|
||
/// Decompose into (clockwise quarter turns applied after an optional horizontal flip).
|
||
pub fn to_parts(self) -> (bool, u8) {
|
||
match self {
|
||
Orientation::Normal => (false, 0),
|
||
Orientation::Rotate90 => (false, 1),
|
||
Orientation::Rotate180 => (false, 2),
|
||
Orientation::Rotate270 => (false, 3),
|
||
Orientation::FlipH => (true, 0),
|
||
Orientation::Transverse => (true, 1),
|
||
Orientation::FlipV => (true, 2),
|
||
Orientation::Transpose => (true, 3),
|
||
}
|
||
}
|
||
pub fn from_parts(flip: bool, quarter_turns_cw: u8) -> Orientation {
|
||
match (flip, quarter_turns_cw % 4) {
|
||
(false, 0) => Orientation::Normal,
|
||
(false, 1) => Orientation::Rotate90,
|
||
(false, 2) => Orientation::Rotate180,
|
||
(false, _) => Orientation::Rotate270,
|
||
(true, 0) => Orientation::FlipH,
|
||
(true, 1) => Orientation::Transverse,
|
||
(true, 2) => Orientation::FlipV,
|
||
(true, _) => Orientation::Transpose,
|
||
}
|
||
}
|
||
/// Rotate the (already oriented) image a further quarter turn clockwise (`cw`) or counter-clockwise.
|
||
pub fn rotated(self, cw: bool) -> Orientation {
|
||
let (f, t) = self.to_parts();
|
||
Orientation::from_parts(f, if cw { t + 1 } else { t + 3 })
|
||
}
|
||
/// Flip the displayed image horizontally.
|
||
pub fn flipped_h(self) -> Orientation {
|
||
let (f, t) = self.to_parts();
|
||
// 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();
|
||
let (mut x, y2, mut w2, mut h2) = (x, y, w, h);
|
||
if flip {
|
||
x = w2 - x;
|
||
}
|
||
let mut y = y2;
|
||
for _ in 0..t {
|
||
// rotate 90° cw: (x, y) -> (h - y, x)
|
||
let nx = h2 - y;
|
||
let ny = x;
|
||
x = nx;
|
||
y = ny;
|
||
std::mem::swap(&mut w2, &mut h2);
|
||
}
|
||
(x, y)
|
||
}
|
||
}
|
||
|
||
pub fn clamp01(v: f64) -> f64 {
|
||
v.clamp(0.0, 1.0)
|
||
}
|
||
|
||
pub fn deg(r: f64) -> f64 {
|
||
r.to_degrees()
|
||
}
|
||
|
||
pub fn rad(d: f64) -> f64 {
|
||
d.to_radians()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn close(a: Point, b: Point) -> bool {
|
||
a.dist(b) < 1e-9
|
||
}
|
||
|
||
#[test]
|
||
fn affine_compose_and_inverse() {
|
||
let t = Affine::translate(Vec2::new(3.0, -2.0)) * Affine::rotate(0.7) * Affine::scale(2.0, 0.5);
|
||
let inv = t.inverse().unwrap();
|
||
let p = Point::new(1.5, 4.0);
|
||
assert!(close(inv.apply(t.apply(p)), p));
|
||
assert!(close((t * inv).apply(p), p));
|
||
}
|
||
|
||
#[test]
|
||
fn rotate_about_fixes_centre() {
|
||
let c = Point::new(10.0, 5.0);
|
||
assert!(close(Affine::rotate_about(1.1, c).apply(c), c));
|
||
}
|
||
|
||
#[test]
|
||
fn rect_ops() {
|
||
let r = Rect::new(0.0, 0.0, 4.0, 2.0);
|
||
assert_eq!(r.aspect(), 2.0);
|
||
let f = r.fit_aspect(1.0);
|
||
assert_eq!((f.width(), f.height()), (2.0, 2.0));
|
||
assert_eq!(f.center(), r.center());
|
||
assert!(r.contains(Point::new(3.9, 1.9)));
|
||
assert!(!r.contains(Point::new(4.0, 1.0)));
|
||
assert!(r.intersect(&Rect::new(5.0, 5.0, 6.0, 6.0)).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn orientation_roundtrip_and_dims() {
|
||
for v in 1..=8 {
|
||
let o = Orientation::from_exif(v);
|
||
assert_eq!(o.to_exif(), v);
|
||
let (f, t) = o.to_parts();
|
||
assert_eq!(Orientation::from_parts(f, t), o);
|
||
}
|
||
assert!(Orientation::Rotate90.swaps_axes());
|
||
// Rotate90: top-left of stored goes to top-right of display.
|
||
let (x, y) = Orientation::Rotate90.map(0.0, 0.0, 4.0, 2.0);
|
||
assert_eq!((x, y), (2.0, 0.0));
|
||
assert_eq!(Orientation::Normal.rotated(true).rotated(true), Orientation::Rotate180);
|
||
assert_eq!(Orientation::Normal.rotated(false), Orientation::Rotate270);
|
||
assert_eq!(Orientation::Normal.flipped_h(), Orientation::FlipH);
|
||
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) {
|
||
let t = Affine::translate(Vec2::new(tx, -tx)) * Affine::rotate(a) * Affine::scale(sx, sy);
|
||
let p = Point::new(px, py);
|
||
let q = t.inverse().unwrap().apply(t.apply(p));
|
||
proptest::prop_assert!(q.dist(p) < 1e-7);
|
||
}
|
||
}
|
||
}
|