M4.4: Upright — Auto/Level/Vertical/Full from detected lines + Guided upright (up to 4 guides)

- pipeline::upright: own gradient-based line-segment detector (level-line region growing seeded from the
  strongest gradients, weighted principal-axis fit, elongation test); vanishing points by RANSAC over
  segment pairs + least-squares refinement (smallest eigenvector of Σ w·l·lᵀ)
- corrections as a virtual camera rotation K·R·K⁻¹ (vertical VP → y axis; Full also horizontal VP → x axis
  with focal length from VP orthogonality when plausible); Level = rotation; Auto = Full/Vertical when
  moderate, else Level; all re-centred
- Guided: guides stored in normalized lens-corrected coordinates; 2+ vertical/horizontal guides define the
  vanishing points, a single guide levels
- the analysis runs on the lens-corrected image at 1024 px and the result is stored in
  geometry.upright_transform (identical preview/export); settings naming a mode without a transform
  (XMP, CLI) are resolved at render time
- commands geometry.upright {mode} (Update = run again) and geometry.guides {guides, add}
- UI: Upright mode buttons, Update, Draw/Clear Guides in the crop panel; on-canvas guide drawing that
  follows the warp
- tests: segment detector on known lines; Vertical/Auto/Full fix converging verticals; Level fixes a tilted
  horizon; Full fixes both families; guided from two guides; analysis resolution independence; lazy vs
  stored transform; engine commands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 01:16:18 -04:00
co-authored by Claude Opus 5.5
parent 8cbdece6f2
commit b00dda4cec
10 changed files with 935 additions and 12 deletions
+8 -2
View File
@@ -1,7 +1,7 @@
//! The develop settings schema. Every field has a neutral default; serde uses `#[serde(default)]` so
//! older/newer files load (unknown fields are ignored, missing fields take defaults).
use lightcraft_geom::{CropGeometry, Orientation, Point};
use lightcraft_geom::{CropGeometry, Homography, Orientation, Point};
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
@@ -493,8 +493,13 @@ pub enum Upright {
#[serde(default)]
pub struct Geometry {
pub upright: Upright,
/// Guided Upright guides (normalized image coords), up to 4 line segments.
/// Guided Upright guides, up to 4 line segments, in normalized coordinates of the lens-corrected
/// (pre-perspective) oriented image.
pub guides: Vec<(Point, Point)>,
/// The automatic Upright correction found by analysis for `upright` (Auto/Level/Vertical/Full), as a
/// homography lens-corrected → transformed in centred coordinates (`(p − centre) / (long edge / 2)`).
/// Stored so previews and exports use the identical transform; recomputed by `geometry.upright`.
pub upright_transform: Option<Homography>,
pub vertical: f64,
pub horizontal: f64,
pub rotate: f64,
@@ -510,6 +515,7 @@ impl Default for Geometry {
Self {
upright: Upright::Off,
guides: Vec::new(),
upright_transform: None,
vertical: 0.0,
horizontal: 0.0,
rotate: 0.0,
+61 -1
View File
@@ -1,7 +1,7 @@
//! Develop commands. All operate on the active photo unless `ids` are given (sync).
use lightcraft_catalog::{Op, PhotoId, Version};
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, WbMode, controls};
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
use lightcraft_geom::{CropGeometry, Point, Rect, crop_fit_angle};
use serde_json::{Value, json};
@@ -382,6 +382,66 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
}),
// ---- geometry: Upright
cmd!(
"geometry.upright",
"Upright",
[],
None,
"{mode: off|auto|guided|level|vertical|full} — analyses the photo (line segments → vanishing points) and stores the correction; run again to update after lens changes",
has_active,
|s, p| {
let mode: Upright =
serde_json::from_value(p.get("mode").cloned().unwrap_or(json!("auto"))).map_err(|e| bad("geometry.upright", e.to_string()))?;
let id = active(s, "geometry.upright")?;
let d = s.develop_of(id).unwrap_or_default();
let transform = match mode {
Upright::Off | Upright::Guided => None,
m => {
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("geometry.upright", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
Some(lightcraft_pipeline::upright::auto_transform(&src, &info, &d, m))
}
};
edit(s, "geometry.upright", "Upright", |d| {
d.geometry.upright = mode;
d.geometry.upright_transform = transform;
Ok(())
})?;
Ok(json!({"mode": mode, "transform": transform}))
}
),
cmd!(
"geometry.guides",
"Guided Upright Guides",
[],
None,
"{guides: [[x0,y0,x1,y1], …] (≤ 4, normalized coords of the lens-corrected image), add?: bool (append one guide, dropping the oldest beyond 4)} — switches Upright to Guided",
has_active,
|s, p| {
let parse = |v: &Value| -> Option<(Point, Point)> {
let a: Vec<f64> = v.as_array()?.iter().filter_map(Value::as_f64).collect();
(a.len() == 4 && a.iter().all(|x| x.is_finite())).then(|| (Point::new(a[0], a[1]), Point::new(a[2], a[3])))
};
let guides: Vec<(Point, Point)> =
p.get("guides").and_then(Value::as_array).map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default();
let add = bool_or(p, "add", false);
edit(s, "geometry.guides", "Guided Upright", |d| {
if add {
d.geometry.guides.extend(guides);
} else {
d.geometry.guides = guides;
}
let n = d.geometry.guides.len();
if n > 4 {
d.geometry.guides.drain(..n - 4);
}
d.geometry.upright = Upright::Guided;
d.geometry.upright_transform = None;
Ok(())
})
}
),
// ---- copy / paste / sync
cmd!(
"develop.copy",
+23
View File
@@ -166,3 +166,26 @@ fn command_sweep() {
}
assert!(s.commands().len() >= 70, "{}", s.commands().len());
}
#[test]
fn upright_and_guides_commands() {
let mut s = demo();
let r = s.execute("geometry.upright", &json!({"mode": "level"})).unwrap();
assert_eq!(r["mode"], "level");
let d = active_dev(&s);
assert_eq!(d.geometry.upright, lightcraft_develop::Upright::Level);
assert!(d.geometry.upright_transform.is_some(), "analysis result is stored");
s.execute("geometry.upright", &json!({"mode": "off"})).unwrap();
assert!(active_dev(&s).geometry.upright_transform.is_none());
for i in 0..5 {
let x = 0.1 + 0.15 * i as f64;
s.execute("geometry.guides", &json!({"guides": [[x, 0.2, x + 0.02, 0.8]], "add": true})).unwrap();
}
let d = active_dev(&s);
assert_eq!(d.geometry.upright, lightcraft_develop::Upright::Guided);
assert_eq!(d.geometry.guides.len(), 4, "at most four guides");
assert!((d.geometry.guides[0].0.x - 0.25).abs() < 1e-9, "oldest dropped");
let id = s.active().unwrap();
assert!(s.render_now(id, 160, 160).is_ok());
assert!(s.execute("geometry.upright", &json!({"mode": "sideways"})).is_err());
}
+2 -2
View File
@@ -223,11 +223,11 @@ impl Frame {
/// The perspective homography of the settings (Upright, then the manual Transform sliders), lens-corrected →
/// transformed, in centred coordinates.
pub fn perspective(s: &DevelopSettings, _ow: f64, _oh: f64) -> Homography {
pub fn perspective(s: &DevelopSettings, ow: f64, oh: f64) -> Homography {
if !s.section_enabled("geometry") {
return Homography::IDENTITY;
}
crate::transform::manual(&s.geometry)
crate::transform::manual(&s.geometry).mul(&crate::upright::homography(&s.geometry, ow, oh))
}
/// One resample through the warp: `o2t` maps output px → transformed px, the warp maps those to source px
+2
View File
@@ -28,6 +28,7 @@ pub mod profiles;
pub mod spots;
mod tone;
pub mod transform;
pub mod upright;
use lightcraft_develop::{DevelopSettings, Treatment};
use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8, par_rows};
@@ -121,6 +122,7 @@ pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &Render
};
let mut t = prof.then(std::time::Instant::now);
let s = &*profiles::effective(s);
let s = &*upright::resolve(src, info, s);
let frame = frame_for(src, info, s, req.apply_crop);
let (w, h) = frame.fit(req.max_w, req.max_h);
let mut img = frame.sample(src, w, h);
+2 -1
View File
@@ -268,7 +268,8 @@ pub fn estimate_lateral_ca(img: &Rgb32f) -> [f64; 2] {
v
}
fn fingerprint(img: &Rgb32f) -> u64 {
/// Cheap content fingerprint of an image (dimensions + 512 sampled pixels), for analysis caches.
pub(crate) fn fingerprint(img: &Rgb32f) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ ((img.width as u64) << 32 | img.height as u64);
let n = img.data.len();
for i in 0..512usize {
+156
View File
@@ -274,3 +274,159 @@ fn transforms_preview_matches_export() {
let err = mean_abs_diff_downscaled(&small, &big);
assert!(err < 4.0, "mean abs error {err}");
}
// ------------------------------------------------------------------------------------------ M4.4 Upright
use crate::upright::{Segment, detect_segments, solve_guided};
use lightcraft_develop::Upright;
#[test]
fn segment_detector_finds_known_lines() {
// three dark lines at known angles on a light background
let lines = [
(Point::new(60.0, 40.0), Point::new(80.0, 280.0)),
(Point::new(120.0, 300.0), Point::new(420.0, 260.0)),
(Point::new(300.0, 30.0), Point::new(440.0, 200.0)),
];
let dist = |p: Point, a: Point, b: Point| {
let ab = b - a;
let t = ((p - a).dot(ab) / ab.dot(ab)).clamp(0.0, 1.0);
p.dist(a + ab * t)
};
let lum = lightcraft_raster::Plane::from_fn(480, 320, |x, y| {
let p = Point::new(x as f64 + 0.5, y as f64 + 0.5);
let d = lines.iter().map(|(a, b)| dist(p, *a, *b)).fold(f64::MAX, f64::min);
(0.15 + 0.7 * ((d - 1.0) / 1.2).clamp(0.0, 1.0)) as f32
});
let segs = detect_segments(&lum);
let l = 240.0;
for (a, b) in lines {
let want = (b.y - a.y).atan2(b.x - a.x);
let len = a.dist(b) / l;
let hit = segs.iter().any(|s| {
let got = (s.b.y - s.a.y).atan2(s.b.x - s.a.x);
let mut d = (got - want).abs() % std::f64::consts::PI;
if d > std::f64::consts::FRAC_PI_2 {
d = std::f64::consts::PI - d;
}
d.to_degrees() < 0.7 && s.len() > 0.6 * len
});
assert!(hit, "line {a:?}–{b:?} not found in {segs:?}");
}
}
/// x of the same dark vertical line at the top, bottom and middle of the output (tracked from the middle).
fn vertical_line_track(img: &Rgba8, x0: f64) -> Vec<f64> {
let mid = line_x(img, img.height / 2 + 7, x0, 14.0);
[img.height / 6 + 7, img.height * 5 / 6 - 7].iter().map(|&y| line_x(img, y, mid, 10.0)).chain([mid]).collect()
}
fn horizontal_line_track(img: &Rgba8, y0: f64) -> Vec<f64> {
let mid = line_y(img, img.width / 2 + 7, y0, 14.0);
[img.width / 6 + 7, img.width * 5 / 6 - 7].iter().map(|&x| line_y(img, x, mid, 10.0)).chain([mid]).collect()
}
fn upright_render(src: &Rgb32f, mode: Upright) -> Rgba8 {
let mut s = DevelopSettings::default();
s.geometry.upright = mode;
s.geometry.upright_transform = Some(crate::upright::auto_transform(src, &SourceInfo::default(), &s, mode));
render(src, &SourceInfo::default(), &s, &RenderRequest::fit(src.width, src.height)).image
}
#[test]
fn upright_vertical_fixes_converging_verticals() {
let (w, h) = (720usize, 480usize);
// "shot from below" and slightly rolled: the inverse of what the correction should do
let shot = lightcraft_develop::Geometry { vertical: 35.0, rotate: -2.0, ..Default::default() };
let src = keystoned_grid(w, h, crate::transform::manual(&shot), 48.0);
let raw = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(w, h)).image;
let before = vertical_line_track(&raw, 130.0);
assert!(spread(&before) > 6.0, "source verticals converge: {before:?}");
for mode in [Upright::Vertical, Upright::Auto, Upright::Full] {
let img = upright_render(&src, mode);
for x0 in [130.0, 360.0, 590.0] {
let t = vertical_line_track(&img, x0);
assert!(spread(&t) < 1.2, "{mode:?}: line near {x0} at {t:?}");
}
let t = horizontal_line_track(&img, 240.0);
assert!(spread(&t) < 1.5, "{mode:?}: horizontals stay level: {t:?}");
}
}
#[test]
fn upright_level_straightens_a_tilted_horizon() {
let (w, h) = (720usize, 480usize);
let shot = lightcraft_develop::Geometry { rotate: 3.5, ..Default::default() };
let src = keystoned_grid(w, h, crate::transform::manual(&shot), 48.0);
let img = upright_render(&src, Upright::Level);
for y0 in [120.0, 240.0, 360.0] {
let t = horizontal_line_track(&img, y0);
assert!(spread(&t) < 1.2, "line near {y0} at {t:?}");
}
}
#[test]
fn upright_full_fixes_both_families() {
let (w, h) = (720usize, 480usize);
let shot = lightcraft_develop::Geometry { vertical: 25.0, horizontal: -25.0, ..Default::default() };
let src = keystoned_grid(w, h, crate::transform::manual(&shot), 48.0);
let img = upright_render(&src, Upright::Full);
for x0 in [200.0, 360.0, 520.0] {
let t = vertical_line_track(&img, x0);
assert!(spread(&t) < 1.5, "vertical near {x0}: {t:?}");
}
for y0 in [150.0, 330.0] {
let t = horizontal_line_track(&img, y0);
assert!(spread(&t) < 1.5, "horizontal near {y0}: {t:?}");
}
}
#[test]
fn guided_upright_from_two_vertical_guides() {
let (w, h) = (720usize, 480usize);
let shot = lightcraft_develop::Geometry { vertical: 30.0, ..Default::default() };
let fwd = crate::transform::manual(&shot);
let src = keystoned_grid(w, h, fwd, 48.0);
// guides drawn along two building edges: straight lines of the "true" image, seen through fwd⁻¹
let inv = fwd.inverse().unwrap();
let l = 360.0;
let norm = |p: Point| {
let q = inv.apply(p);
Point::new((q.x * l + 360.0) / 720.0, (q.y * l + 240.0) / 480.0)
};
let mut s = DevelopSettings::default();
s.geometry.upright = Upright::Guided;
s.geometry.guides =
vec![(norm(Point::new(-0.5, -0.5)), norm(Point::new(-0.5, 0.5))), (norm(Point::new(0.55, -0.4)), norm(Point::new(0.55, 0.5)))];
let img = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(w, h)).image;
for x0 in [180.0, 360.0, 540.0] {
let t = vertical_line_track(&img, x0);
assert!(spread(&t) < 1.2, "guided: line near {x0} at {t:?}");
}
// a single guide only levels (rotation): it becomes vertical
let one = solve_guided(&[Segment { a: Point::new(0.1, -0.5), b: Point::new(0.12, 0.5) }]);
let p = one.apply(Point::new(0.1, -0.5));
let q = one.apply(Point::new(0.12, 0.5));
assert!((p.x - q.x).abs() < 1e-9, "single vertical guide becomes vertical: {p:?} {q:?}");
}
#[test]
fn upright_analysis_is_resolution_independent() {
let shot = lightcraft_develop::Geometry { vertical: 30.0, rotate: 1.5, ..Default::default() };
let fwd = crate::transform::manual(&shot);
let big = keystoned_grid(1200, 800, fwd, 80.0);
let small = keystoned_grid(600, 400, fwd, 40.0);
let s = DevelopSettings::default();
let a = crate::upright::auto_transform(&big, &SourceInfo::default(), &s, Upright::Vertical);
let b = crate::upright::auto_transform(&small, &SourceInfo::default(), &s, Upright::Vertical);
for p in [Point::new(-1.0, -0.66), Point::new(1.0, -0.66), Point::new(1.0, 0.66), Point::new(-1.0, 0.66)] {
assert!(a.apply(p).dist(b.apply(p)) < 0.006, "{:?} vs {:?}", a.apply(p), b.apply(p));
}
// a setting that names the mode without a stored transform renders like the stored one
let mut s1 = DevelopSettings::default();
s1.geometry.upright = Upright::Vertical;
let lazy = render(&small, &SourceInfo::default(), &s1, &RenderRequest::fit(300, 300)).image;
s1.geometry.upright_transform = Some(b);
let stored = render(&small, &SourceInfo::default(), &s1, &RenderRequest::fit(300, 300)).image;
assert_eq!(lazy.data, stored.data);
}
+570
View File
@@ -0,0 +1,570 @@
//! Upright: automatic perspective correction.
//!
//! 1. **Line segments** — our own gradient-based detector: pixels with a strong luminance gradient are grown into
//! regions of consistent level-line orientation (seeded from the strongest gradients), and each elongated region
//! is fitted with a segment (weighted principal axis).
//! 2. **Vanishing points** — near-vertical and near-horizontal segments are grouped; for each family a RANSAC over
//! segment pairs finds the point (possibly at infinity) most segments point at, refined by weighted least squares
//! (smallest eigenvector of `Σ w·l·lᵀ` over the inlier lines).
//! 3. **Correction** — a virtual camera rotation `K·R·K⁻¹` that sends the vertical vanishing direction to the image
//! y axis (and, for Full, the horizontal one to the x axis, with the focal length estimated from their
//! orthogonality when possible); Level is a pure rotation.
//!
//! Guided Upright solves the same problem from up to four user-drawn guides.
//!
//! Coordinates: centred, `(p − centre) / (long edge / 2)` of the lens-corrected image, so results are independent
//! of the analysis resolution. Results map lens-corrected → transformed coordinates.
use std::borrow::Cow;
use std::sync::Mutex;
use lightcraft_develop::{Crop, DevelopSettings, Geometry, Upright};
use lightcraft_geom::{Homography, Point};
use lightcraft_raster::{Plane, Rgb32f};
use crate::SourceInfo;
use crate::transform::{DEFAULT_FOCAL, Mat3, recentre, rotation_homography};
/// A detected (or user-drawn) line segment in centred coordinates.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Segment {
pub a: Point,
pub b: Point,
}
impl Segment {
pub fn len(&self) -> f64 {
self.a.dist(self.b)
}
pub fn is_empty(&self) -> bool {
self.len() == 0.0
}
fn mid(&self) -> Point {
self.a.lerp(self.b, 0.5)
}
/// Homogeneous line through the endpoints, normalized so (a, b) is a unit normal.
fn line(&self) -> [f64; 3] {
let l = cross([self.a.x, self.a.y, 1.0], [self.b.x, self.b.y, 1.0]);
let n = l[0].hypot(l[1]).max(1e-12);
[l[0] / n, l[1] / n, l[2] / n]
}
/// Angle from vertical in degrees (0 = vertical), folded to 0..90.
fn tilt_from_vertical(&self) -> f64 {
let (dx, dy) = (self.b.x - self.a.x, self.b.y - self.a.y);
dx.abs().atan2(dy.abs()).to_degrees()
}
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn norm(a: [f64; 3]) -> [f64; 3] {
let n = dot(a, a).sqrt().max(1e-300);
[a[0] / n, a[1] / n, a[2] / n]
}
// ------------------------------------------------------------------------------------------ segments
/// Detect line segments in a luminance plane (any scale; values roughly 0..1, perceptual). Returns segments in
/// centred coordinates of the plane.
pub fn detect_segments(lum: &Plane) -> Vec<Segment> {
let (w, h) = (lum.width, lum.height);
if w < 16 || h < 16 {
return Vec::new();
}
let img = lightcraft_raster::blur::gaussian(lum, 0.8);
let at = |x: usize, y: usize| img.data[y * w + x];
let n = w * h;
let mut mag = vec![0.0f32; n];
let mut ang = vec![0.0f32; n]; // level-line angle (gradient + 90°), radians, mod π
for y in 1..h - 1 {
for x in 1..w - 1 {
let gx = (at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1)) - (at(x - 1, y - 1) + 2.0 * at(x - 1, y) + at(x - 1, y + 1));
let gy = (at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1)) - (at(x - 1, y - 1) + 2.0 * at(x, y - 1) + at(x + 1, y - 1));
let m = gx.hypot(gy) / 8.0;
mag[y * w + x] = m;
ang[y * w + x] = (gy.atan2(gx) + std::f32::consts::FRAC_PI_2).rem_euclid(std::f32::consts::PI);
}
}
// adaptive threshold: strong edges relative to the image's own contrast
let mut sorted: Vec<f32> = mag.iter().copied().filter(|m| *m > 1e-4).collect();
if sorted.len() < 32 {
return Vec::new();
}
sorted.sort_by(f32::total_cmp);
let p90 = sorted[sorted.len() * 9 / 10];
let thr = (p90 * 0.35).max(0.008);
let mut order: Vec<u32> = (0..n as u32).filter(|&i| mag[i as usize] > thr).collect();
order.sort_by(|&a, &b| mag[b as usize].total_cmp(&mag[a as usize]));
let mut used = vec![false; n];
let tol = 22.5f32.to_radians();
let min_len = 0.04 * w.max(h) as f64;
let half_long = w.max(h) as f64 / 2.0;
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
let mut out = Vec::new();
let mut stack: Vec<usize> = Vec::new();
let mut region: Vec<usize> = Vec::new();
for &seed in &order {
let seed = seed as usize;
if used[seed] {
continue;
}
used[seed] = true;
region.clear();
region.push(seed);
stack.clear();
stack.push(seed);
// region angle via doubled-angle mean
let (mut sc, mut ss) = ((2.0 * ang[seed]).cos(), (2.0 * ang[seed]).sin());
while let Some(i) = stack.pop() {
let (x, y) = (i % w, i / w);
let ra = ss.atan2(sc) / 2.0;
for dy in -1i32..=1 {
for dx in -1i32..=1 {
if dx == 0 && dy == 0 {
continue;
}
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
if nx < 1 || ny < 1 || nx >= w as i32 - 1 || ny >= h as i32 - 1 {
continue;
}
let j = ny as usize * w + nx as usize;
if used[j] || mag[j] <= thr {
continue;
}
let mut d = (ang[j] - ra).abs() % std::f32::consts::PI;
if d > std::f32::consts::FRAC_PI_2 {
d = std::f32::consts::PI - d;
}
if d > tol {
continue;
}
used[j] = true;
region.push(j);
stack.push(j);
sc += (2.0 * ang[j]).cos();
ss += (2.0 * ang[j]).sin();
}
}
}
if region.len() < 12 {
continue;
}
// weighted principal axis
let (mut sw, mut mx, mut my) = (0.0f64, 0.0f64, 0.0f64);
for &i in &region {
let wt = mag[i] as f64;
sw += wt;
mx += wt * (i % w) as f64;
my += wt * (i / w) as f64;
}
mx /= sw;
my /= sw;
let (mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0);
for &i in &region {
let wt = mag[i] as f64;
let (dx, dy) = ((i % w) as f64 - mx, (i / w) as f64 - my);
sxx += wt * dx * dx;
syy += wt * dy * dy;
sxy += wt * dx * dy;
}
let theta = 0.5 * (2.0 * sxy).atan2(sxx - syy);
let (ux, uy) = (theta.cos(), theta.sin());
let (mut lo, mut hi, mut wlo, mut whi) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
for &i in &region {
let (dx, dy) = ((i % w) as f64 - mx, (i / w) as f64 - my);
let t = dx * ux + dy * uy;
let s = -dx * uy + dy * ux;
lo = lo.min(t);
hi = hi.max(t);
wlo = wlo.min(s);
whi = whi.max(s);
}
let (len, width) = (hi - lo, (whi - wlo).max(1.0));
if len < min_len || len / width < 5.0 {
continue;
}
let a = Point::new((mx + 0.5 + ux * lo - cx) / half_long, (my + 0.5 + uy * lo - cy) / half_long);
let b = Point::new((mx + 0.5 + ux * hi - cx) / half_long, (my + 0.5 + uy * hi - cy) / half_long);
out.push(Segment { a, b });
}
out
}
// ------------------------------------------------------------------------------------------ vanishing points
/// Angular error (radians) between a segment and the direction from its midpoint to the (homogeneous) point `v`.
fn vp_error(s: &Segment, v: [f64; 3]) -> f64 {
let m = s.mid();
let (dx, dy) = (v[0] - m.x * v[2], v[1] - m.y * v[2]);
let (sx, sy) = (s.b.x - s.a.x, s.b.y - s.a.y);
let d = dx.hypot(dy);
if d < 1e-12 {
return 0.0;
}
let c = ((dx * sx + dy * sy) / (d * sx.hypot(sy).max(1e-12))).abs().min(1.0);
c.acos()
}
/// Smallest eigenvector of a symmetric 3×3 matrix (Jacobi rotations).
fn smallest_eigvec(m: Mat3) -> [f64; 3] {
let mut a = m;
let mut v = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
for _ in 0..50 {
let (mut p, mut q, mut big) = (0, 1, 0.0);
for i in 0..3 {
for j in i + 1..3 {
if a[i][j].abs() > big {
big = a[i][j].abs();
p = i;
q = j;
}
}
}
if big < 1e-15 {
break;
}
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
let t = if theta == 0.0 { 1.0 } else { t };
let c = 1.0 / (t * t + 1.0).sqrt();
let s = t * c;
for k in 0..3 {
let (akp, akq) = (a[k][p], a[k][q]);
a[k][p] = c * akp - s * akq;
a[k][q] = s * akp + c * akq;
}
for k in 0..3 {
let (apk, aqk) = (a[p][k], a[q][k]);
a[p][k] = c * apk - s * aqk;
a[q][k] = s * apk + c * aqk;
}
for row in v.iter_mut() {
let (vp, vq) = (row[p], row[q]);
row[p] = c * vp - s * vq;
row[q] = s * vp + c * vq;
}
}
let i = (0..3).min_by(|&i, &j| a[i][i].total_cmp(&a[j][j])).unwrap_or(0);
norm([v[0][i], v[1][i], v[2][i]])
}
/// Least-squares vanishing point of `segs` (weights = lengths).
fn refine_vp(segs: &[&Segment]) -> [f64; 3] {
let mut m = [[0.0; 3]; 3];
for s in segs {
let l = s.line();
let w = s.len();
for i in 0..3 {
for j in 0..3 {
m[i][j] += w * l[i] * l[j];
}
}
}
smallest_eigvec(m)
}
/// The dominant vanishing point of a segment family: RANSAC over pairs, then least squares on the inliers.
/// Returns the point and the total inlier length, or None with fewer than two consistent segments.
pub fn vanishing_point(segs: &[Segment]) -> Option<([f64; 3], f64)> {
if segs.len() < 2 {
return None;
}
let thr = 1.5f64.to_radians();
let score = |v: [f64; 3]| -> f64 { segs.iter().map(|s| if vp_error(s, v) < thr { s.len() } else { 0.0 }).sum() };
// deterministic pair sampling, longest segments first
let mut idx: Vec<usize> = (0..segs.len()).collect();
idx.sort_by(|&a, &b| segs[b].len().total_cmp(&segs[a].len()));
idx.truncate(60);
let mut best: Option<([f64; 3], f64)> = None;
for (k, &i) in idx.iter().enumerate() {
for &j in &idx[k + 1..] {
let v = cross(segs[i].line(), segs[j].line());
if dot(v, v) < 1e-24 {
continue;
}
let v = norm(v);
let sc = score(v);
if best.is_none_or(|b| sc > b.1) {
best = Some((v, sc));
}
}
}
let (v, _) = best?;
let inl: Vec<&Segment> = segs.iter().filter(|s| vp_error(s, v) < thr * 2.0).collect();
if inl.len() < 2 {
return None;
}
let v = refine_vp(&inl);
let total: f64 = segs.iter().filter(|s| vp_error(s, v) < thr * 2.0).map(|s| s.len()).sum();
let count = segs.iter().filter(|s| vp_error(s, v) < thr * 2.0).count();
(count >= 2).then_some((v, total))
}
// ------------------------------------------------------------------------------------------ solving
/// Direction (camera frame) of a vanishing point for focal length `f`.
fn direction(v: [f64; 3], f: f64) -> [f64; 3] {
norm([v[0] / f, v[1] / f, v[2]])
}
/// The rotation (rows) taking unit vector `a` to unit vector `b` (Rodrigues; minimal angle).
fn rotation_between(a: [f64; 3], b: [f64; 3]) -> Mat3 {
let axis = cross(a, b);
let s = dot(axis, axis).sqrt();
let c = dot(a, b);
if s < 1e-12 {
return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
}
let k = [axis[0] / s, axis[1] / s, axis[2] / s];
let kx = [[0.0, -k[2], k[1]], [k[2], 0.0, -k[0]], [-k[1], k[0], 0.0]];
let mut r = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
let kk: f64 = (0..3).map(|m| kx[i][m] * kx[m][j]).sum();
r[i][j] = if i == j { 1.0 } else { 0.0 } + s * kx[i][j] + (1.0 - c) * kk;
}
}
r
}
fn rotation_angle(r: &Mat3) -> f64 {
((r[0][0] + r[1][1] + r[2][2] - 1.0) / 2.0).clamp(-1.0, 1.0).acos()
}
/// Vertical correction: send the vertical vanishing direction to the image y axis.
fn solve_vertical(v: [f64; 3], f: f64) -> Mat3 {
let mut d = direction(v, f);
if d[1] < 0.0 {
d = [-d[0], -d[1], -d[2]];
}
rotation_between(d, [0.0, 1.0, 0.0])
}
/// Full correction: vertical direction → y, horizontal direction → x (Gram–Schmidt).
fn solve_full(v: [f64; 3], hz: [f64; 3], f: f64) -> Mat3 {
let mut dv = direction(v, f);
if dv[1] < 0.0 {
dv = [-dv[0], -dv[1], -dv[2]];
}
let mut dh = direction(hz, f);
if dh[0] < 0.0 {
dh = [-dh[0], -dh[1], -dh[2]];
}
let p = dot(dh, dv);
let r1 = norm([dh[0] - p * dv[0], dh[1] - p * dv[1], dh[2] - p * dv[2]]);
let r3 = cross(r1, dv);
[r1, dv, r3]
}
/// Focal length (centred units) that makes two finite vanishing points orthogonal, when plausible.
fn focal_from_vps(v: [f64; 3], h: [f64; 3]) -> Option<f64> {
if v[2].abs() < 1e-9 || h[2].abs() < 1e-9 {
return None;
}
let (vx, vy, hx, hy) = (v[0] / v[2], v[1] / v[2], h[0] / h[2], h[1] / h[2]);
let f2 = -(vx * hx + vy * hy);
(f2 > 0.0).then(|| f2.sqrt()).filter(|f| (0.5..=6.0).contains(f))
}
/// Pure rotation (degrees, clockwise +) that levels the dominant lines.
fn level_angle(vert: &[Segment], horiz: &[Segment]) -> Option<f64> {
let dev = |s: &Segment, vertical: bool| {
let (dx, dy) = (s.b.x - s.a.x, s.b.y - s.a.y);
// signed deviation in degrees of the segment from the axis
let a = if vertical { dx.atan2(dy) } else { dy.atan2(dx) };
let a = a.to_degrees();
let a = if a > 90.0 {
a - 180.0
} else if a < -90.0 {
a + 180.0
} else {
a
};
if vertical { -a } else { a }
};
let lh: f64 = horiz.iter().map(Segment::len).sum();
let lv: f64 = vert.iter().map(Segment::len).sum();
let (set, vertical) = if lh >= lv * 0.5 && lh > 0.0 { (horiz, false) } else { (vert, true) };
if set.is_empty() {
return None;
}
// length-weighted median
let mut v: Vec<(f64, f64)> = set.iter().map(|s| (dev(s, vertical), s.len())).collect();
v.sort_by(|a, b| a.0.total_cmp(&b.0));
let total: f64 = v.iter().map(|x| x.1).sum();
let mut acc = 0.0;
for (d, l) in &v {
acc += l;
if acc >= total / 2.0 {
return Some(-*d);
}
}
None
}
fn rot_z_h(deg: f64) -> Homography {
let (s, c) = deg.to_radians().sin_cos();
Homography([c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0])
}
/// What an Upright analysis found.
#[derive(Clone, Debug, Default)]
pub struct Analysis {
pub vertical: Vec<Segment>,
pub horizontal: Vec<Segment>,
pub vertical_vp: Option<[f64; 3]>,
pub horizontal_vp: Option<[f64; 3]>,
}
/// Group segments into near-vertical / near-horizontal families and find their vanishing points.
pub fn analyze(segs: &[Segment]) -> Analysis {
let vertical: Vec<Segment> = segs.iter().copied().filter(|s| s.tilt_from_vertical() < 30.0).collect();
let horizontal: Vec<Segment> = segs.iter().copied().filter(|s| s.tilt_from_vertical() > 60.0).collect();
let vertical_vp = vanishing_point(&vertical).map(|v| v.0);
let horizontal_vp = vanishing_point(&horizontal).map(|v| v.0);
Analysis { vertical, horizontal, vertical_vp, horizontal_vp }
}
/// The Upright correction for `mode` from an analysis (lens-corrected → transformed, centred coordinates).
pub fn solve(mode: Upright, a: &Analysis) -> Homography {
let level = || level_angle(&a.vertical, &a.horizontal).map(rot_z_h).unwrap_or(Homography::IDENTITY);
let vertical = || a.vertical_vp.map(|v| solve_vertical(v, DEFAULT_FOCAL));
let full = || {
let (v, h) = (a.vertical_vp?, a.horizontal_vp?);
let f = focal_from_vps(v, h).unwrap_or(DEFAULT_FOCAL);
Some((solve_full(v, h, f), f))
};
match mode {
Upright::Off | Upright::Guided => Homography::IDENTITY,
Upright::Level => level(),
Upright::Vertical => vertical().map(|r| recentre(&rotation_homography(&r, DEFAULT_FOCAL))).unwrap_or_else(level),
Upright::Full => match full() {
Some((r, f)) => recentre(&rotation_homography(&r, f)),
None => vertical().map(|r| recentre(&rotation_homography(&r, DEFAULT_FOCAL))).unwrap_or_else(level),
},
Upright::Auto => {
// balanced: full correction only when it is moderate, else vertical, else level
if let Some((r, f)) = full().filter(|(r, _)| rotation_angle(r) < 20f64.to_radians()) {
return recentre(&rotation_homography(&r, f));
}
match vertical().filter(|r| rotation_angle(r) < 25f64.to_radians()) {
Some(r) => recentre(&rotation_homography(&r, DEFAULT_FOCAL)),
None => level(),
}
}
}
}
/// Guided Upright from user guides (centred coordinates of the lens-corrected image). Guides closer to vertical
/// define the vertical vanishing point, the others the horizontal one; one guide of a kind only levels.
pub fn solve_guided(guides: &[Segment]) -> Homography {
let guides: Vec<Segment> = guides.iter().copied().filter(|g| g.len() > 1e-6).take(4).collect();
let vert: Vec<Segment> = guides.iter().copied().filter(|g| g.tilt_from_vertical() <= 45.0).collect();
let horiz: Vec<Segment> = guides.iter().copied().filter(|g| g.tilt_from_vertical() > 45.0).collect();
let vp = |s: &[Segment]| -> Option<[f64; 3]> {
if s.len() < 2 {
return None;
}
let refs: Vec<&Segment> = s.iter().collect();
Some(refine_vp(&refs))
};
let (v, h) = (vp(&vert), vp(&horiz));
match (v, h) {
(Some(v), Some(h)) => {
let f = focal_from_vps(v, h).unwrap_or(DEFAULT_FOCAL);
recentre(&rotation_homography(&solve_full(v, h, f), f))
}
(Some(v), None) => recentre(&rotation_homography(&solve_vertical(v, DEFAULT_FOCAL), DEFAULT_FOCAL)),
(None, Some(_)) => {
// two horizontal guides: make them parallel and horizontal (rotate the problem by 90°)
let swap = |p: Point| Point::new(p.y, -p.x);
let hs: Vec<Segment> = horiz.iter().map(|s| Segment { a: swap(s.a), b: swap(s.b) }).collect();
let refs: Vec<&Segment> = hs.iter().collect();
let r = solve_vertical(refine_vp(&refs), DEFAULT_FOCAL);
let inner = rotation_homography(&r, DEFAULT_FOCAL);
let to = Homography([0.0, 1.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0]); // (x, y) → (y, −x)
let back = to.inverse().unwrap_or(Homography::IDENTITY);
recentre(&back.mul(&inner).mul(&to))
}
(None, None) => level_angle(&vert, &horiz).map(rot_z_h).unwrap_or(Homography::IDENTITY),
}
}
// ------------------------------------------------------------------------------------------ sources
/// Long edge of the analysis image.
const ANALYSIS_LONG: usize = 1024;
/// Analyse a source: the lens-corrected (optics applied, no perspective/crop) image at [`ANALYSIS_LONG`] px,
/// segment detection and vanishing points. Cached per source + optics settings.
pub fn analyze_source(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> Analysis {
static CACHE: Mutex<Vec<(u64, Analysis)>> = Mutex::new(Vec::new());
let mut base = s.clone();
base.geometry = Geometry::default();
base.crop = Crop::default();
let key = crate::optics::fingerprint(src)
^ lightcraft_develop::DevelopSettings { optics: s.optics, orientation: s.orientation, ..Default::default() }.hash64().rotate_left(17);
if let Ok(c) = CACHE.lock()
&& let Some((_, a)) = c.iter().find(|(k, _)| *k == key)
{
return a.clone();
}
let frame = crate::frame_for(src, info, &base, false);
let (w, h) = frame.fit(ANALYSIS_LONG, ANALYSIS_LONG);
let img = frame.sample(src, w, h);
// perceptual lightness, normalized to the image's bright end
let lum = img.luminance();
let mut v: Vec<f32> = lum.data.iter().copied().filter(|x| x.is_finite()).collect();
v.sort_by(f32::total_cmp);
let white = v.get(v.len() * 99 / 100).copied().unwrap_or(1.0).max(1e-4);
let lum = lum.map(|x| (x.max(0.0) / white).min(1.5).powf(1.0 / 2.4));
let a = analyze(&detect_segments(&lum));
if let Ok(mut c) = CACHE.lock() {
c.push((key, a.clone()));
if c.len() > 8 {
c.remove(0);
}
}
a
}
/// The automatic Upright correction for `mode` on `src` (identity for Off and Guided).
pub fn auto_transform(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, mode: Upright) -> Homography {
if matches!(mode, Upright::Off | Upright::Guided) {
return Homography::IDENTITY;
}
solve(mode, &analyze_source(src, info, s))
}
/// Fill in a missing automatic Upright transform (settings from XMP/CLI that name a mode but carry no analysis).
pub fn resolve<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings) -> Cow<'a, DevelopSettings> {
let g = &s.geometry;
if matches!(g.upright, Upright::Off | Upright::Guided) || g.upright_transform.is_some() || !s.section_enabled("geometry") {
return Cow::Borrowed(s);
}
let mut e = s.clone();
e.geometry.upright_transform = Some(auto_transform(src, info, s, g.upright));
Cow::Owned(e)
}
/// The Upright homography in effect for settings (stored automatic transform, or solved from guides).
/// `ow × oh` is the oriented image size (guides are normalized to it).
pub fn homography(g: &Geometry, ow: f64, oh: f64) -> Homography {
match g.upright {
Upright::Off => Homography::IDENTITY,
Upright::Guided => {
let l = ow.max(oh) / 2.0;
let c = |p: Point| Point::new((p.x * ow - ow / 2.0) / l, (p.y * oh - oh / 2.0) / l);
let segs: Vec<Segment> = g.guides.iter().map(|(a, b)| Segment { a: c(*a), b: c(*b) }).collect();
solve_guided(&segs)
}
_ => g.upright_transform.unwrap_or(Homography::IDENTITY),
}
}
+70 -6
View File
@@ -17,12 +17,33 @@ use crate::widgets::register;
/// An in-progress on-canvas gesture.
#[derive(Clone, Debug)]
pub enum Gesture {
Brush { points: Vec<Point> },
Spot { points: Vec<Point> },
CropHandle { handle: u8, start: lightcraft_geom::Rect, angle: f64 },
CropRotate { start_angle: f64, a0: f32 },
Pan { start: (f32, f32), at: Pos2 },
MaskHandle { mask: u32, handle: u8 },
Brush {
points: Vec<Point>,
},
Spot {
points: Vec<Point>,
},
CropHandle {
handle: u8,
start: lightcraft_geom::Rect,
angle: f64,
},
CropRotate {
start_angle: f64,
a0: f32,
},
Pan {
start: (f32, f32),
at: Pos2,
},
MaskHandle {
mask: u32,
handle: u8,
},
/// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates).
Guide {
a: Point,
},
}
/// Screen ↔ normalized-image mapping for the displayed frame.
@@ -237,6 +258,10 @@ fn general_interaction(
// ------------------------------------------------------------------------ crop
fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings, id: PhotoId) {
if app.ui.tool == "guidedUpright" {
guided_overlay(app, ui, resp, map, frame, d);
return;
}
let quad = frame_crop_quad(d, frame);
let pts: Vec<Pos2> = quad.iter().map(|q| map.screen(*q)).collect();
let p = ui.painter();
@@ -366,6 +391,45 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
let _ = id;
}
/// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are
/// stored in lens-corrected (pre-perspective) coordinates, so they stay attached to the image as it warps.
fn guided_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings) {
let p = ui.painter_at(map.rect.expand(8.0));
let col = Color32::from_rgb(255, 196, 40);
let draw = |a: Pos2, b: Pos2| {
p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(120)));
p.line_segment([a, b], Stroke::new(1.5, col));
for q in [a, b] {
p.circle_filled(q, 4.0, col);
p.circle_stroke(q, 4.0, Stroke::new(1.0, Color32::BLACK));
}
};
for (i, (a, b)) in d.geometry.guides.iter().enumerate() {
let (sa, sb) = (map.screen(frame.corrected_to_transformed(*a)), map.screen(frame.corrected_to_transformed(*b)));
register(ui.ctx(), format!("uprightGuide:{i}"), Rect::from_two_pos(sa, sb));
draw(sa, sb);
}
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
if resp.drag_started()
&& let Some(q) = resp.interact_pointer_pos()
{
app.gesture = Some(Gesture::Guide { a: map.norm(q) });
}
if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, resp.interact_pointer_pos()) {
draw(map.screen(*a), q);
}
if resp.drag_stopped()
&& let (Some(Gesture::Guide { a }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
{
app.gesture = None;
let b = map.norm(q);
if a.dist(b) > 0.02 {
let (ca, cb) = (frame.transformed_to_corrected(a), frame.transformed_to_corrected(b));
let _ = app.run("geometry.guides", json!({"guides": [[ca.x, ca.y, cb.x, cb.y]], "add": true}));
}
}
}
/// Normalized oriented coords → the straightened (rotated) frame the crop rect lives in.
fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point {
let (w, h) = (frame.ow, frame.oh);
+41
View File
@@ -134,6 +134,47 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
divider(ui);
header(ui, "Geometry");
padded(ui, |ui| {
ui.label("Upright");
ui.horizontal_wrapped(|ui| {
use lightcraft_develop::Upright;
for (label, mode, key) in [
("Off", Upright::Off, "off"),
("Auto", Upright::Auto, "auto"),
("Guided", Upright::Guided, "guided"),
("Level", Upright::Level, "level"),
("Vertical", Upright::Vertical, "vertical"),
("Full", Upright::Full, "full"),
] {
if text_button(ui, &format!("upright-{key}"), label, d.geometry.upright == mode).clicked() {
let _ = app.run("geometry.upright", json!({"mode": key}));
app.ui.tool = if mode == Upright::Guided { "guidedUpright".into() } else { String::new() };
}
}
});
let guided = d.geometry.upright == lightcraft_develop::Upright::Guided;
ui.horizontal_wrapped(|ui| {
if !guided && d.geometry.upright != lightcraft_develop::Upright::Off && text_button(ui, "uprightUpdate", "Update", false).clicked() {
let mode = serde_json::to_value(d.geometry.upright).unwrap_or_default();
let _ = app.run("geometry.upright", json!({"mode": mode}));
}
if guided {
let drawing = app.ui.tool == "guidedUpright";
if text_button(ui, "uprightDraw", "Draw Guides", drawing).clicked() {
app.ui.tool = if drawing { String::new() } else { "guidedUpright".into() };
}
if !d.geometry.guides.is_empty() && text_button(ui, "uprightClear", "Clear Guides", false).clicked() {
let _ = app.run("geometry.guides", json!({"guides": []}));
}
}
});
if guided {
ui.label(
egui::RichText::new(format!("{} of 4 guides — drag along lines that should be vertical or horizontal.", d.geometry.guides.len()))
.size(11.0)
.color(Tokens::get(ui.ctx()).text_dim),
);
}
ui.add_space(4.0);
let mut c = d.geometry.constrain_crop;
if ui.checkbox(&mut c, "Constrain Crop").changed() {
let _ = app.run("develop.merge", json!({"settings": {"geometry": {"constrain_crop": c}}, "label": "Constrain Crop"}));