mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 04:27:30 -06:00
- 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>
90 lines
3.7 KiB
Rust
90 lines
3.7 KiB
Rust
//! 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
|
||
}
|