Files
lightcraft/crates/pipeline/src/transform.rs
T
Brandon ThomasandClaude Opus 5.5 733e59f84e M4.3: geometry — manual transforms as one homography in the geometry frame, Constrain Crop
- pipeline::transform::manual: Vertical/Horizontal keystone as a virtual camera rotation K·R·K⁻¹ (re-centred,
  local scale preserved), Rotate, Aspect, Scale, Offset X/Y composed into one homography
- the homography lives in the Warp: output → crop affine → inverse perspective → lens model → source, still a
  single resample; masks/crop/spots stay in transformed normalized coordinates
- Constrain Crop: the effective crop shrinks about its centre until no blank (outside-source) area remains;
  aspect unchanged
- UI: Constrain Crop checkbox in the crop panel's Geometry section
- tests: slider signs, known keystones straightened (3 slider combos), constrain removes blank corners,
  preview vs export match

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 01:07:19 -04:00

90 lines
3.7 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.
//! Perspective transforms: the manual Transform sliders (vertical/horizontal keystone, rotate, aspect, scale,
//! offset) as one homography, composed with the Upright correction.
//!
//! All homographies here map *lens-corrected* centred coordinates to *transformed* centred coordinates, where
//! centred coordinates are `(p − image centre) / (long edge / 2)`. Keystone corrections are modelled as a virtual
//! camera rotation `K·R·K⁻¹` (focal length [`DEFAULT_FOCAL`] in those units), re-centred so the image centre stays
//! put and its local scale is preserved.
use lightcraft_develop::Geometry;
use lightcraft_geom::Homography;
/// Assumed focal length in half-long-edge units (≈ 27 mm on a 36 mm-wide frame).
pub const DEFAULT_FOCAL: f64 = 1.5;
/// Vertical/Horizontal ±100 → virtual camera rotation (radians).
const KEYSTONE_MAX: f64 = 0.5;
pub type Mat3 = [[f64; 3]; 3];
pub fn mat_mul(a: &Mat3, b: &Mat3) -> Mat3 {
let mut r = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
r[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
r
}
pub fn to_h(m: &Mat3) -> Homography {
Homography([m[0][0], m[0][1], m[0][2], m[1][0], m[1][1], m[1][2], m[2][0], m[2][1], m[2][2]])
}
/// `K·R·K⁻¹` for a rotation `r` (rows) and focal length `f`.
pub fn rotation_homography(r: &Mat3, f: f64) -> Homography {
let k = [[f, 0.0, 0.0], [0.0, f, 0.0], [0.0, 0.0, 1.0]];
let ki = [[1.0 / f, 0.0, 0.0], [0.0, 1.0 / f, 0.0], [0.0, 0.0, 1.0]];
to_h(&mat_mul(&k, &mat_mul(r, &ki)))
}
pub fn rot_x(a: f64) -> Mat3 {
let (s, c) = a.sin_cos();
[[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]
}
pub fn rot_y(a: f64) -> Mat3 {
let (s, c) = a.sin_cos();
[[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]]
}
/// Translate so the origin maps to itself and scale so the local area there is unchanged.
pub fn recentre(h: &Homography) -> Homography {
let o = h.apply(lightcraft_geom::Point::new(0.0, 0.0));
let t = Homography([1.0, 0.0, -o.x, 0.0, 1.0, -o.y, 0.0, 0.0, 1.0]).mul(h);
// Jacobian at the origin (numerically)
let e = 1e-4;
let px = t.apply(lightcraft_geom::Point::new(e, 0.0));
let py = t.apply(lightcraft_geom::Point::new(0.0, e));
let det = ((px.x * py.y - px.y * py.x) / (e * e)).abs();
let s = if det > 1e-12 { 1.0 / det.sqrt() } else { 1.0 };
Homography([s, 0.0, 0.0, 0.0, s, 0.0, 0.0, 0.0, 1.0]).mul(&t)
}
/// The manual Transform sliders as a homography (lens-corrected → transformed, centred coordinates).
/// Signs (like Lightroom): Vertical − widens the top (fixes converging verticals shot from below), Horizontal −
/// enlarges the left side, Rotate + turns the image clockwise, Aspect + stretches vertically, Scale > 100 zooms in,
/// Offset X + moves the image right, Offset Y + moves it up.
pub fn manual(g: &Geometry) -> Homography {
let mut h = Homography::IDENTITY;
if g.vertical != 0.0 || g.horizontal != 0.0 {
let r = mat_mul(&rot_y(g.horizontal / 100.0 * KEYSTONE_MAX), &rot_x(-g.vertical / 100.0 * KEYSTONE_MAX));
h = recentre(&rotation_homography(&r, DEFAULT_FOCAL));
}
if g.rotate != 0.0 {
let (s, c) = g.rotate.to_radians().sin_cos();
h = Homography([c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0]).mul(&h);
}
if g.aspect != 0.0 {
let k = (g.aspect / 200.0).exp2();
h = Homography([1.0 / k, 0.0, 0.0, 0.0, k, 0.0, 0.0, 0.0, 1.0]).mul(&h);
}
let s = (g.scale / 100.0).clamp(0.1, 10.0);
if s != 1.0 {
h = Homography([s, 0.0, 0.0, 0.0, s, 0.0, 0.0, 0.0, 1.0]).mul(&h);
}
if g.offset_x != 0.0 || g.offset_y != 0.0 {
h = Homography([1.0, 0.0, g.offset_x / 100.0 * 0.5, 0.0, 1.0, -g.offset_y / 100.0 * 0.5, 0.0, 0.0, 1.0]).mul(&h);
}
h
}