mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 02:59:17 -06:00
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:
co-authored by
Claude Opus 5.5
parent
8cbdece6f2
commit
b00dda4cec
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ®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<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),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"}));
|
||||
|
||||
Reference in New Issue
Block a user