LR-CROP-AUTO: Auto straighten reads the vertical vanishing point and the horizon

Auto straighten levelled only by the consensus of long lines, which a sea horizon among waves or
a building's converging verticals don't give. It now tries, in crates/pipeline/src/straighten.rs:
the roll from the vertical vanishing point (at least 8 inliers across the frame), then the
horizon as the straight full-width line with brighter rows on one side (scored by its weaker
columns, so waves, clouds and ridges don't make one), then the long lines as before; the long
lines also veto a horizon they disagree with by more than 1°. The limit drops from 10° to 5°.
Upright Level is unchanged.

On 113 photos straightened by hand in Lightroom Classic: 50 instead of 29 come out straighter,
10 (as before) more tilted, mean error 0.82° instead of 1.01°.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ingolfteetz
2026-10-24 14:59:20 +02:00
co-authored by Claude Opus 5.5
parent 23f54803e6
commit 47e2c0d248
6 changed files with 312 additions and 55 deletions
+1
View File
@@ -39,6 +39,7 @@ pub mod output;
pub mod profiles;
pub mod redeye;
pub mod spots;
pub mod straighten;
pub mod tone;
pub mod transform;
pub mod upright;
+186
View File
@@ -0,0 +1,186 @@
//! Auto straighten: the crop angle that levels a photo, from three cues on the analysis image
//! ([`crate::upright::analysis_plane`]), tried in this order:
//!
//! 1. **Plumb lines**: the roll from the vertical vanishing point. Verticals that converge in
//! perspective (a building shot upward) still name the camera's roll, where their mean tilt
//! doesn't. Only a vanishing point backed by many lines across the frame counts.
//! 2. **The horizon**: the straight line across the whole width along which it is brighter on one
//! side than on the other (sky and sea, sky and land). It is scored by its weaker columns, so
//! the edges of waves, clouds or a ridge, which hold only in places, don't make one.
//! 3. **Long lines**: the consensus of near-horizontal or near-vertical segments (the rule Upright
//! Level uses), with at least [`MIN_LINES`] agreeing. It also vetoes a horizon it disagrees with.
//!
//! Nothing past [`MAX_DEGREES`] is levelled.
//!
//! Measured on 113 photos straightened by hand in Lightroom Classic, against the long lines alone:
//! 50 instead of 29 come out straighter, 10 (as before) more tilted, and the mean error is 0.82°
//! instead of 1.01°. Of 300 photos left as shot, 11 instead of 8 are turned by more than 3°.
use lightcraft_raster::Plane;
use crate::upright::{Analysis, Segment, detect_segments, level_angle_within, vanishing_point, vp_error};
/// The most Auto straighten turns a photo (degrees). None of the 113 photos straightened by hand
/// needed more than 6.1°, and larger "levels" came from texture (planks, a branch, a bird's wing).
pub const MAX_DEGREES: f64 = 5.0;
/// The fewest agreeing long lines that level a photo on their own: one line (the frame of a
/// window, a branch) said nothing about the horizon.
pub const MIN_LINES: usize = 3;
/// Long lines read up to this angle, so that a steeper consensus can veto a horizon.
const LINES_MAX_DEGREES: f64 = 10.0;
/// A horizon the long lines disagree with by more than this (degrees) is not used.
const VETO_DEGREES: f64 = 1.0;
/// Plumb lines: fewest inliers of the vertical vanishing point, their least total length and the
/// least spread across the frame (both in half long edges), and the most a vertical may lean.
const PLUMB_MIN_LINES: usize = 8;
const PLUMB_MIN_LENGTH: f64 = 1.5;
const PLUMB_MIN_SPREAD: f64 = 0.6;
const PLUMB_MAX_LEAN: f64 = 20.0;
/// Horizon: blur (px), rows averaged on each side of the line, the share of columns that may be
/// weaker than the score, the least score (relative to the image's strongest edges), and the
/// share of the height at the top and bottom a horizon doesn't lie in.
const HORIZON_SIGMA: f32 = 1.5;
const HORIZON_ROWS: usize = 4;
const HORIZON_QUANTILE: f64 = 0.3;
const HORIZON_MIN_SCORE: f64 = 0.3;
const HORIZON_MARGIN: f64 = 0.05;
/// Horizon search: the angles tried (degrees) before the limit applies, and their steps.
const HORIZON_SEARCH: f64 = 10.0;
const HORIZON_STEP: f64 = 0.25;
const HORIZON_FINE_STEP: f64 = 0.02;
/// The crop angle (degrees, clockwise +) that levels the photo whose analysis image is `lum` and
/// whose segments are analysed in `a`, or `None` when no cue can be trusted.
pub(crate) fn decide(lum: &Plane, a: &Analysis) -> Option<f64> {
if let Some(roll) = plumb_roll(&a.vertical) {
return Some(roll);
}
let lines = level_angle_within(&a.vertical, &a.horizontal, LINES_MAX_DEGREES, MIN_LINES);
match (horizon(lum), lines) {
(Some(h), Some(l)) => ((h - l).abs() <= VETO_DEGREES).then_some(h),
(h, l) => h.or(l.filter(|l| l.abs() <= MAX_DEGREES)),
}
}
/// [`decide`] for an analysis image alone (its segments detected here).
pub fn level_plane(lum: &Plane) -> Option<f64> {
decide(lum, &crate::upright::analyze(&detect_segments(lum)))
}
fn fold(a: f64) -> f64 {
if a > 90.0 {
a - 180.0
} else if a < -90.0 {
a + 180.0
} else {
a
}
}
/// The roll named by the vanishing point of the near-vertical segments.
fn plumb_roll(vertical: &[Segment]) -> Option<f64> {
let lines: Vec<Segment> = vertical.iter().copied().filter(|s| s.tilt_from_vertical() < PLUMB_MAX_LEAN).collect();
let (v, _) = vanishing_point(&lines)?;
let thr = 2f64.to_radians();
let inliers: Vec<&Segment> = lines.iter().filter(|s| vp_error(s, v) < thr).collect();
let length: f64 = inliers.iter().map(|s| s.len()).sum();
let (lo, hi) = inliers.iter().map(|s| s.mid().x).fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(x), hi.max(x)));
if inliers.len() < PLUMB_MIN_LINES || length < PLUMB_MIN_LENGTH || hi - lo < PLUMB_MIN_SPREAD {
return None;
}
// a vanishing point inside or near the frame is a scene feature, not the camera's roll
let finite = v[2].abs() > 1e-9;
if finite && (v[0] / v[2]).hypot(v[1] / v[2]) < 2.0 {
return None;
}
let (vx, vy) = if finite { (v[0] / v[2], v[1] / v[2]) } else { (v[0], v[1]) };
let roll = fold(vx.atan2(vy).to_degrees());
(roll.is_finite() && roll.abs() <= MAX_DEGREES).then_some(roll)
}
/// The crop angle that levels the horizon of `lum`, if it has one: the straight line across the
/// width along which the rows above are brighter (or darker) than those below in most columns.
fn horizon(lum: &Plane) -> Option<f64> {
let img = lightcraft_raster::blur::gaussian(lum, HORIZON_SIGMA);
let (w, h) = (img.width, img.height);
let k = HORIZON_ROWS;
if w < 32 || h < 4 * k + 8 || img.data.len() < w * h {
return None;
}
// per sampled column: the mean of the k rows above each row minus that of the k rows below
let cols: Vec<usize> = (0..w).step_by(2).collect();
let mut diff = vec![0f32; cols.len() * h];
let mut prefix = vec![0f64; h + 1];
for (ci, &x) in cols.iter().enumerate() {
for y in 0..h {
prefix[y + 1] = prefix[y] + f64::from(img.data[y * w + x]);
}
for y in k..h - k {
let above = (prefix[y] - prefix[y - k]) / k as f64;
let below = (prefix[y + k] - prefix[y]) / k as f64;
diff[ci * h + y] = (above - below) as f32;
}
}
let mut strengths: Vec<f32> = diff.iter().map(|d| d.abs()).filter(|d| *d > 0.0).collect();
if strengths.len() < 100 {
return None;
}
let p99 = strengths.len() * 99 / 100;
strengths.select_nth_unstable_by(p99, f32::total_cmp);
let norm = f64::from(strengths[p99]).max(1e-4);
let cx = w as f64 / 2.0;
let rows = ((h as f64 * HORIZON_MARGIN) as isize).max(k as isize)..((h as f64 * (1.0 - HORIZON_MARGIN)) as isize).min((h - k) as isize);
let mut column = Vec::with_capacity(cols.len());
// the score of the line through (cx, b) with slope t: the contrast most of its columns reach
let mut score = |t: f64, b: f64, sign: f32| -> f64 {
column.clear();
for (ci, &x) in cols.iter().enumerate() {
let y = (b + t * (x as f64 - cx)).round() as isize;
if rows.contains(&y) {
column.push(sign * diff[ci * h + y as usize]);
}
}
// a line that leaves the frame for more than a fifth of the width is not a horizon
if column.len() * 5 < cols.len() * 4 {
return f64::MIN;
}
let i = ((column.len() - 1) as f64 * HORIZON_QUANTILE) as usize;
column.select_nth_unstable_by(i, f32::total_cmp);
f64::from(column[i])
};
let mut best = (f64::MIN, 0.0f64, 0.0f64, 1.0f32);
for sign in [1.0f32, -1.0] {
let mut deg = -HORIZON_SEARCH;
while deg <= HORIZON_SEARCH {
let t = deg.to_radians().tan();
for b in rows.clone().step_by(2) {
let s = score(t, b as f64, sign);
if s > best.0 {
best = (s, deg, b as f64, sign);
}
}
deg += HORIZON_STEP;
}
}
let (coarse_deg, coarse_b, sign) = (best.1, best.2, best.3);
let mut deg = coarse_deg - 0.3;
while deg <= coarse_deg + 0.3 {
let t = deg.to_radians().tan();
let mut b = coarse_b - 3.0;
while b <= coarse_b + 3.0 {
let s = score(t, b, sign);
if s > best.0 {
best = (s, deg, b, sign);
}
b += 0.5;
}
deg += HORIZON_FINE_STEP;
}
let (s, deg) = (best.0 / norm, best.1);
// a line descending `deg` to the right is levelled by the crop angle −deg
(s >= HORIZON_MIN_SCORE && deg.abs() <= MAX_DEGREES).then_some(-deg)
}
+90 -17
View File
@@ -469,12 +469,12 @@ fn debug_upright_scene() {
}
}
/// Sign convention for Auto straighten: a horizon descending 6° to the right needs crop angle −6°,
/// Sign convention for Auto straighten: a horizon descending 4° to the right needs crop angle −4°,
/// which `level_degrees` must report (the crop panel uses it as the crop angle directly).
#[test]
fn level_degrees_sign_matches_crop_angle() {
let (w, h) = (900usize, 600usize);
let t = 6f32.to_radians().tan();
let t = 4f32.to_radians().tan();
let src = Rgb32f::from_fn(w, h, |x, y| {
let yh = h as f32 / 2.0 + (x as f32 - w as f32 / 2.0) * t;
if (y as f32) < yh {
@@ -486,7 +486,7 @@ fn level_degrees_sign_matches_crop_angle() {
}
});
let deg = crate::upright::level_degrees(&src, &SourceInfo::default(), &DevelopSettings::default()).expect("lines found");
assert!((deg + 6.0).abs() < 0.5, "{deg}");
assert!((deg + 4.0).abs() < 0.5, "{deg}");
}
/// A "horizon" of `deg` degrees: sky above, below it bands of `band` px (0: one edge only).
@@ -505,22 +505,95 @@ fn tilted_scene(deg: f32, band: f32) -> Rgb32f {
})
}
/// Auto straighten acts on several agreeing lines only: one edge (the frame of a window, a branch)
/// turned straight photos in a measurement on real ones, so it is left alone.
#[test]
fn auto_straighten_needs_more_than_one_line() {
let level = |src: &Rgb32f| crate::upright::level_degrees(src, &SourceInfo::default(), &DevelopSettings::default());
assert_eq!(level(&tilted_scene(4.0, 0.0)), None, "one edge");
let deg = level(&tilted_scene(4.0, 30.0)).expect("a horizon and the bands below it");
assert!((deg + 4.0).abs() < 0.5, "{deg}");
fn auto_level(src: &Rgb32f) -> Option<f64> {
crate::upright::level_degrees(src, &SourceInfo::default(), &DevelopSettings::default())
}
/// Auto straighten turns by at most `STRAIGHTEN_MAX_DEGREES`: larger "levels" came from texture
/// (planks, a bird's wing) on photos that were straight.
/// Thick dark lines on a light ground, each from `a` to `b` in centred coordinates (half the long
/// edge = 1).
fn line_drawing(lines: &[((f32, f32), (f32, f32))]) -> Rgb32f {
let (w, h) = (900usize, 600usize);
let half = w as f32 / 2.0;
Rgb32f::from_fn(w, h, |x, y| {
let p = ((x as f32 - w as f32 / 2.0) / half, (y as f32 - h as f32 / 2.0) / half);
let near = lines.iter().any(|&((ax, ay), (bx, by))| {
let (dx, dy) = (bx - ax, by - ay);
let t = (((p.0 - ax) * dx + (p.1 - ay) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
let (qx, qy) = (ax + t * dx - p.0, ay + t * dy - p.1);
qx.hypot(qy) * half < 3.0
});
if near { [0.05, 0.05, 0.05] } else { [0.8, 0.8, 0.8] }
})
}
/// A single short line (the frame of a window, a branch) levels nothing: it turned straight
/// photos in a measurement on real ones.
#[test]
fn auto_straighten_ignores_a_single_short_line() {
let t = 4f32.to_radians().tan();
assert_eq!(auto_level(&line_drawing(&[((-0.3, -0.3 * t), (0.3, 0.3 * t))])), None, "one short line");
// a horizon is one straight edge too, but across the whole width
let deg = auto_level(&tilted_scene(4.0, 0.0)).expect("a horizon");
assert!((deg + 4.0).abs() < 0.3, "{deg}");
}
/// Auto straighten turns by at most `straighten::MAX_DEGREES` (5°): none of 113 photos
/// straightened by hand needed more than 6.1°, and larger "levels" came from texture.
#[test]
fn auto_straighten_leaves_steep_lines_alone() {
let level = |src: &Rgb32f| crate::upright::level_degrees(src, &SourceInfo::default(), &DevelopSettings::default());
assert_eq!(level(&tilted_scene(20.0, 30.0)), None, "20° is not a horizon to level");
let deg = level(&tilted_scene(8.0, 30.0)).expect("8° is");
assert!((deg + 8.0).abs() < 0.5, "{deg}");
assert_eq!(auto_level(&tilted_scene(20.0, 30.0)), None, "20° is not a horizon to level");
assert_eq!(auto_level(&tilted_scene(8.0, 30.0)), None, "nor is 8°");
let deg = auto_level(&tilted_scene(4.0, 30.0)).expect("4° is");
assert!((deg + 4.0).abs() < 0.3, "{deg}");
}
/// A sea horizon among ripples: the short, randomly tilted edges of the water outnumber the
/// horizon, which the line consensus took for no answer (or the ripples' angle), but the horizon
/// runs across the whole width and levels the photo.
#[test]
fn auto_straighten_finds_a_sea_horizon_among_ripples() {
let (w, h) = (900usize, 600usize);
let t = 2f32.to_radians().tan();
let mut seed = 7u32;
let mut rnd = || {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(seed >> 8) as f32 / (1u32 << 24) as f32
};
// ripples: short bright dashes tilted up to ±12°
let ripples: Vec<(f32, f32, f32, f32)> =
(0..400).map(|_| (rnd() * w as f32, rnd() * h as f32, (rnd() - 0.5) * 0.4, 8.0 + rnd() * 25.0)).collect();
let src = Rgb32f::from_fn(w, h, |x, y| {
let (x, y) = (x as f32, y as f32);
let yh = h as f32 * 0.4 + (x - w as f32 / 2.0) * t;
if y < yh {
return [0.75, 0.8, 0.9];
}
let lit = ripples.iter().any(|&(rx, ry, slope, len)| {
let dx = x - rx;
dx.abs() < len && (y - ry - slope * dx).abs() < 1.2 && ry > yh + 4.0
});
if lit { [0.5, 0.55, 0.6] } else { [0.1, 0.15, 0.25] }
});
let deg = auto_level(&src).expect("the horizon");
assert!((deg + 2.0).abs() < 0.3, "{deg}");
}
/// A building shot upward and rolled 3°: its verticals converge on a vanishing point above the
/// frame. Their mean tilt depends on where in the frame they stand (here all right of centre);
/// the vanishing point gives the roll.
#[test]
fn auto_straighten_takes_the_roll_from_converging_verticals() {
let roll = 3f32.to_radians();
let vp = (6.0 * roll.sin(), -6.0 * roll.cos());
let lines: Vec<_> = (0..12)
.map(|i| {
let x = 0.05 + i as f32 * 0.07;
let bottom = (x, 0.6);
// two thirds of the way from the bottom towards the vanishing point, ending in the frame
let top_t = (0.6 + 0.6) / (0.6 - vp.1);
(bottom, (x + (vp.0 - x) * top_t, 0.6 + (vp.1 - 0.6) * top_t))
})
.collect();
let deg = auto_level(&line_drawing(&lines)).expect("the vanishing point");
assert!((deg + 3.0).abs() < 0.3, "{deg}");
}
+23 -36
View File
@@ -40,7 +40,7 @@ impl Segment {
pub fn is_empty(&self) -> bool {
self.len() == 0.0
}
fn mid(&self) -> Point {
pub(crate) fn mid(&self) -> Point {
self.a.lerp(self.b, 0.5)
}
/// Homogeneous line through the endpoints, normalized so (a, b) is a unit normal.
@@ -50,7 +50,7 @@ impl Segment {
[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 {
pub(crate) 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()
}
@@ -200,7 +200,7 @@ pub fn detect_segments(lum: &Plane) -> Vec<Segment> {
// ------------------------------------------------------------------------------------------ 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 {
pub(crate) 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);
@@ -380,7 +380,7 @@ fn level_angle(vert: &[Segment], horiz: &[Segment]) -> Option<f64> {
/// [`level_angle`] that only answers for at most `max` degrees, backed by at least `min_lines`
/// agreeing segments.
fn level_angle_within(vert: &[Segment], horiz: &[Segment], max: f64, min_lines: usize) -> Option<f64> {
pub(crate) fn level_angle_within(vert: &[Segment], horiz: &[Segment], max: f64, min_lines: usize) -> 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
@@ -547,9 +547,6 @@ const ANALYSIS_LONG: usize = 1024;
/// 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()
@@ -557,16 +554,7 @@ pub fn analyze_source(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> A
{
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));
let a = analyze(&detect_segments(&analysis_plane(src, info, s)));
if let Ok(mut c) = CACHE.lock() {
c.push((key, a.clone()));
if c.len() > 8 {
@@ -576,27 +564,26 @@ pub fn analyze_source(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> A
a
}
/// The most Auto straighten turns a photo (degrees). Measured on 68 photos straightened by hand
/// in Lightroom Classic and 300 left as shot: none was turned by more than 6°, while a larger
/// "level" came from texture (wooden planks, a branch, a bird's wing) and turned photos that were
/// straight.
pub const STRAIGHTEN_MAX_DEGREES: f64 = 10.0;
/// The fewest agreeing lines Auto straighten acts on: one line (the frame's edge, a branch) said
/// nothing about the horizon in that measurement.
pub const STRAIGHTEN_MIN_LINES: usize = 3;
/// The rotation (degrees, clockwise +) that levels `src`'s dominant horizon/plumb lines, if they agree
/// (the consensus rule of Upright Level), within [`STRAIGHTEN_MAX_DEGREES`] and backed by at least
/// [`STRAIGHTEN_MIN_LINES`] lines. Used by the crop panel's Auto straighten.
pub fn level_degrees(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> Option<f64> {
let a = analyze_source(src, info, s);
straighten_angle(&a)
/// The image an analysis reads: the lens-corrected source (optics applied, no perspective or crop)
/// at [`ANALYSIS_LONG`] px, as perceptual lightness normalized to its bright end.
pub(crate) fn analysis_plane(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> Plane {
let mut base = s.clone();
base.geometry = Geometry::default();
base.crop = Crop::default();
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);
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);
lum.map(|x| (x.max(0.0) / white).min(1.5).powf(1.0 / 2.4))
}
/// [`level_degrees`] of an analysis.
pub fn straighten_angle(a: &Analysis) -> Option<f64> {
level_angle_within(&a.vertical, &a.horizontal, STRAIGHTEN_MAX_DEGREES, STRAIGHTEN_MIN_LINES)
/// The crop angle (degrees, clockwise +) Auto straighten levels `src` by, if it finds a horizon or
/// plumb lines it can trust ([`crate::straighten`]). Used by the crop panel's Auto straighten.
pub fn level_degrees(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> Option<f64> {
crate::straighten::decide(&analysis_plane(src, info, s), &analyze_source(src, info, s))
}
/// The automatic Upright correction for `mode` on `src` (identity for Off and Guided).
+3 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (LR-CROP-AUTO: Auto straighten reads the vertical vanishing point and the horizon; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -250,7 +250,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return); on a straightened photo a moved box slides along the tilted edges to where the pointer asks, so a box that fills the photo's height still reaches its sides (issue #2299) |
| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) |
| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) |
| LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required); turns by at most 10° (`STRAIGHTEN_MAX_DEGREES`) and only on at least 3 agreeing lines (`STRAIGHTEN_MIN_LINES`), and says so when it finds nothing (`auto_straighten_says_when_it_finds_nothing`). Measured on 68 photos straightened by hand in Lightroom Classic and 300 left as shot: it levels 14 of the 68 within 0.5° (1 more than 1° off) and turns 19 of the 300 by more than 1° (before the limits: 79, some by 20° or more). 🟡 because it still leaves most tilted photos alone: a soft sea horizon among wave crests, and verticals that converge in perspective, aren't used yet |
| LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:crop.autoStraighten`, `crates/pipeline/src/straighten.rs` | crop-angle leveling from three cues, in this order: the roll from the vertical vanishing point (plumb lines, also when they converge in perspective), the horizon as the straight full-width line with brighter rows on one side (scored by its weaker columns, so waves, clouds and ridges don't make one), and the consensus of long lines (at least 3 agreeing, which also vetoes a horizon it disagrees with by more than 1°); never more than 5° (`straighten::MAX_DEGREES`); says so when it finds nothing (`auto_straighten_says_when_it_finds_nothing`). Measured on 113 photos straightened by hand in Lightroom Classic: 50 come out straighter (29 with the long lines alone), 10 more tilted (also 10 before), mean error 0.82° (1.01°). 🟡 because it still leaves about a third of tilted photos alone (36 of the 113) |
| LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) |
| LR-GUIDED-SELECT | Guide selection in Guided Upright mode | P0 | ✅ | `cmd:tool.guidedUpright`, `cmd:geometry.guides`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/state.rs` (`selected_guide`), `crates/ui-egui/src/tests_guided_guides.rs` | a press near a guide's line selects it (`selected_guide = Some((photo id, index))`; another press on it deselects), a press on an empty spot starts a new guide at that point, and an endpoint drags to reshape the guide (`Gesture::GuideEndpoint`, one undo step; clicking an endpoint only selects); tested in `tests_guided_guides` 8/8 |
| LR-GUIDED-DELETE | Delete removes the selected guide alone | P0 | ✅ | `cmd:photo.delete`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_guided_guides.rs` | with the Guided Upright tool open (`RightPanel::Crop && tool == "guidedUpright"`), Delete / Backspace removes only the selected guide and clears the selection; with nothing selected it removes nothing (the photo itself is never deleted while the tool is open); tested in `tests_guided_guides` |
@@ -932,3 +932,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-22 | minor | MENU-BAR: on macOS the native menu gives a key back to the window while a text field has focus or a shortcut is being recorded (muda's `set_accelerator(None)` does nothing there, so the menu item is now swapped for one without a key, and the key is put back on that item afterwards); status unchanged (✅) |
| 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) |
| 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone |
| 2026-10-24 | minor | LR-CROP-AUTO: Auto straighten takes the roll from the vertical vanishing point and finds the horizon as a full-width brightness edge, before the long-line consensus (which vetoes a horizon it disagrees with); cap 5°. On 113 hand-straightened photos: 50 come out straighter instead of 29, still 10 more tilted; status unchanged (🟡) |
+9
View File
@@ -1,5 +1,14 @@
# What's new in LightCraft
## 2026-10-24
### Auto straighten finds the horizon and plumb lines
- Auto straighten (Auto in the crop panel, or a double-click with the Straighten tool) now finds a sea or
landscape horizon among waves and clouds, and levels buildings by where their verticals converge, so a photo shot
upward is no longer turned by the lean of its walls. Measured on 113 photos straightened by hand in Lightroom
Classic, it straightens 50 of them instead of 29, without making more of them worse.
- It turns a photo by at most 5° (before: 10°); only two of those photos needed more.
## 2026-10-23
### Tab leaves the name field in New Album, New Smart Album and the name prompts