The decoding half of camera-profiles.md §11-§12. RawImage gains baseline_exposure: the file's BaselineExposure plus the chosen profile's BaselineExposureOffset, as the DNG SDK sums them (+0.25 for the library's 6D DNGs). A profile copied out of a DNG carries that DNG's baseline as its offset, so the body's CR2s, which have none, land at the same total. ProfileTables gains the profile's ProfileToneCurve, resampled at decode onto 1025 points with a natural cubic spline; an identity curve counts as none. dr-types now holds Camera Raw's ACR3 default curve, RawTherapee's adobe_camera_raw_default_curve copied value for value, for every raw whose profile has no curve. Nothing renders through either yet.
1037 lines
48 KiB
Rust
1037 lines
48 KiB
Rust
//! The develop session's identity, lifecycle and file metadata.
|
||
//!
|
||
//! `DevelopSession` itself, opening a photograph, and the small set of
|
||
//! whole-session queries (settings, metadata) that do not belong to any of
|
||
//! the more specific areas split out alongside this one.
|
||
use dr_decode::RawImage;
|
||
use dr_gpu::{
|
||
AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext,
|
||
HistogramPass, MaskPass, RawHistogram, RawHistogramPass,
|
||
};
|
||
use dr_pipeline::{CropRect, Edit, EditGraph, History, Preset, Scope};
|
||
use std::sync::Arc;
|
||
|
||
use crate::labels;
|
||
use crate::segmentation::Segmentation;
|
||
|
||
use super::masks::MaskView;
|
||
use super::segmentation::SessionId;
|
||
|
||
pub struct DevelopSession {
|
||
/// This session's name, for work that outlives the frame it started on.
|
||
pub(super) id: SessionId,
|
||
/// TRACES: FR-CULL-10
|
||
/// Confirmed faces in this photograph, normalised to the long edge.
|
||
///
|
||
/// Empty until [`DevelopSession::set_face_names`] is called, and empty for
|
||
/// ever on a library with no face indexing — in which case segmentation
|
||
/// behaves exactly as it did before, which is the point.
|
||
pub(super) face_names: Vec<crate::identity::NormalisedNamedBox>,
|
||
/// How this photograph is stored relative to how it is shown.
|
||
///
|
||
/// Kept because the segmentation proxy is rendered through a *neutral*
|
||
/// graph and is therefore in sensor order, while faces were found on the
|
||
/// upright thumbnail. On anything shot in portrait the two differ by a
|
||
/// quarter turn, and matching them without undoing it finds nothing.
|
||
pub(super) orientation: dr_types::Orientation,
|
||
/// TRACES: FR-EXP-8
|
||
/// What the file these pixels came from said about itself.
|
||
///
|
||
/// A session is one photograph, and this is that photograph's header: the
|
||
/// body, the lens, the moment the shutter fired, the rights statement.
|
||
/// Nothing in develop reads it. It is remembered so that an export made
|
||
/// from the open image can disclose the same things an export of the same
|
||
/// file from the grid does, and so `{date}` can mean the capture date on
|
||
/// both paths rather than nothing on one of them.
|
||
///
|
||
/// **Why here rather than beside the frame on `export::Source::Rendered`.**
|
||
/// Hanging it on the export request would work and would touch less of
|
||
/// this file, but the header would then have to be held somewhere in the
|
||
/// interface *alongside* the session and paired with it at export time —
|
||
/// two cells to keep in step across the six places a photograph is opened,
|
||
/// replaced or fails to open. The failure mode of getting that pairing
|
||
/// wrong is not a missing tag: it is one photograph exported under
|
||
/// another's byline and coordinates, silently. Kept here, the header
|
||
/// arrives with the pixels it belongs to or not at all, and there is no
|
||
/// pairing left to break.
|
||
///
|
||
/// It is the *decoded header* and not a `dr_export::SourceMetadata`, which
|
||
/// matters: what an export may disclose is a decision taken per export
|
||
/// from the settings, inside `dr-export` — see that crate's note on why
|
||
/// source metadata is a parameter and not a field on `Frame`. This is only
|
||
/// the memory of where the pixels came from; the allowlist that turns it
|
||
/// into something writable stays the one function in `export.rs`.
|
||
pub(super) source_meta: Option<dr_decode::Metadata>,
|
||
/// Whether the lens in the header matched a profile in the database.
|
||
///
|
||
/// A separate flag rather than `graph.lens_profile().is_some()`, because
|
||
/// the two answer different questions once the photographer starts work:
|
||
/// the graph says what is *applied*, which a manual correction also
|
||
/// satisfies, and this says whether a *measurement* was found. Only the
|
||
/// second can honestly caption "no profile".
|
||
pub(super) lens_profile_found: bool,
|
||
/// TRACES: FR-DEV-3e
|
||
/// The camera profile embedded in the file, where its embed policy lets
|
||
/// it be copied and no installed profile already covers the body: what
|
||
/// the info panel offers to save for every photograph from that camera
|
||
/// (D20). `None` once taken up.
|
||
pub(super) profile_offer: Option<Arc<dr_decode::dcp::Dcp>>,
|
||
/// Kept so the session can build GPU resources after construction.
|
||
///
|
||
/// The distance fields behind a subject mask are made when a layer is
|
||
/// *shaped*, not when the image opens, and cloning a `GpuContext` is two
|
||
/// `Arc` bumps.
|
||
pub(super) ctx: GpuContext,
|
||
pub(super) graph: EditGraph,
|
||
/// TRACES: FR-DEV-5
|
||
/// Undo, kept beside the graph rather than in the window.
|
||
///
|
||
/// Every mutator below records into it, so a caller cannot change the edit
|
||
/// and forget to. That is the whole reason it lives here: the callbacks in
|
||
/// `lib.rs` are generic by construction and there are a dozen of them, and
|
||
/// a history the *call sites* had to remember would be one press of undo
|
||
/// away from wrong every time a control is added.
|
||
pub(super) history: History,
|
||
/// TRACES: FR-DEV-5
|
||
/// The named snapshots of this edit, as the sidecar had them plus what
|
||
/// this sitting took, minus what it deleted.
|
||
///
|
||
/// Beside the history rather than inside it, because they answer a
|
||
/// different question. The history is what was done in this sitting and
|
||
/// is deliberately forgotten with it; a snapshot is a state the
|
||
/// photographer *named*, which is the act of saying it should outlive
|
||
/// the sitting. It is persisted as a version of the sidecar pointing at
|
||
/// this one (`Version::snapshot_of`), which is what makes it survive a
|
||
/// restart and reach the other device.
|
||
pub(super) snapshots: Vec<dr_pipeline::Version>,
|
||
/// The ids of snapshots deleted this sitting, so the save can remove
|
||
/// them from the file without removing what another device added since
|
||
/// — see `Sidecar::replace_snapshots`.
|
||
pub(super) removed_snapshots: Vec<String>,
|
||
/// TRACES: FR-DEV-7
|
||
/// The snapshot the canvas is showing instead of the edit, while a
|
||
/// comparison is held. Viewing state: nothing about the edit changes,
|
||
/// and it goes down with the session.
|
||
pub(super) compared_snapshot: Option<String>,
|
||
/// TRACES: FR-DEV-17
|
||
/// The layers the last committed crop took out of the frame, while the
|
||
/// history still stands on that crop. See [`super::framing::CropNotice`].
|
||
pub(super) crop_notice: Option<super::framing::CropNotice>,
|
||
pub(super) demosaiced: Arc<DemosaicedImage>,
|
||
/// TRACES: FR-DEV-3g
|
||
/// The learned demosaic: the kept mosaic, the job, the result. See
|
||
/// [`super::denoise`].
|
||
pub(super) denoise: super::denoise::DenoiseState,
|
||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||
/// The photograph at full resolution, when it is too large to hold in one
|
||
/// texture. `demosaiced` is then a reduced copy of it, which is all the
|
||
/// canvas needs at fit and all the histograms, masks and probes ever
|
||
/// read; a render that wants more detail than the copy has — the canvas
|
||
/// zoomed in, a tile of the export — cuts a window from this instead
|
||
/// (see `source_for`). `None` for every photograph that fits, which is
|
||
/// nearly all of them.
|
||
pub(super) full: Option<Arc<RawImage>>,
|
||
/// The last window cut from `full`: the region it covers in normalised
|
||
/// source coordinates, the reduction it was cut at, and the texture. Kept
|
||
/// so panning a zoomed canvas does not re-upload what is already there.
|
||
pub(super) window: Option<(CropRect, u32, Arc<DemosaicedImage>)>,
|
||
pub(super) adjust: AdjustPass,
|
||
/// TRACES: FR-DSP-7
|
||
/// Optional, because a session that cannot count its frames is still a
|
||
/// session that can develop them. If the reduction fails to build — an
|
||
/// old driver, a device without the storage-buffer atomics it needs — the
|
||
/// photographer loses the histogram and keeps the photograph.
|
||
pub(super) histogram: Option<HistogramPass>,
|
||
/// TRACES: FR-CULL-3
|
||
/// The raw-domain reduction, on the same terms as the display one above:
|
||
/// optional, because a session that cannot count the sensor data is still
|
||
/// a session that can develop it.
|
||
pub(super) raw_histogram: Option<RawHistogramPass>,
|
||
/// The raw reading, once taken.
|
||
///
|
||
/// **Cached, where the display histogram is recomputed every settled
|
||
/// frame, and the difference is not an optimisation.** This measures the
|
||
/// demosaiced source, which nothing downstream of the demosaic can change:
|
||
/// no slider, no crop, no zoom, no output space moves a single count in
|
||
/// it. Recomputing it per frame would be a dispatch and a device sync
|
||
/// point spent to arrive back at the number already held — and on the
|
||
/// culling pass FR-CULL-3 is written for, that is a cost paid three
|
||
/// thousand times over.
|
||
///
|
||
/// `None` until first asked for, and it stays `None` on a file with no
|
||
/// sensor data behind it. The session is one photograph and the demosaiced
|
||
/// source is fixed for its life, so there is no invalidation to get wrong.
|
||
pub(super) raw_counts: Option<RawHistogram>,
|
||
/// TRACES: FR-CULL-3
|
||
/// The focus-peaking overlay, on the same terms as the histogram above:
|
||
/// optional, because a device that cannot compile the pass is still a
|
||
/// device that can develop the photograph. What is lost is an instrument,
|
||
/// not the picture.
|
||
pub(super) peak: Option<FocusPeakPass>,
|
||
/// TRACES: FR-CULL-3
|
||
/// What the photographer asked the overlay to look like, or `None` for
|
||
/// off.
|
||
///
|
||
/// **Interface state, not part of the edit** — the same category as
|
||
/// `show_overlay` beside it. It changes no pixel of the photograph, it is
|
||
/// not in the sidecar, and it is not on the undo stack: pressing undo
|
||
/// after switching peaking on should take back the last *edit*, not the
|
||
/// last thing looked at.
|
||
///
|
||
/// An `Option` rather than a bool plus a settings field, so that "off" and
|
||
/// "on, in some configuration" cannot disagree with each other.
|
||
pub(super) peaking: Option<FocusPeaking>,
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// The region map local masks select from, once it has been computed.
|
||
///
|
||
/// `None` until the photographer asks for it. Segmentation costs about
|
||
/// half a second and most edits never need one, so running it on open
|
||
/// would tax every photograph for a feature used on some of them.
|
||
pub(super) segmentation: Option<Segmentation>,
|
||
/// Rasterises the mask layers. Built lazily for the same reason.
|
||
pub(super) masks: Option<MaskPass>,
|
||
/// One signed distance field per active subject layer, on the GPU.
|
||
pub(super) subjects: Option<dr_gpu::SubjectMasks>,
|
||
/// What `subjects` was built from.
|
||
///
|
||
/// The fields are expensive — an exact distance transform over the proxy
|
||
/// for each layer — and almost nothing changes them. Feather, falloff and
|
||
/// simple growing are arithmetic the shader does on the field it already
|
||
/// has, so this deliberately does *not* include them: dragging those
|
||
/// sliders must not rebuild anything.
|
||
pub(super) subject_key: u64,
|
||
/// Which layers the develop panel is editing, if any.
|
||
///
|
||
/// This is what lets one panel serve both scopes: with layers selected,
|
||
/// the sliders read and write *their* chains, and the photographer is
|
||
/// adjusting one or more regions rather than the frame.
|
||
///
|
||
/// A plain click replaces this outright; a modifier-click toggles one id
|
||
/// in or out, so several layers can be shaped by the same slider drag —
|
||
/// "make these three subjects a stop darker" is one gesture rather than
|
||
/// three. Order is insertion order and nothing reads it, only membership.
|
||
pub(super) active_masks: Vec<String>,
|
||
/// TRACES: FR-DEV-19a
|
||
/// Which part of the selected layer the tools and the edge controls point
|
||
/// at. Zero — the base — whenever a layer is selected afresh.
|
||
///
|
||
/// An index rather than an id, because it addresses a row the panel is
|
||
/// already showing by position, and because a gesture that outlived the
|
||
/// part it was aimed at would be a stroke landing somewhere nobody asked
|
||
/// for. Clamped on the way in and re-checked on the way out.
|
||
pub(super) active_part: usize,
|
||
/// TRACES: FR-DEV-19b
|
||
/// The layer and part a stroke in progress is going into.
|
||
///
|
||
/// Captured on press and held for the gesture: the panel's selection can
|
||
/// change under a finger — a stray tap, a sync arriving — and a stroke
|
||
/// that changed target half way through would leave half a mark in each.
|
||
pub(super) painting: Option<(String, usize)>,
|
||
/// TRACES: FR-DEV-19b
|
||
/// The brush: radius as a fraction of the frame's shorter edge, hardness,
|
||
/// and flow.
|
||
///
|
||
/// On the session rather than on a layer, because it belongs to the
|
||
/// *tool*: somebody who sets a small eraser expects it to still be small
|
||
/// the next time they erase, whichever mask they are working on.
|
||
pub(super) brush: (f32, f32, f32),
|
||
/// TRACES: FR-DEV-8
|
||
/// Which repair the panel is describing, if any.
|
||
///
|
||
/// Interface state and not part of the edit, exactly as `active_masks` is:
|
||
/// it changes no pixel, it is not in the sidecar, and it is not on the undo
|
||
/// stack. One at a time rather than a set — a repair is eight numbers and
|
||
/// there is no gesture that usefully moves several at once, where three
|
||
/// masked layers really can share a slider drag.
|
||
pub(super) selected_spot: Option<String>,
|
||
/// Whether to draw the false-coloured region overlay.
|
||
pub(super) show_overlay: bool,
|
||
/// TRACES: FR-DEV-19c
|
||
/// How shown masks are drawn — one style for all of them.
|
||
///
|
||
/// Interface state, like `show_overlay` beside it and `active_masks` above
|
||
/// — it changes no pixel of the photograph, it is not in the sidecar and
|
||
/// it is not on the undo stack. It reaches the pipeline as an argument to
|
||
/// the one composition that draws the canvas, which is what makes an
|
||
/// export structurally unable to carry it (`EditGraph::compose_revealing`).
|
||
pub(super) reveal_style: dr_pipeline::mask::RevealStyle,
|
||
/// TRACES: FR-DEV-19c
|
||
/// Per layer: whether its mask is shown, and in what colour.
|
||
///
|
||
/// Per layer rather than "the selected one", because the question a
|
||
/// photographer asks of two masks is how they meet — where the sky's edge
|
||
/// sits against the building's — and that needs both on screen at once,
|
||
/// in colours that can be told apart. Keyed by id, and an id that is no
|
||
/// longer in the stack is simply never asked for; `reveal` walks the
|
||
/// stack, not this map.
|
||
///
|
||
/// Viewing state and not edit state, for the reason the style is: it does
|
||
/// not travel in a sidecar, so a photograph reopened has every eye closed.
|
||
pub(super) mask_views: std::collections::HashMap<String, MaskView>,
|
||
/// Which attribute the panel is filtered to, or all of them.
|
||
///
|
||
/// `None` is "show everything" and is what a frontend that ignores
|
||
/// attributes leaves it at — the tabs are the interface's idea, not the
|
||
/// core's, and nothing breaks without them (ARCH §4.3a).
|
||
pub(super) active_tab: Option<dr_pipeline::Attribute>,
|
||
/// TRACES: FR-DEV-3
|
||
/// Which of the curve widget's subjects the panel is plotting.
|
||
///
|
||
/// The tone curve is four curves — one over tone and one per colour
|
||
/// channel — and one square plot draws one of them at a time. The index
|
||
/// is into the subjects the operation's parameters are faceted on, in the
|
||
/// order it declares them, so nothing here knows that "red" exists.
|
||
///
|
||
/// **Interface state, not part of the edit.** It changes no pixel, so it
|
||
/// is not a parameter, it is not in the graph, it is not in the sidecar
|
||
/// and it is not on the undo stack — the same standing as which tab is
|
||
/// open. One value rather than one per operation, for the same reason
|
||
/// `curve_samples` is one polyline: the panel draws one curve.
|
||
pub(super) curve_channel: usize,
|
||
/// TRACES: FR-DSP-8
|
||
/// The space the canvas is encoded into, for the display now showing it.
|
||
///
|
||
/// **Not part of the edit, and not interface state either.** It is a fact
|
||
/// about the glass in front of the photographer: the same graph on the
|
||
/// same file composes differently on a wide-gamut second monitor, and
|
||
/// neither the sidecar nor the undo stack has any business knowing about
|
||
/// it. That is also why it lives here rather than on the `EditGraph` —
|
||
/// `compose_for` deliberately takes the space per call because "the same
|
||
/// edit goes to the screen in the display's space and to a file in
|
||
/// whatever the export asks for, and neither is more authoritative".
|
||
///
|
||
/// sRGB until the application says otherwise, which is the same answer
|
||
/// `dr_plat::display`'s fallback gives and means a session constructed in
|
||
/// a test behaves exactly as it did before this existed.
|
||
/// TRACES: FR-DEV-3
|
||
/// What the straightening auto-crop last wrote, and what it was derived
|
||
/// from: `(applied, intended)`.
|
||
///
|
||
/// **The graph holds the corrected rectangle; this remembers the intent
|
||
/// behind it.** `auto_crop_to_angle` pulls the crop inside the area an
|
||
/// angle leaves defined, and that operation can only ever shrink. Applied
|
||
/// to its own output it ratchets — straighten to 20 degrees, come back to
|
||
/// 3, and the crop stays at the size 20 degrees demanded, which is not
|
||
/// what turning the slider back means. So the correction is never
|
||
/// accumulated: it is recomputed from the intent every time, and as the
|
||
/// angle falls the crop grows back and stops exactly where the user put
|
||
/// it. At zero degrees the safe area is the whole frame and the two are
|
||
/// equal again.
|
||
///
|
||
/// **A pair rather than a single remembered rectangle, so it repairs
|
||
/// itself.** Every other route to the crop — a handle dragged, a ratio
|
||
/// chosen, a sidecar loaded, a paste, an undo — leaves the graph holding
|
||
/// something other than `applied`, and that mismatch is exactly the signal
|
||
/// that the remembered intent is stale. [`Self::intended_crop`] checks it
|
||
/// rather than requiring each of those paths to remember to write here,
|
||
/// which is the kind of bookkeeping that is correct until someone adds a
|
||
/// seventh path.
|
||
///
|
||
/// Session-scoped. A sidecar records the crop that was *applied*, because
|
||
/// that is the one that describes the photograph, so reopening starts from
|
||
/// that rectangle as its own intent.
|
||
pub(super) auto_crop: Option<(CropRect, CropRect)>,
|
||
pub(super) display_space: dr_types::ColourSpace,
|
||
}
|
||
|
||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||
/// The longest edge a photograph is developed at whole; one larger is
|
||
/// developed from a reduced copy and full-resolution windows.
|
||
///
|
||
/// Below the device's own limit on purpose. A 16384-texel texture of
|
||
/// half floats is two gigabytes, and the canvas never shows more than a few
|
||
/// thousand pixels of it; 8192 is four times a 4K long edge, the same bound
|
||
/// the JPEG path fits a film scan to.
|
||
pub const PROXY_EDGE: u32 = 8192;
|
||
|
||
impl DevelopSession {
|
||
/// Demosaic an image and prepare its edit graph.
|
||
///
|
||
/// `orientation` is the file's EXIF orientation, not an edit: a sensor is
|
||
/// scanned the same way whichever way the body was held, so this is what
|
||
/// makes a portrait frame open upright. It is fixed for the life of the
|
||
/// session and survives a reset.
|
||
pub fn open(
|
||
ctx: &GpuContext,
|
||
raw: &RawImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
||
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
|
||
Ok(Self::with_source(ctx, demosaiced, orientation))
|
||
}
|
||
|
||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||
/// Open a decoded photograph, whatever its size.
|
||
///
|
||
/// A photograph that fits the device goes through [`Self::open`] as it
|
||
/// always did. A linear DNG larger than [`PROXY_EDGE`] — a stitched
|
||
/// panorama — is instead held at full resolution on the CPU and opened
|
||
/// on a copy reduced to fit, from which the canvas, the histograms and
|
||
/// the masks work; the full resolution is cut into windows only where a
|
||
/// render needs it. Without this the file opened as its embedded preview
|
||
/// with develop withheld.
|
||
pub fn open_owned(
|
||
ctx: &GpuContext,
|
||
raw: RawImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let (w, h) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||
let edge = PROXY_EDGE.min(DemosaicedImage::max_dimension(ctx));
|
||
if raw.samples_per_pixel != 3 || w.max(h) <= edge {
|
||
let mut session = Self::open(ctx, &raw, orientation)?;
|
||
// TRACES: FR-DEV-3g
|
||
// The mosaic stays with the session when the learned demosaic
|
||
// could take it, so asking for it later needs no second decode.
|
||
session.keep_mosaic(raw);
|
||
return Ok(session);
|
||
}
|
||
let reduce = w.max(h).div_ceil(edge);
|
||
log::info!("{w}×{h} is larger than one texture; developing from a 1/{reduce} copy");
|
||
let proxy = DemosaicedImage::linear_rgb16_window(ctx, &raw, [0, 0, w, h], reduce)
|
||
.map_err(|e| e.to_string())?;
|
||
let mut session = Self::with_source(ctx, proxy, orientation);
|
||
session.full = Some(Arc::new(raw));
|
||
Ok(session)
|
||
}
|
||
|
||
/// Prepare an edit graph over an already-processed RGB image.
|
||
///
|
||
/// The JPEG path. A JPEG is already demosaiced, so there is no sensor
|
||
/// stage to run — but everything after it is identical, which is why this
|
||
/// shares [`Self::with_source`] rather than duplicating the session.
|
||
///
|
||
/// Worth being honest about what this cannot recover: an 8-bit JPEG has
|
||
/// clipped highlights and quantised shadows that no edit brings back, so
|
||
/// exposure has far less latitude here than on sensor data. The controls
|
||
/// are the same controls; the file simply carries less to work with.
|
||
pub fn open_rgb(
|
||
ctx: &GpuContext,
|
||
rgba: &[u8],
|
||
width: u32,
|
||
height: u32,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let source =
|
||
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?;
|
||
Ok(Self::with_source(ctx, source, orientation))
|
||
}
|
||
|
||
pub(super) fn with_source(
|
||
ctx: &GpuContext,
|
||
demosaiced: DemosaicedImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Self {
|
||
let mut graph = EditGraph::default_chain();
|
||
graph.set_orientation(orientation);
|
||
let history = History::new(&graph);
|
||
Self {
|
||
id: SessionId::next(),
|
||
face_names: Vec::new(),
|
||
orientation,
|
||
// Filled by `crate::open_session`, which is the only place that
|
||
// has both the bytes and the header read from them. A session
|
||
// built straight from pixels — a test, `masks_ui`'s fixture —
|
||
// honestly has no header, and says so.
|
||
source_meta: None,
|
||
// Nothing has been looked up, which is not the same as "looked up
|
||
// and not found" — `lens_summary` distinguishes them.
|
||
lens_profile_found: false,
|
||
profile_offer: None,
|
||
ctx: ctx.clone(),
|
||
graph,
|
||
history,
|
||
snapshots: Vec::new(),
|
||
removed_snapshots: Vec::new(),
|
||
compared_snapshot: None,
|
||
crop_notice: None,
|
||
demosaiced: Arc::new(demosaiced),
|
||
denoise: Default::default(),
|
||
full: None,
|
||
window: None,
|
||
adjust: AdjustPass::new(ctx),
|
||
histogram: HistogramPass::new(ctx)
|
||
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
|
||
.ok(),
|
||
raw_histogram: RawHistogramPass::new(ctx)
|
||
.inspect_err(|e| log::warn!("no raw histogram on this device: {e}"))
|
||
.ok(),
|
||
raw_counts: None,
|
||
peak: FocusPeakPass::new(ctx)
|
||
.inspect_err(|e| log::warn!("no focus peaking on this device: {e}"))
|
||
.ok(),
|
||
peaking: None,
|
||
segmentation: None,
|
||
masks: None,
|
||
subjects: None,
|
||
subject_key: 0,
|
||
active_masks: Vec::new(),
|
||
active_part: 0,
|
||
painting: None,
|
||
brush: (
|
||
dr_pipeline::mask::DEFAULT_BRUSH_RADIUS,
|
||
dr_pipeline::mask::DEFAULT_BRUSH_HARDNESS,
|
||
dr_pipeline::mask::DEFAULT_BRUSH_FLOW,
|
||
),
|
||
selected_spot: None,
|
||
show_overlay: false,
|
||
reveal_style: dr_pipeline::mask::RevealStyle::Tint,
|
||
mask_views: std::collections::HashMap::new(),
|
||
active_tab: None,
|
||
curve_channel: 0,
|
||
display_space: dr_types::ColourSpace::Srgb,
|
||
auto_crop: None,
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-EXP-8
|
||
/// Remember what the file this session was opened from said about itself.
|
||
///
|
||
/// Called by [`crate::open_session`] rather than by the constructors,
|
||
/// because that is the one function that reads a photograph's bytes and
|
||
/// its header together — every other way of making a session starts from
|
||
/// pixels that never had a file behind them.
|
||
pub fn set_source_metadata(&mut self, meta: dr_decode::Metadata) {
|
||
// The lens profile is applied *here* rather than by the caller, and
|
||
// that is the point of putting it in this method. This is the one
|
||
// place a session is told which file it came from, so it is the one
|
||
// place the lookup can be made unforgettable — the same shape
|
||
// `FilmRebake` uses to stop a derived thing being quietly skipped.
|
||
self.apply_lens_profile(&meta);
|
||
self.source_meta = Some(meta);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Look this shot's lens up and hand the coefficients to the corrections.
|
||
///
|
||
/// Called with **every** header, including ones naming no lens: the
|
||
/// clearing case matters as much as the setting one, because a session
|
||
/// reused for a second photograph would otherwise correct it for the
|
||
/// optics of the first.
|
||
pub(super) fn apply_lens_profile(&mut self, meta: &dr_decode::Metadata) {
|
||
let found = Self::profile_for(meta);
|
||
self.lens_profile_found = found.is_some();
|
||
self.graph.set_lens_profile(found);
|
||
}
|
||
|
||
/// The profile for one shot, converted into the pipeline's own types.
|
||
///
|
||
/// **The conversion lives here because nowhere else can see both sides.**
|
||
/// `dr-lens` carries the Lensfun database and `dr-pipeline` carries the
|
||
/// maths, and the coefficient structs are deliberately duplicated so that
|
||
/// the dependency between them does not exist (ARCH §6.5a). This function
|
||
/// is the seam, and it is a `match` on three optionals.
|
||
///
|
||
/// Every field is taken independently. The database routinely knows a
|
||
/// lens's distortion and not its vignetting, or covers only part of a
|
||
/// zoom's range, and a partial profile is worth applying — discarding it
|
||
/// because one field is missing would turn a good correction into none.
|
||
pub(crate) fn profile_for(meta: &dr_decode::Metadata) -> Option<dr_pipeline::LensProfile> {
|
||
// All three are needed to ask the question at all. A lens name alone
|
||
// does not identify a correction: 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 would return a profile measured
|
||
// for a shot nobody took.
|
||
let (lens, focal, aperture) = (meta.lens.as_deref()?, meta.focal_length?, meta.aperture?);
|
||
|
||
let shot = dr_lens::ShotInfo::new(lens, focal, aperture);
|
||
let found = dr_lens::lookup(&shot)?;
|
||
if found.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(dr_pipeline::LensProfile {
|
||
distortion: found
|
||
.distortion
|
||
.map(|d| dr_pipeline::ops::distortion::PtLens {
|
||
a: d.a,
|
||
b: d.b,
|
||
c: d.c,
|
||
}),
|
||
tca: found.tca.map(|t| dr_pipeline::Tca {
|
||
red_scale: t.red_scale,
|
||
blue_scale: t.blue_scale,
|
||
}),
|
||
vignetting: found.vignetting.map(|v| dr_pipeline::ops::vignetting::Pa {
|
||
k1: v.k1,
|
||
k2: v.k2,
|
||
k3: v.k3,
|
||
}),
|
||
})
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// What to tell the photographer about the automatic lens correction.
|
||
///
|
||
/// `dr-lens` states the rule this exists to satisfy: an automatic
|
||
/// correction that silently did nothing is worse than one the user can see
|
||
/// is unavailable. Most lenses in most photographs will not be in the
|
||
/// database — third-party glass often reports nothing, adapted manual
|
||
/// lenses report nothing at all — so "no profile" is the ordinary case and
|
||
/// has to read as a fact rather than as a failure.
|
||
pub fn lens_summary(&self) -> String {
|
||
let Some(meta) = self.source_meta.as_ref() else {
|
||
return String::new();
|
||
};
|
||
let Some(lens) = meta
|
||
.lens
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|l| !l.is_empty())
|
||
else {
|
||
// Not "no profile found": nothing was looked up, because the file
|
||
// does not say what it was taken with. Naming the wrong reason
|
||
// would send someone hunting for a profile that was never missing.
|
||
return "Lens not recorded".into();
|
||
};
|
||
match (self.lens_profile_found, self.graph.lens_profile_applied()) {
|
||
(true, true) => format!("{lens} · corrected"),
|
||
// A profile exists and is switched off, which is neither of the
|
||
// other two answers: the photographer turned it off, and a line
|
||
// reading "no profile" would send them looking for one that is
|
||
// sitting right there in the panel with its box unticked.
|
||
(true, false) => format!("{lens} · profile off"),
|
||
(false, _) => format!("{lens} · no profile"),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3e
|
||
/// Remember the profile embedded in the file, if it may be offered for
|
||
/// copying (see [`Self::profile_offer`]).
|
||
pub fn set_embedded_profile(&mut self, embedded: Option<dr_decode::dcp::Dcp>) {
|
||
self.profile_offer = embedded
|
||
.filter(|p| p.may_copy())
|
||
.filter(|p| {
|
||
let model = p.unique_camera_model.as_deref();
|
||
model.is_some() && dr_decode::dcp::find(model, "", "").is_none()
|
||
})
|
||
.map(Arc::new);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3e
|
||
/// What to tell the photographer about the camera profile (D20).
|
||
///
|
||
/// Empty for an already-rendered source — a JPEG has no camera profile
|
||
/// to speak of. Otherwise the profile's name and where it came from, or
|
||
/// that there is none, which is the ordinary case for a CR2 and must read
|
||
/// as a fact: the matrix alone is a correct rendering, only a plainer one.
|
||
pub fn profile_summary(&self) -> String {
|
||
if self.demosaiced.is_non_linear() {
|
||
return String::new();
|
||
}
|
||
let Some(tables) = self.demosaiced.profile_tables() else {
|
||
return "No camera profile · matrix only".into();
|
||
};
|
||
let name = if tables.name.is_empty() {
|
||
"Camera profile"
|
||
} else {
|
||
tables.name.as_str()
|
||
};
|
||
let applied = self
|
||
.graph
|
||
.param(
|
||
dr_pipeline::ops::camera_profile::ID,
|
||
dr_pipeline::ops::camera_profile::APPLY,
|
||
)
|
||
.is_none_or(|v| v != 0.0);
|
||
if !applied {
|
||
return format!("{name} · off");
|
||
}
|
||
match &tables.origin {
|
||
dr_types::ProfileOrigin::Embedded => format!("{name} · in the file"),
|
||
dr_types::ProfileOrigin::File(file) => format!("{name} · {file}"),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3e
|
||
/// The action the info panel offers for this file's embedded profile,
|
||
/// worded with the body it would apply to; `None` where there is nothing
|
||
/// to offer.
|
||
pub fn profile_offer(&self) -> Option<String> {
|
||
let model = self.profile_offer.as_ref()?.unique_camera_model.clone()?;
|
||
Some(format!("Use this profile for every {model}"))
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3e
|
||
/// Save the embedded profile into the profiles directory, so every other
|
||
/// photograph from this body — its CR2s, above all — renders through it
|
||
/// from the next time it is opened.
|
||
pub fn adopt_profile(&mut self) -> Result<std::path::PathBuf, String> {
|
||
let profile = self.profile_offer.as_ref().ok_or("nothing to adopt")?;
|
||
let path = dr_decode::dcp::save(profile)?;
|
||
self.profile_offer = None;
|
||
Ok(path)
|
||
}
|
||
|
||
/// TRACES: FR-EXP-8
|
||
/// The header this session was opened from, where there was one.
|
||
///
|
||
/// `None` is a real answer and not a failure: a JPEG with no EXIF block, a
|
||
/// file opened from bytes whose header would not parse, a session built in
|
||
/// a test. An export from such a session writes only what `dr-export` says
|
||
/// about itself, and in particular invents no capture date.
|
||
pub fn source_metadata(&self) -> Option<&dr_decode::Metadata> {
|
||
self.source_meta.as_ref()
|
||
}
|
||
|
||
/// The displayed size, for sizing the viewport.
|
||
///
|
||
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
||
/// the aspect ratio, and a viewport sized to the sensor would letterbox a
|
||
/// cropped image against the wrong shape.
|
||
pub fn source_size(&self) -> (u32, u32) {
|
||
let (w, h) = self.demosaiced.size();
|
||
self.graph.output_size(w, h)
|
||
}
|
||
|
||
/// How many shader pipelines have been compiled. Surfaced so the status
|
||
/// strip can show that slider movement is not recompiling.
|
||
pub fn compiled_pipelines(&self) -> usize {
|
||
self.adjust.cached_pipelines()
|
||
}
|
||
|
||
pub fn is_neutral(&self) -> bool {
|
||
self.graph.is_neutral()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Lift this session's edit onto the clipboard.
|
||
///
|
||
/// Captured at full scope — framing included — because the decision about
|
||
/// what travels is made when the preset is *applied*. Copying, then
|
||
/// changing one's mind about the crop, must not mean copying again.
|
||
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||
/// The local adjustment stack, for writing this image's edit back.
|
||
///
|
||
/// Beside `copy_settings` rather than part of it: that returns a `Preset`,
|
||
/// which travels *between* photographs, and a mask must not — it is drawn
|
||
/// against one frame and describes nothing on another. The save path takes
|
||
/// both; the paste path takes only the preset.
|
||
pub fn masks(&self) -> &dr_pipeline::mask::MaskStack {
|
||
self.graph.masks()
|
||
}
|
||
|
||
pub fn copy_settings(&self) -> Preset {
|
||
Preset::capture(&self.graph)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Replace this session's edit within `scope`.
|
||
///
|
||
/// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves
|
||
/// values the sliders are showing, and nothing here pushes them.
|
||
pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) {
|
||
let rebake = preset.apply(&mut self.graph, scope);
|
||
// TRACES: FR-DEV-3f
|
||
// Before the step is recorded, so the history holds the photograph as
|
||
// it now renders — on the preset's stock, baked from the film sliders
|
||
// that just arrived with it.
|
||
self.pay_film_debt(&rebake);
|
||
// A paste is undoable, and is the action most in need of it: it
|
||
// replaces everything in scope at once, so getting it wrong costs more
|
||
// than any single control can.
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::PASTE));
|
||
}
|
||
|
||
/// TRACES: FR-CAT-8
|
||
/// Load a stored edit, as read from this image's sidecar.
|
||
///
|
||
/// A replacement rather than an overlay — [`Version::apply`] resets first —
|
||
/// so a version that stores nothing opens the photograph at its defaults
|
||
/// rather than leaving the previous image's exposure standing. The file's
|
||
/// orientation survives it, since that was never an edit.
|
||
pub fn apply_version(&mut self, version: &dr_pipeline::Version) {
|
||
// TRACES: FR-DEV-3f
|
||
// The film, which `apply` cleared and could not restore: a sidecar
|
||
// names a stock, and turning a name into tables needs the profile
|
||
// database that `dr-pipeline` deliberately does not link. So it is
|
||
// re-baked here, after the parameters, because the bake reads the
|
||
// film's own exposure sliders and they have just arrived.
|
||
let rebake = version.apply(&mut self.graph);
|
||
self.pay_film_debt(&rebake);
|
||
// The stored edit becomes the floor rather than a step. It is not
|
||
// something the user did in this sitting, and an undo that reached
|
||
// behind it would discard a previous session's work in one press —
|
||
// then persist that on the way out, since saving is automatic.
|
||
self.history.reset(&self.graph);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::develop::test_support::*;
|
||
|
||
/// A lookup needs all three of lens, focal length and aperture.
|
||
///
|
||
/// Not pedantry about missing fields: distortion is interpolated across a
|
||
/// zoom's focal range and vignetting depends strongly on aperture, so a
|
||
/// lookup done without them would return coefficients measured for a shot
|
||
/// nobody took and apply them with full confidence. Refusing is the honest
|
||
/// answer, and the panel says so.
|
||
#[test]
|
||
fn a_lookup_needs_the_whole_shot_and_not_just_the_lens() {
|
||
let complete = dr_decode::Metadata {
|
||
lens: Some("Nikon AF-S 50mm f/1.8G".into()),
|
||
focal_length: Some(50.0),
|
||
aperture: Some(1.8),
|
||
..Default::default()
|
||
};
|
||
|
||
for (name, meta) in [
|
||
(
|
||
"no lens",
|
||
dr_decode::Metadata {
|
||
lens: None,
|
||
..complete.clone()
|
||
},
|
||
),
|
||
(
|
||
"no focal length",
|
||
dr_decode::Metadata {
|
||
focal_length: None,
|
||
..complete.clone()
|
||
},
|
||
),
|
||
(
|
||
"no aperture",
|
||
dr_decode::Metadata {
|
||
aperture: None,
|
||
..complete.clone()
|
||
},
|
||
),
|
||
] {
|
||
assert!(
|
||
DevelopSession::profile_for(&meta).is_none(),
|
||
"{name}: a partial header must not produce a confident profile"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// "Not recorded" and "no profile" are different facts.
|
||
///
|
||
/// Collapsing them would send someone hunting for a missing profile when
|
||
/// the file simply never said what took the photograph — and `dr-lens`'s
|
||
/// own rule is that the interface must be plain about which it is, because
|
||
/// a correction that silently did nothing is worse than one visibly
|
||
/// unavailable.
|
||
/// A flat 8×8 raw carrying `tables`, for the camera profile line.
|
||
fn raw_with(tables: Option<dr_types::ProfileTables>) -> dr_decode::RawImage {
|
||
dr_decode::RawImage {
|
||
width: 8,
|
||
height: 8,
|
||
data: vec![20_000; 64],
|
||
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||
black_level: [0; 4],
|
||
white_level: u16::MAX,
|
||
wb_coeffs: [1.0; 4],
|
||
color_matrix: None,
|
||
samples_per_pixel: 1,
|
||
profile: None,
|
||
profile_tables: tables.map(Arc::new),
|
||
baseline_exposure: 0.0,
|
||
make: "Canon".into(),
|
||
model: "EOS 6D".into(),
|
||
crop: dr_decode::CropRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 8,
|
||
height: 8,
|
||
},
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_profile_line_names_the_profile_and_where_it_came_from() {
|
||
// TRACES: FR-DEV-3e
|
||
let Some(ctx) = headless() else { return };
|
||
let tables = |origin| dr_types::ProfileTables {
|
||
name: "Adobe Standard".into(),
|
||
origin,
|
||
hue_sat: None,
|
||
look: dr_types::HueSatTable::new(2, 2, 1, false, vec![[5.0, 1.1, 1.0]; 4]),
|
||
tone_curve: None,
|
||
};
|
||
let open = |t| {
|
||
DevelopSession::open(&ctx, &raw_with(t), dr_types::Orientation::NORMAL)
|
||
.expect("session")
|
||
};
|
||
|
||
let none = open(None);
|
||
assert_eq!(none.profile_summary(), "No camera profile · matrix only");
|
||
assert_eq!(none.profile_offer(), None);
|
||
|
||
let mut embedded = open(Some(tables(dr_types::ProfileOrigin::Embedded)));
|
||
assert_eq!(embedded.profile_summary(), "Adobe Standard · in the file");
|
||
embedded.graph.set_param(
|
||
dr_pipeline::ops::camera_profile::ID,
|
||
dr_pipeline::ops::camera_profile::APPLY,
|
||
0.0,
|
||
);
|
||
assert_eq!(embedded.profile_summary(), "Adobe Standard · off");
|
||
|
||
let file = open(Some(tables(dr_types::ProfileOrigin::File(
|
||
"Canon EOS 6D Adobe Standard.dcp".into(),
|
||
))));
|
||
assert_eq!(
|
||
file.profile_summary(),
|
||
"Adobe Standard · Canon EOS 6D Adobe Standard.dcp"
|
||
);
|
||
|
||
// A JPEG has no camera profile to speak of.
|
||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let jpeg = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert_eq!(jpeg.profile_summary(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn only_a_copyable_profile_is_offered() {
|
||
// TRACES: FR-DEV-3e
|
||
let Some(ctx) = headless() else { return };
|
||
let mut s = DevelopSession::open(&ctx, &raw_with(None), dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
let profile = |policy| dr_decode::dcp::Dcp {
|
||
name: "Adobe Standard".into(),
|
||
unique_camera_model: Some("Nonexistent Body 1".into()),
|
||
copyright: None,
|
||
calibration_signature: None,
|
||
embed_policy: policy,
|
||
illuminants: [Some(21), None],
|
||
color_matrix: [
|
||
Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
|
||
None,
|
||
],
|
||
forward_matrix: [None, None],
|
||
hue_sat: [None, None],
|
||
look: dr_types::HueSatTable::new(2, 2, 1, false, vec![[5.0, 1.1, 1.0]; 4]),
|
||
tone_curve: None,
|
||
baseline_exposure_offset: 0.0,
|
||
};
|
||
s.set_embedded_profile(Some(profile(0)));
|
||
assert_eq!(
|
||
s.profile_offer().as_deref(),
|
||
Some("Use this profile for every Nonexistent Body 1")
|
||
);
|
||
s.set_embedded_profile(Some(profile(2)));
|
||
assert_eq!(s.profile_offer(), None, "embed never");
|
||
s.set_embedded_profile(None);
|
||
assert_eq!(s.profile_offer(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_lens_line_says_which_kind_of_nothing_it_found() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
|
||
let session = |meta: dr_decode::Metadata| {
|
||
let mut s = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
s.set_source_metadata(meta);
|
||
s
|
||
};
|
||
|
||
// A session that was never given a header at all.
|
||
let bare = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert_eq!(bare.lens_summary(), "");
|
||
|
||
let unrecorded = session(dr_decode::Metadata::default());
|
||
assert_eq!(unrecorded.lens_summary(), "Lens not recorded");
|
||
|
||
// A name no database will match. Deliberately absurd rather than a real
|
||
// obscure lens, so the test cannot start passing for the wrong reason
|
||
// if the bundled database grows.
|
||
let unmatched = session(dr_decode::Metadata {
|
||
lens: Some("Nonexistent 999mm f/0.5".into()),
|
||
focal_length: Some(999.0),
|
||
aperture: Some(0.5),
|
||
..Default::default()
|
||
});
|
||
assert_eq!(
|
||
unmatched.lens_summary(),
|
||
"Nonexistent 999mm f/0.5 · no profile"
|
||
);
|
||
assert!(
|
||
unmatched.graph.lens_profile().is_none(),
|
||
"an unmatched lens must leave the corrections alone"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A profile switched off is a third answer, and has to read as one.
|
||
///
|
||
/// "No profile" sends a photographer looking for a lens the database does
|
||
/// not have. If the profile is sitting in the panel with its box unticked,
|
||
/// that is a different sentence, and the line has to say which.
|
||
///
|
||
/// Depends on the bundled database holding a common lens, exactly as
|
||
/// `dr_lens`'s own tests do — this is the only path that sets
|
||
/// `lens_profile_found`, and standing a profile up by hand would test the
|
||
/// formatting while skipping the lookup it is reporting on.
|
||
#[test]
|
||
fn the_lens_line_separates_a_declined_profile_from_a_missing_one() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
const LENS: &str = "Canon EF 16-35mm f/2.8L USM";
|
||
session.set_source_metadata(dr_decode::Metadata {
|
||
lens: Some(LENS.into()),
|
||
focal_length: Some(20.0),
|
||
aperture: Some(2.8),
|
||
..Default::default()
|
||
});
|
||
assert_eq!(session.lens_summary(), format!("{LENS} · corrected"));
|
||
|
||
session.graph.set_lens_profile_applied(false);
|
||
assert_eq!(session.lens_summary(), format!("{LENS} · profile off"));
|
||
|
||
session.graph.set_lens_profile_applied(true);
|
||
assert_eq!(session.lens_summary(), format!("{LENS} · corrected"));
|
||
}
|
||
|
||
/// Opening a second photograph must not correct it for the first one's lens.
|
||
///
|
||
/// The clearing case, and the reason `apply_lens_profile` runs on every
|
||
/// header rather than only on the ones that match something. A stale
|
||
/// profile is invisible: the picture is simply wrong in a way that looks
|
||
/// like the lens.
|
||
#[test]
|
||
fn a_second_photograph_does_not_inherit_the_first_lens_profile() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
// Stand in for a matched lens by applying a profile directly, so the
|
||
// test does not depend on what the bundled database happens to hold.
|
||
session
|
||
.graph
|
||
.set_lens_profile(Some(dr_pipeline::LensProfile {
|
||
distortion: Some(dr_pipeline::ops::distortion::PtLens {
|
||
a: 0.0,
|
||
b: -0.02,
|
||
c: 0.0,
|
||
}),
|
||
tca: None,
|
||
vignetting: None,
|
||
}));
|
||
assert!(session.graph.lens_profile().is_some());
|
||
|
||
session.set_source_metadata(dr_decode::Metadata::default());
|
||
|
||
assert!(
|
||
session.graph.lens_profile().is_none(),
|
||
"a header naming no lens must clear the previous photograph's \
|
||
correction, not leave it standing"
|
||
);
|
||
}
|
||
}
|