Look the lens up and say plainly whether one was found

`dr-lens` has held a complete Lensfun lookup — distortion, TCA and vignetting
coefficients from a lens name, a focal length and an aperture — with no
dependents anywhere in the workspace. The three corrections it feeds now
exist in the graph, so this connects the two and finishes the chain.

The coefficient structs stay duplicated. `dr-pipeline` is organised around
having no dependencies so its codegen is testable without a device or a
database (ARCH §6.5a), and `dr-lens` carries an XML parser and 5.5 MB of
profile data. Neither crate can convert to the other, so the conversion goes
above both, in `develop.rs`, which is the only place that sees them together.

Both traits grow the same defaulted door. The optical corrections do not sit
on the same side of the fetch — distortion and CA rewrite coordinates and are
`Warp`s, vignetting applies a gain to the pixel already there and is an
ordinary node — and fanning a profile out by which trait each happens to
implement would make the caller reason about that distinction. Each correction
takes its own share of the whole profile instead, and `set_lens_profile` walks
both lists identically.

The lookup happens in `set_source_metadata` rather than in its caller, because
that is the one place a session is told which file it came from. Doing it
there makes it unforgettable, in the shape `FilmRebake` already uses for the
other derived thing — and, more to the point, makes *clearing* unforgettable:
a session that opened a second photograph while still holding the first one's
profile would correct it for the wrong optics, invisibly, in a way that looks
exactly like the lens.

It needs the whole shot and not just a name. Distortion is interpolated across
a zoom's focal range and vignetting depends strongly on aperture — a fast
prime can be two stops down in the corners wide open and clean by f/8 — so a
lookup missing either returns coefficients measured for a shot nobody took.
Missing any of the three refuses rather than guesses.

A profile is derived, not persisted: it comes from the file's EXIF and a
database, so it is not a parameter, not in the sidecar and not undoable. What
is an edit is the manual trim beside it, which each correction composes with
the measurement — so a photographer can lean on it, override it, or work
without one.

`InfoPanel` gains a lens line, and it distinguishes three cases rather than
two. `dr-lens` states the rule it exists for: an automatic correction that
silently did nothing is worse than one the user can see is unavailable. A
session with no header draws nothing, a header naming no lens reads "Lens not
recorded", and a lens the database has never heard of reads "· no profile".
Collapsing the last two would send somebody hunting for a profile that was
never missing — which, for third-party and adapted glass, is the ordinary case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 15:11:32 +02:00
co-authored by Claude Opus 5
parent a1165ef182
commit c4ddcbe0f7
14 changed files with 525 additions and 42 deletions
+114
View File
@@ -14,6 +14,7 @@ use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
};
use crate::framing::{CropRect, Framing};
use crate::lens::LensProfile;
use crate::mask::MaskStack;
use crate::operation::{compose_full, ComposedShader, Operation};
use crate::ops;
@@ -134,6 +135,19 @@ pub struct EditGraph {
/// result — but held separately because framing also changes the output's
/// dimensions, which a warp never does.
warps: Vec<Box<dyn crate::lens::Warp>>,
/// The lens profile the corrections above were given, if any.
///
/// Kept as well as fanned out, because the corrections hold it in a form
/// nothing can read back: each has folded its own share of the profile
/// into private coefficients. Something has to be able to answer "is this
/// photograph corrected from a measurement, or by hand?" — the interface
/// is required to say so plainly rather than let an automatic correction
/// silently do nothing — and this is the only place that can.
///
/// Not in [`EditState`], not in the sidecar, and not undoable: it is
/// derived from the file's EXIF and a database, exactly as the film's
/// baked tables are derived from a stock's id.
lens_profile: Option<LensProfile>,
}
/// TRACES: FR-DEV-3f
@@ -188,6 +202,7 @@ impl EditGraph {
Box::new(crate::ops::Distortion::new()),
Box::new(crate::ops::Aberration::new()),
],
lens_profile: None,
}
}
@@ -272,6 +287,41 @@ impl EditGraph {
self.ops.iter().map(|o| o.descriptor()).collect()
}
/// TRACES: FR-DEV-3
/// Apply a lens profile's measured coefficients to every correction that
/// wants some, or clear them all with `None`.
///
/// **Derived state, not an edit.** A profile comes from the file's EXIF
/// plus a database this crate does not link, so it is not a parameter, is
/// not in the sidecar, and is not undoable. What *is* an edit is the manual
/// trim beside it: each correction composes the profile with its own
/// slider, so a photographer can lean on the measurement, override it, or
/// work without one.
///
/// **Clearing matters as much as setting.** Opening a photograph from an
/// unrecognised lens must pass `None` rather than simply not calling this:
/// a graph reused across images would otherwise correct this frame for the
/// optics of the last one, which is both wrong and invisible.
///
/// Fans out over the warps and the operations alike. Which trait a
/// correction implements is a fact about where it sits relative to the
/// fetch, and no business of the caller's — see
/// [`crate::Operation::set_lens_profile`].
pub fn set_lens_profile(&mut self, profile: Option<LensProfile>) {
self.lens_profile = profile;
for warp in &mut self.warps {
warp.set_profile(profile.as_ref());
}
for op in &mut self.ops {
op.set_lens_profile(profile.as_ref());
}
}
/// The profile currently applied, if any.
pub fn lens_profile(&self) -> Option<&LensProfile> {
self.lens_profile.as_ref()
}
/// Descriptors for the coordinate-domain lens corrections, in order.
///
/// The counterpart to [`Self::descriptors`] and split from it for the same
@@ -436,6 +486,10 @@ impl EditGraph {
// the sidecar, the clipboard and the undo stack carry them with
// nothing registered anywhere (FR-DEV-3c).
warps: _,
// Derived from the file and a database, so it is rebuilt on open
// rather than restored — the same reason the film's tables travel
// as an id and not as numbers.
lens_profile: _,
masks,
film,
spots,
@@ -973,6 +1027,66 @@ mod tests {
assert_eq!(restored.param(aberration::ID, aberration::RED), Some(25.0));
}
/// A profile has to reach all three corrections, across both traits.
///
/// The failure this guards is the quiet one: a profile that reached the
/// warps and not the vignetting node would correct the geometry and leave
/// the corners dark, which looks like an under-corrected lens rather than
/// like a wiring fault.
#[test]
fn a_lens_profile_reaches_every_correction_that_wants_one() {
use crate::lens::{LensProfile, Tca};
use crate::ops::{aberration, distortion, vignetting};
let mut g = EditGraph::default_chain();
for id in [distortion::ID, aberration::ID, vignetting::ID] {
assert_eq!(
g.param(id, ParamId("amount")).unwrap_or(0.0),
0.0,
"{id} should start neutral"
);
}
g.set_lens_profile(Some(LensProfile {
distortion: Some(distortion::PtLens {
a: 0.0,
b: -0.012,
c: 0.0,
}),
tca: Some(Tca {
red_scale: 1.000_32,
blue_scale: 0.999_93,
}),
vignetting: Some(vignetting::Pa {
k1: -0.42,
k2: 0.05,
k3: 0.0,
}),
}));
// Every correction is now doing something, with every slider still at
// its default — which is the whole point of a profile.
let source = g.compose().source;
for marker in [
"---- warp: distortion ----",
"---- warp: aberration ----",
"---- vignetting ----",
] {
assert!(
source.contains(marker),
"a profile did not reach {marker}: {source}"
);
}
// And clearing it puts the photograph back, which is what opening an
// image from an unrecognised lens has to do.
g.set_lens_profile(None);
let cleared = g.compose().source;
assert!(!cleared.contains("---- warp: "));
assert!(!cleared.contains("---- vignetting ----"));
assert!(g.lens_profile().is_none());
}
/// A warp is an edit, so `reset` has to reach it. It did not until the
/// loop was added: a reset that left the lens corrections standing would
/// mean "back to the file as it is" quietly did not mean that.