Login now persists properly rather than through the JSON file the test
harness was using.
dr-plat SecretStore trait plus a Secret Service backend.
Verified against the live GNOME Keyring: store,
retrieve, delete, confirm-gone all round-trip.
Session/SessionStore splits credentials from settings — the app
password goes to the keyring (FR-NC-2), while
server, login, chosen root and format selection are
ordinary config. A test asserts the credential never
appears in the config file.
LaunchModel the launch-screen state machine, testable without a
display server: sign in, approve in browser, choose
folder, tick formats, sign out.
launch.slint the screen itself, in its own file.
Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.
Two bugs caught by tests rather than by running it:
- fail() after busy() signed the user out, because busy() had already
discarded the session. A failed *scan* would have logged you out.
Busy now carries the session.
- normalise_server upgrades http:// to https:// rather than accepting
it. NFR-SEC-3 requires TLS, and silently sending a credential in the
clear is not a decision to make on the user's behalf.
launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.
419 tests passing across ten crates.
301 lines
12 KiB
Rust
301 lines
12 KiB
Rust
//! Lens profile lookup — the Lensfun database, reduced to coefficients.
|
|
//!
|
|
//! # What this crate is for
|
|
//!
|
|
//! `dr-pipeline` knows the *maths* of lens correction: the `ptlens`
|
|
//! polynomial, the `poly3` per-channel scale, the `pa` vignetting curve. It
|
|
//! does not know which coefficients belong to which lens, and deliberately
|
|
//! has no dependencies with which to find out.
|
|
//!
|
|
//! This crate closes that gap. Given what EXIF reports — a lens name, a focal
|
|
//! length, an aperture — it returns the coefficients for that shot, and
|
|
//! nothing else. The pipeline consumes plain `f32`s and never links the
|
|
//! database.
|
|
//!
|
|
//! # Why a separate crate rather than a module
|
|
//!
|
|
//! Two reasons, both about keeping a dependency contained:
|
|
//!
|
|
//! - **`dr-pipeline` has no dependencies on purpose.** Its codegen is testable
|
|
//! without a GPU, and adding an XML parser plus 5.5 MB of profile data to it
|
|
//! would cost exactly the property it is organised around (ARCH §6.5a).
|
|
//! - **The `lensfun` crate is a third-party port**, not upstream Lensfun.
|
|
//! Confining it behind [`LensProfile`] means replacing it — with the C
|
|
//! library, with our own parser, with vendor-supplied profiles — touches
|
|
//! this crate and nothing downstream.
|
|
//!
|
|
//! # Matching is best-effort, and says so
|
|
//!
|
|
//! EXIF lens names are not clean identifiers. Different bodies report the same
|
|
//! lens differently, third-party lenses often report nothing, and adapted
|
|
//! manual lenses report nothing at all. So every lookup returns an `Option`,
|
|
//! and the UI is expected to say plainly whether a profile was found — an
|
|
//! automatic correction that silently did nothing is worse than one the user
|
|
//! can see is unavailable (ARCH §9.4 applies the same honesty rule to sync).
|
|
|
|
use std::sync::OnceLock;
|
|
|
|
/// The `ptlens` distortion coefficients, as Lensfun stores them.
|
|
///
|
|
/// Mirrors `dr_pipeline::ops::distortion::PtLens`. Duplicated rather than
|
|
/// shared because the dependency would have to run the wrong way: the
|
|
/// pipeline must not depend on the profile database.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct Distortion {
|
|
pub a: f32,
|
|
pub b: f32,
|
|
pub c: f32,
|
|
}
|
|
|
|
/// Lateral chromatic aberration: a per-channel radial scale.
|
|
///
|
|
/// Red and blue are scaled about the optical axis; green is the reference and
|
|
/// is never moved, so a correction that is wrong still leaves the image
|
|
/// sharp in one channel rather than softening all three.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct Tca {
|
|
pub red_scale: f32,
|
|
pub blue_scale: f32,
|
|
}
|
|
|
|
/// The `pa` vignetting polynomial: `1 + k1·r² + k2·r⁴ + k3·r⁶`.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct Vignetting {
|
|
pub k1: f32,
|
|
pub k2: f32,
|
|
pub k3: f32,
|
|
}
|
|
|
|
/// Everything known about one lens at one set of shooting parameters.
|
|
///
|
|
/// Each field is independently optional: the Lensfun database frequently
|
|
/// carries distortion for a lens but no vignetting, or covers only part of a
|
|
/// zoom's range. A partial profile is useful and must not be discarded.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
|
pub struct LensProfile {
|
|
pub distortion: Option<Distortion>,
|
|
pub tca: Option<Tca>,
|
|
pub vignetting: Option<Vignetting>,
|
|
}
|
|
|
|
impl LensProfile {
|
|
/// Whether this profile carries anything at all.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.distortion.is_none() && self.tca.is_none() && self.vignetting.is_none()
|
|
}
|
|
}
|
|
|
|
/// What EXIF reports about a shot, as far as lens correction cares.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct ShotInfo<'a> {
|
|
/// The lens model string. Frequently absent or unhelpful.
|
|
pub lens: &'a str,
|
|
/// Focal length in mm. Selects between a zoom's calibration points.
|
|
pub focal_length: f32,
|
|
/// Aperture as an f-number. Vignetting depends on it strongly — a lens
|
|
/// wide open can be two stops down in the corners and clean by f/8.
|
|
pub aperture: f32,
|
|
/// Focus distance in metres, when known. Vignetting varies with it, but
|
|
/// EXIF rarely reports it, so the default stands in for "far away".
|
|
pub distance: f32,
|
|
}
|
|
|
|
impl<'a> ShotInfo<'a> {
|
|
/// The distance Lensfun's calibrations use for "not a close-up".
|
|
///
|
|
/// Most database entries are measured at 1000 m — effectively infinity —
|
|
/// and EXIF almost never carries focus distance, so this is the value
|
|
/// nearly every lookup uses.
|
|
pub const FAR: f32 = 1000.0;
|
|
|
|
pub fn new(lens: &'a str, focal_length: f32, aperture: f32) -> Self {
|
|
Self {
|
|
lens,
|
|
focal_length,
|
|
aperture,
|
|
distance: Self::FAR,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The bundled Lensfun database, loaded once on first use.
|
|
///
|
|
/// Decompressing 56 XML files costs enough to be worth doing once, and little
|
|
/// enough not to be worth doing in the background. `OnceLock` rather than a
|
|
/// constructor the callers must thread through: this is a read-only reference
|
|
/// table, and making every call site own a handle to it would buy nothing.
|
|
fn database() -> Option<&'static lensfun::Database> {
|
|
static DB: OnceLock<Option<lensfun::Database>> = OnceLock::new();
|
|
DB.get_or_init(|| match lensfun::Database::load_bundled() {
|
|
Ok(db) => Some(db),
|
|
Err(e) => {
|
|
// Not fatal. Manual correction still works, so the develop panel
|
|
// stays usable with the automatic profile absent.
|
|
log::warn!("lens profile database unavailable: {e}");
|
|
None
|
|
}
|
|
})
|
|
.as_ref()
|
|
}
|
|
|
|
/// Look up correction coefficients for a shot.
|
|
///
|
|
/// Returns `None` when the lens is unknown — which is common and not an
|
|
/// error. A blank lens name short-circuits, because matching on one returns
|
|
/// arbitrary entries rather than no entries.
|
|
pub fn lookup(shot: &ShotInfo<'_>) -> Option<LensProfile> {
|
|
if shot.lens.trim().is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let db = database()?;
|
|
let matches = db.find_lenses(None, shot.lens);
|
|
// The database returns candidates ranked by match quality; anything
|
|
// beyond the best is a different lens that merely reads similarly.
|
|
let lens = matches.first()?;
|
|
|
|
let profile = LensProfile {
|
|
distortion: lens
|
|
.interpolate_distortion(shot.focal_length)
|
|
.and_then(|d| match d.model {
|
|
lensfun::DistortionModel::Ptlens { a, b, c } => Some(Distortion { a, b, c }),
|
|
// Other models exist in the database (`poly3`, `fov1`). Rather
|
|
// than approximate one with another — which would correct by
|
|
// the wrong curve and look like a bad profile — report nothing
|
|
// and let the manual control take over.
|
|
_ => None,
|
|
}),
|
|
tca: lens
|
|
.interpolate_tca(shot.focal_length)
|
|
.and_then(|t| match t.model {
|
|
lensfun::TcaModel::Poly3 { red, blue } => Some(Tca {
|
|
// Index 0 is the linear radial scale `v`, which carries
|
|
// essentially all of the correction; the higher terms are
|
|
// zero throughout the database in practice.
|
|
red_scale: red[0],
|
|
blue_scale: blue[0],
|
|
}),
|
|
_ => None,
|
|
}),
|
|
vignetting: lens
|
|
.interpolate_vignetting(shot.focal_length, shot.aperture, shot.distance)
|
|
.and_then(|v| match v.model {
|
|
lensfun::VignettingModel::Pa { k1, k2, k3 } => Some(Vignetting { k1, k2, k3 }),
|
|
_ => None,
|
|
}),
|
|
};
|
|
|
|
// A match that yielded no usable coefficients is the same as no match, and
|
|
// reporting it as a hit would tell the user a correction is active when
|
|
// nothing is being corrected.
|
|
(!profile.is_empty()).then_some(profile)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A lens with dense calibration across a zoom range, so interpolation is
|
|
/// genuinely exercised.
|
|
const KNOWN: &str = "Canon EF 16-35mm f/2.8L USM";
|
|
|
|
#[test]
|
|
fn the_bundled_database_loads() {
|
|
// The property that makes this crate work on Android: no system
|
|
// library, no data directory, no filesystem path (ARCH §6.9).
|
|
assert!(database().is_some(), "the bundled database must load");
|
|
}
|
|
|
|
#[test]
|
|
fn a_known_lens_resolves() {
|
|
let profile = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).expect("a stocked lens");
|
|
assert!(profile.distortion.is_some(), "distortion expected");
|
|
assert!(!profile.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_lens_is_none_rather_than_a_panic() {
|
|
// Adapted and third-party lenses report names absent from the
|
|
// database. That is the normal case, not an error.
|
|
assert!(lookup(&ShotInfo::new("Nonexistent 999mm f/0.5", 50.0, 2.0)).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_blank_lens_name_does_not_match_arbitrarily() {
|
|
// EXIF often carries no lens at all. Matching on an empty string
|
|
// returns unrelated entries, which would silently apply another
|
|
// lens's correction — worse than applying none.
|
|
for name in ["", " "] {
|
|
assert!(
|
|
lookup(&ShotInfo::new(name, 50.0, 2.0)).is_none(),
|
|
"{name:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn coefficients_interpolate_between_calibration_points() {
|
|
// 20mm and 22mm are calibrated; 21mm is not. If interpolation were
|
|
// absent, a zoom would snap between corrections mid-range.
|
|
let at20 = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.distortion);
|
|
let at21 = lookup(&ShotInfo::new(KNOWN, 21.0, 2.8)).and_then(|p| p.distortion);
|
|
let at22 = lookup(&ShotInfo::new(KNOWN, 22.0, 2.8)).and_then(|p| p.distortion);
|
|
|
|
let (a, b, c) = (at20.unwrap(), at21.unwrap(), at22.unwrap());
|
|
assert_ne!(a, b, "21mm must not reuse the 20mm coefficients verbatim");
|
|
let between = (a.a.min(c.a)..=a.a.max(c.a)).contains(&b.a);
|
|
assert!(
|
|
between,
|
|
"21mm coefficient {} is outside [{}, {}]",
|
|
b.a, a.a, c.a
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vignetting_responds_to_aperture() {
|
|
// The reason aperture is in `ShotInfo` at all: a lens wide open
|
|
// vignettes heavily and is clean stopped down, so a correction that
|
|
// ignored aperture would be wrong at both ends.
|
|
let wide = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.vignetting);
|
|
let stopped = lookup(&ShotInfo::new(KNOWN, 20.0, 8.0)).and_then(|p| p.vignetting);
|
|
|
|
if let (Some(w), Some(s)) = (wide, stopped) {
|
|
assert_ne!(w, s, "aperture must change the vignetting curve");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tca_leaves_green_as_the_reference() {
|
|
// Green is never scaled, so red and blue are corrected towards it.
|
|
// Both scales sit within a fraction of a percent of 1.0; anything
|
|
// far from that would be a unit error rather than a real correction.
|
|
if let Some(tca) = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.tca) {
|
|
for s in [tca.red_scale, tca.blue_scale] {
|
|
assert!(
|
|
(0.99..=1.01).contains(&s),
|
|
"{s} is not a plausible per-channel scale"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_far_focus_distance_is_the_default() {
|
|
// EXIF rarely carries focus distance, so nearly every real lookup
|
|
// relies on this standing in.
|
|
assert_eq!(ShotInfo::new(KNOWN, 20.0, 2.8).distance, ShotInfo::FAR);
|
|
}
|
|
|
|
#[test]
|
|
fn repeated_lookups_reuse_one_database() {
|
|
// The database is decompressed on first use; a second lookup must not
|
|
// pay for it again.
|
|
assert!(lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).is_some());
|
|
assert!(lookup(&ShotInfo::new(KNOWN, 24.0, 4.0)).is_some());
|
|
assert!(std::ptr::eq(
|
|
database().expect("loaded"),
|
|
database().expect("loaded")
|
|
));
|
|
}
|
|
}
|