Skip to content

feat(crop): auto straighten from detected lines (Shift+S) - #1845

Open
lalibertemarc wants to merge 11 commits into
CyberTimon:mainfrom
lalibertemarc:feat/auto-straighten
Open

lalibertemarc wants to merge 11 commits into
CyberTimon:mainfrom
lalibertemarc:feat/auto-straighten

Conversation

@lalibertemarc

@lalibertemarc lalibertemarc commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Description

Adds a one-click auto straighten to the crop tools: a wand button next to the straighten ruler, plus a new Shift+S keybind (plain S still toggles the manual ruler). It finds the straight lines in the photo and sets the fine rotation so that vertical structures (poles, posts, door frames, building edges) and level lines (horizons, waterlines, facades facing the camera) end up straight. The crop is refit automatically, and the result goes through the normal history, so Ctrl+Z undoes it. When it can't find trustworthy lines, it shows a toast and leaves the photo alone instead of guessing.

It's also in the Productivity submenu of the right-click menus. In the editor it straightens the open photo. In the library it runs as a batch on every selected photo, like Auto Adjust and Auto Lens Correction.

This picks up the idea from #1507, which was closed because it didn't detect lines on most images. One likely cause: DynamicImage::thumbnail on an Rgb32F image adds +0.5 to every channel, so that analysis ran on a washed-out picture. This PR builds its analysis image with downscale_f32_image instead.

The approach follows darktable's rotate and perspective module (ashift): detect line segments, keep the ones that agree on a vanishing point, then fit the rotation to all of them at once.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Performance improvement
  • Code refactoring
  • Documentation update
  • UI/UX improvement
  • Build/CI or Dependency update

Changes Made

  • New Tauri command calculate_auto_straighten (src-tauri/src/auto_straighten.rs). It returns the absolute rotation to apply, or null when nothing trustworthy is found.
    • Analysis image: a 1024px image made from the loaded original with the same steps the AI masks use (build_full_warped_image, i.e. raw tone curve plus lens/transform warp), then the 90° orientation and flips. The fine rotation and crop are left out. So lens correction and transforms are taken into account, and the current rotation doesn't affect the result.
    • Line segments: LSD (Line Segment Detector) via the lsdetect crate (see Additional Notes). Segments within 30° of vertical or horizontal are kept.
    • Vanishing-point consensus per direction, as in darktable: candidate vanishing points come from pairs of the longest lines, the inlier tolerance is self-tuned, and lines that don't agree are dropped. Candidates are taken from every pair of the 60 longest lines rather than random samples, so the same photo always gives the same answer.
    • Trust rule: a direction only counts if its vanishing point is at least 5 image diagonals away. Lines that converge closer than that are perspective (rails, roads, sagging wires), not camera roll, and are ignored.
    • Fit: a weighted least-squares rotation over the trusted vertical and horizontal lines, with darktable's balancing between the two directions. Results beyond ±10° are rejected.
    • Reuses cross and unit_norm from guided_perspective.rs (now pub(crate)) instead of duplicating them.
    • New apply_orientation_and_flip in adjustment_utils.rs, used by both apply_spatial_transformations and the detector, so they share the same transform order.
  • New batch command apply_auto_straighten_to_paths (library selection).
    • Shared loop: it runs through a new update_sidecars_from_images helper, extracted from apply_auto_adjustments_to_paths, which now uses it too. The loop decodes each photo, updates its sidecar, syncs XMP and regenerates the thumbnail with progress. Auto Adjust behaves exactly as before.
    • Decoding: photos are decoded at full quality, two at a time (see Additional Notes).
    • Crop: each photo gets its rotation and a refit crop from crop_for_rotation, a Rust port of calculateAutoCropForRotation in cropUtils.ts.
  • Frontend
    • A Wand2 button in the Rotation slider label of CropPanel, with a spinner while it runs.
    • handleAutoStraighten in useEditorActions:
      • Image still loading: waits for it to finish instead of silently ignoring the press.
      • Photo changed meanwhile: discards the result.
      • Otherwise: sets rotation and refits the crop through withRotation. The manual straighten tool in Editor.tsx now uses the same helper. This way the crop is also refit when it runs from the editor's context menu with the Crop panel closed.
    • New auto_straighten keybind (default Shift+S, configurable like the others). It switches to the Crop panel if needed.
    • New isAutoStraightening flag in the editor store.
    • Productivity menu entries in the editor and library context menus (useAppContextMenus).
    • Open photo in a selection: when a filmstrip or library selection includes the photo open in the editor, that photo goes through handleAutoStraighten (applied in place and undoable, the same way pasting adjustments treats it). Only the other photos go to the batch. The batch command returns before it writes the sidecars, so otherwise the editor would reload stale adjustments and autosave would undo the batch result.
    • Shared reload: reloadAdjustmentsForPaths replaces the identical after-batch reload code in Auto Adjust and Auto Lens Correction. Auto Straighten uses it too.
  • i18n: 6 new strings (tooltip, toast for "no straight lines found", error toast, keybind label, and the editor and library menu labels with plural forms). They're translated in all 15 locales, including Czech, which was added to main after this PR was opened. The branch is rebased onto it.

Screenshots/Videos

Before :

image image

After :
image

Worked on almost all the photos I tested, though it is possible that the algo doesnt find a line (please enjoy my photo of the inside of a dumpster, litteraly trash photo) :

image

Testing

  • These changes were tested locally by a human and confirmed to work.
  • I haven't added any automated tests to the code because the codebase currently lacks a test suite.

Measured with a local harness (not committed) that runs the detector on real photos (Sony ARW, Canon CR3, Nikon NEF, Fuji RAF, DNG, JPEG), and tested in the app:

  • Accuracy against my own edits: I compared the result with 11 photos I had straightened by hand earlier.
    • Within 0.5° on 6 of them.
    • Off by more than 2° on none.
    • No result on 4: a railway, a treeline silhouette and two borderline scenes.
    • My first attempt (pixel-based edge voting, horizons only) was within 0.5° on just 1 of the 11 and confidently wrong (2–7°) on 7.
  • Speed: about 10–35 ms per photo in a release build and 55–85 ms in npm run start's dev build, on 24–33 MP raws.
  • Behaviour:
    • The button and Shift+S level photos in the app.
    • Pressing Shift+S while a raw is still loading applies the rotation once loading finishes. Before this fix, a second press was needed.
    • The Productivity entries work: the editor entry straightens the open photo, and the library entry straightens a multi-photo selection and updates the thumbnails.
    • Auto Adjust and Auto Lens Correction still work after the shared-helper refactor.
  • No new warnings or errors compared with main: tsc, ESLint, Prettier, the i18n checks (i18n:check, check-runtime.mjs, i18n:lint), clippy (--all-targets, 0 warnings) and rustfmt. ESLint actually reports 2 fewer warnings, because the shared reload removed two any casts.

Test Configuration:

  • OS: Windows 10 Home (19045)
  • Hardware: AMD Ryzen 5 3600X, NVIDIA GeForce RTX 2070

Checklist

  • My code follows the project's code style
  • I haven't added unnecessary AI-generated code comments
  • My changes generate no new warnings or errors

Additional Notes

Why a new dependency (lsdetect)

The detection step needs real line segments: endpoints, length and direction, not just "pixels that lean a certain way".

  • What I tried first: I tried without a new crate, using structure-tensor edge voting and a projection search with imageproc. Sagging power lines, foliage texture and perspective lines (rails, roads) kept winning over the real horizon. That's the 1-in-11 result above.
  • Why imageproc isn't enough: its Hough transform only has 1° angle resolution, and it has no segment detector.
  • What LSD is: a well-established, peer-reviewed algorithm. von Gioi et al., "LSD: a Line Segment Detector", IPOL 2012, https://doi.org/10.5201/ipol.2012.gjmr-lsd. darktable bundles the same algorithm for its auto rotation.
  • Why not port it: porting the reference C into RapidRAW would add over a thousand lines to review and maintain.
  • What the crate costs: lsdetect is pure Rust and small (about 1,000 lines). Its only dependency is rayon, which RapidRAW already uses. It adds one line to Cargo.toml and one package to Cargo.lock.

License notes (please double-check)

I'm not a lawyer, so I want to flag how I read the licenses:

  • lsdetect is MIT OR Apache-2.0.
    • Permissive licenses can be combined into an AGPL-3.0 program.
    • RapidRAW already depends on many crates with the same dual license (image, imageproc, rayon, serde, …).
    • MIT asks that the copyright notice travel with copies. RapidRAW doesn't currently ship per-crate notices for its other MIT/Apache dependencies, so this crate is in the same situation as those. If you ever want a third-party notices file, a tool like cargo-about could generate it for every dependency at once.
  • The LSD reference implementation is AGPL-3.0. The crate states it was written independently from the paper and contains no code from that implementation. Even if it did, AGPL-3.0 is the same license as RapidRAW.
  • darktable's ashift.c is GPL-3.0. No darktable code was copied: the approach (vanishing-point consensus and rotation fit) was reimplemented from scratch. GPL-3.0 and AGPL-3.0 are also explicitly compatible.
  • Maintenance risk, not a legal one: lsdetect is a young crate (v0.3.0) from a single author. If it were ever abandoned, it's small enough to vendor under its MIT terms.

Batch support

  • Why full decodes: the batch decodes RAWs at full quality, like the editor, rather than with the fast decode Auto Adjust uses. The fast decode comes out about 2× brighter, and that was enough to flip borderline photos: one of mine got 2.4° in the batch vs 0.0° with Shift+S. With the same decode, a photo gets the same angle from the button, Shift+S and the batch. The crop is also refit against the photo's real pixel size.
  • Memory: full decodes are heavier, so this operation runs in a 2-thread pool (FULL_DECODE_WORKER_THREADS) instead of on every core. Large selections are slower than Auto Adjust, but memory stays bounded, and the thumbnail progress bar shows where it's at.
  • The one duplicate: the crop refit math now exists in TypeScript (calculateAutoCropForRotation) and Rust (crop_for_rotation). The batch runs entirely in Rust, like the other Productivity batch actions, so it needs its own copy.
  • Undo: like the existing Auto Adjust and Lens Correction batches, the library batch writes the sidecars directly, so Ctrl+Z doesn't undo it. The button, Shift+S and the editor menu entry go through the normal history, so Ctrl+Z undoes them.

Other notes

  • Overlap with my feat(crop): apply with Enter, cancel with Escape #1781 (crop Apply/Cancel): it touches the same frontend files (CropPanel.tsx, useEditorActions.ts, useKeyboardShortcuts.ts, useEditorStore.ts). The overlap is purely additive, keep both sides. I've already resolved it locally with both branches merged, and I'll rebase whichever PR lands second.
  • Scope: this only fixes rotation. It doesn't correct converging verticals (keystone). The same line segments could later drive an automatic perspective correction through the existing Transform sliders, like darktable's fit buttons.
  • Expected "no result": photos without trustworthy straight lines (pure nature scenes, silhouettes) get the toast instead of a guess. That's intentional.
  • Squash merge suggested: the branch history includes the earlier pixel-based attempt that this PR replaces.

AI Disclaimer:

Please state the involvement of AI in this PR:

  • This PR is created by an AI agent
  • This PR is mostly AI-generated but edited/merged together by a human
  • This PR was handwritten with AI assistance (spell check, logic suggestions, error resolving)
  • This PR contains only blood, sweat, and coffee (AI-free)

🤖 Generated with Claude Code

@lalibertemarc
lalibertemarc force-pushed the feat/auto-straighten branch 3 times, most recently from c7189c7 to 6c94992 Compare October 5, 2026 17:18
lalibertemarc and others added 11 commits October 7, 2026 12:58
Adds a wand button next to the straighten ruler and a Shift+S shortcut
that levels the image from its dominant near-horizontal lines.

Detection runs in Rust on a 1024px proxy built like the AI mask input
(raw tone curve + lens/transform warp, then orientation and flips). A
structure tensor picks coherent edges, and a projection-profile search
over +/-15 degrees scores how collinear they are at each angle. Returns
nothing when no long line is found, so the UI shows a toast instead of
guessing.

The proxy uses downscale_f32_image: DynamicImage::thumbnail adds +0.5
to every channel of Rgb32F images, which washed out the earlier
attempt in CyberTimon#1507.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soft horizons such as waterlines behind branches failed the line-length
check at 1024px: twigs and grass took the strongest edges, and a soft
edge spreads over several pixel rows. When the full-resolution search
finds no confident line, search a 256px copy where fine texture blurs
away, then refine within +/-1.5 degrees at full resolution.

Results that already passed are unchanged. On a 28-photo test set this
went from 15 to 19 detections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the pixel-vote horizon detector with the approach darktable's
rotate and perspective module uses: detect line segments (LSD, via the
lsdetect crate), keep near-vertical and near-horizontal ones, drop
segments that disagree with the dominant vanishing point (RANSAC with
self-tuning tolerance), then fit the rotation to all remaining lines.

Verticals now count, so poles, posts and building edges level the
photo. A direction is only trusted when its vanishing point is at least
5 image diagonals away: converging rails or roads are perspective, not
roll. Results beyond 10 degrees are rejected.

On 11 photos straightened by hand: within 0.5 degrees on 7 (was 1),
off by more than 2 degrees on none (was 7), no answer on 2. About
10-30 ms per photo instead of ~150 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use guided_perspective's cross() and unit_norm() instead of local
copies, and drop the hand-rolled RNG: vanishing point candidates now
come from every pair of the 60 longest lines in each direction, so the
result no longer depends on random sampling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shift+S right after switching photos hit the isReady guard and was
dropped silently, so a second press was needed. Show the spinner and
wait for the full image to load instead, giving up if another photo is
selected in the meantime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add Auto Straighten to the Productivity submenu of the editor and
library context menus. In the library it runs on every selected photo.

The batch runs in Rust like Auto Adjust, sharing its sidecar and
thumbnail loop (update_sidecars_from_images). Photos are decoded at
full quality, two at a time, so each one gets the same angle as Shift+S
and its real size for refitting the crop.

The crop refit (crop_for_rotation) mirrors calculateAutoCropForRotation
in cropUtils.ts. In the frontend, withRotation now serves both the
manual straighten tool and auto straighten, and the post-batch reload
is shared by Auto Adjust, Auto Lens Correction and Auto Straighten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Share the orientation and flip step with the render pipeline through a
new apply_orientation_and_flip helper, keep the detector in f64
throughout, and name the near-zero tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Running Auto Straighten from a filmstrip thumbnail sent the photo open
in the editor to the background batch too. The batch command returns
before it writes the sidecar, so the editor reloaded the old
adjustments: only the thumbnail changed, and the next autosave put the
old rotation back.

Like pasting adjustments, the open photo now goes through the editor's
own auto straighten (applied in place, undoable) and only the other
selected photos go to the batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant