From b00dda4cecae3c3eeb0a8751cc174e7a0e12aba4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 01:16:18 -0400 Subject: [PATCH] =?UTF-8?q?M4.4:=20Upright=20=E2=80=94=20Auto/Level/Vertic?= =?UTF-8?q?al/Full=20from=20detected=20lines=20+=20Guided=20upright=20(up?= =?UTF-8?q?=20to=204=20guides)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/develop/src/settings.rs | 10 +- crates/engine/src/cmd/develop.rs | 62 ++- crates/engine/src/tests.rs | 23 ++ crates/pipeline/src/geometry.rs | 4 +- crates/pipeline/src/lib.rs | 2 + crates/pipeline/src/optics.rs | 3 +- crates/pipeline/src/tests_geometry.rs | 156 +++++++ crates/pipeline/src/upright.rs | 570 ++++++++++++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 76 +++- crates/ui-egui/src/panels/right.rs | 41 ++ 10 files changed, 935 insertions(+), 12 deletions(-) create mode 100644 crates/pipeline/src/upright.rs diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index b8f0e5fc2..fa257c367 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -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, 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, diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index a53f31700..b7b790670 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -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 { 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 = 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", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6630910cf..a6623710a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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()); +} diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index 38aa10174..5742c8ab4 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -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 diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 9ca2ab09d..c66026bab 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -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); diff --git a/crates/pipeline/src/optics.rs b/crates/pipeline/src/optics.rs index 96ab6f7e7..7c2cec270 100644 --- a/crates/pipeline/src/optics.rs +++ b/crates/pipeline/src/optics.rs @@ -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 { diff --git a/crates/pipeline/src/tests_geometry.rs b/crates/pipeline/src/tests_geometry.rs index 6d3c097cc..142ec19b2 100644 --- a/crates/pipeline/src/tests_geometry.rs +++ b/crates/pipeline/src/tests_geometry.rs @@ -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 { + 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 { + 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); +} diff --git a/crates/pipeline/src/upright.rs b/crates/pipeline/src/upright.rs new file mode 100644 index 000000000..b7e93bc94 --- /dev/null +++ b/crates/pipeline/src/upright.rs @@ -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 { + 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 = 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 = (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 = Vec::new(); + let mut region: Vec = 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 ®ion { + 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 ®ion { + 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 ®ion { + 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 = (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 { + 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 { + 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, + pub horizontal: Vec, + 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 = segs.iter().copied().filter(|s| s.tilt_from_vertical() < 30.0).collect(); + let horizontal: Vec = 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 = guides.iter().copied().filter(|g| g.len() > 1e-6).take(4).collect(); + let vert: Vec = guides.iter().copied().filter(|g| g.tilt_from_vertical() <= 45.0).collect(); + let horiz: Vec = 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 = 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> = 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 = 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 = g.guides.iter().map(|(a, b)| Segment { a: c(*a), b: c(*b) }).collect(); + solve_guided(&segs) + } + _ => g.upright_transform.unwrap_or(Homography::IDENTITY), + } +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index a23dadf90..4a6eb6267 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -17,12 +17,33 @@ use crate::widgets::register; /// An in-progress on-canvas gesture. #[derive(Clone, Debug)] pub enum Gesture { - Brush { points: Vec }, - Spot { points: Vec }, - 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, + }, + Spot { + points: Vec, + }, + 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 = 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); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 37ea4ef5c..d112c8b3c 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -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"}));