Files
lightcraft/crates/pipeline/src/lib.rs
T
Claude 4ebb934c56 Merge PR #677
On merge: the HDR histogram is applied in develop_reporting (#712 split develop), is skipped for
draft renders (a second render per drag tick), and HdrBins::bin clamps a hand-built split so it
can't underflow (test hdr_bins_with_any_split_stay_in_range).
2026-10-11 13:09:34 +00:00

696 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The LightCraft develop pipeline (CPU reference implementation).
//!
//! Input: a scene-referred, linear Rec.2020 source image (already EXIF-oriented) at any resolution
//! (full size or a proxy), plus [`DevelopSettings`]. Output: a display-encoded sRGB image at the
//! requested size, and its histogram.
//!
//! Stage order (see `docs/pipeline.md`):
//! 1. geometry — user orientation, lens corrections (distortion, CA, vignetting), perspective, crop +
//! straighten, flips; one resample at output resolution; then defringe
//! 2. scene-linear — white balance, exposure, dehaze, local tone (highlights/shadows), texture,
//! clarity, local adjustments (masks)
//! 3. tone map — contrast / whites / blacks filmic curve on luminance, highlight desaturation
//! 4. colour — vibrance, saturation, colour mixer, colour grading, B&W (OkLCh)
//! 5. display — gamut map to the output space (sRGB unless [`RenderRequest::space`] says otherwise), encode, tone curves (parametric + point), vignette, grain
//!
//! Spatial parameters are specified relative to the image's long edge, so a 400 px preview and a
//! 60 MP export look alike.
//!
//! Exposure is a gain, so the spatial stages run on the un-exposed image and the per-pixel stage
//! applies it (filters on log luminance are shift-equivariant: identical result). With
//! [`render_cached`] each stage's output is reused while its inputs are unchanged ([`StageCache`]):
//! dragging a tone, colour or exposure slider re-runs only the per-pixel stage.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod auto;
pub mod blemish;
pub mod colorops;
pub mod cull;
pub mod dust;
pub mod finish;
pub mod geometry;
pub mod local;
pub mod local_tone;
pub mod lut;
pub mod masks;
pub mod optics;
pub mod output;
pub mod profiles;
pub mod redeye;
pub mod spots;
pub mod tone;
pub mod transform;
pub mod upright;
pub mod visualize;
pub use output::{DeepImage, DeepSamples, DisplaySpace, OutputDepth, OutputSpace, OutputTrc, Proof, ProofLut, proof_profile, register_proof_profile};
pub use visualize::{MaskView, Overlay};
use lightcraft_develop::{DevelopSettings, Treatment};
use std::any::Any;
use std::borrow::Cow;
use std::sync::{Arc, Mutex};
use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8, par_rows};
pub use tone::ToneMap;
/// A raw source's own colour model, for white balance: like Lightroom, a white balance
/// re-evaluates the camera's colour matrices at the chosen white (camera-space white balance, DNG
/// spec ch. 6) instead of adapting the as-shot rendering ([`local::wb_matrix_for`]).
#[derive(Debug, PartialEq)]
pub struct CameraColor {
/// The file's colour tags (its profile look left out: it is applied at load).
pub tags: lightcraft_raw::ColorData,
/// The white the source pixels were developed for.
pub developed_for: lightcraft_color::Xy,
}
/// Facts about the source the settings are interpreted against.
#[derive(Clone, Debug, PartialEq)]
pub struct SourceInfo {
/// Lens corrections embedded in the file (DNG opcodes), relative to the EXIF-oriented source.
pub lens: Option<lightcraft_develop::EmbeddedLens>,
/// Raw sources use absolute Kelvin white balance; rendered sources use a relative scale around
/// their as-shot white.
pub raw: bool,
pub as_shot_temp: f64,
pub as_shot_tint: f64,
/// No measured camera illuminant: WB adjustments are relative to the camera's rendered look.
pub relative_wb: bool,
/// The Kelvin scale comes from matrices measured for the camera model, not from the file: before those were
/// used for white balance, this source was edited on the relative scale (6500 K / 0 = as shot), so settings
/// without a [`lightcraft_develop::WbScale`] are read that way ([`local::legacy_to_kelvin`]).
pub legacy_relative_wb: bool,
/// The camera's colour model (raw files with a colour matrix); `None`: white balance adapts
/// the developed pixels (Bradford, linear Rec.2020).
pub camera_color: Option<Arc<CameraColor>>,
pub camera_tone: Option<tone::CameraTone>,
/// Segmentation mattes stored in the file (DNG semantic masks): AI masks use them.
pub mattes: Option<Arc<masks::Mattes>>,
/// The raw's own local tone mapping (DNG `ProfileGainTableMap`), rendered only when the
/// photo's Profile option asks for it ([`local_tone`]).
pub local_tone: Option<Arc<local_tone::LocalTone>>,
}
impl Default for SourceInfo {
fn default() -> Self {
Self {
raw: false,
as_shot_temp: 6500.0,
as_shot_tint: 0.0,
lens: None,
relative_wb: false,
legacy_relative_wb: false,
camera_color: None,
camera_tone: None,
mattes: None,
local_tone: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Quality {
/// Interactive drags: skips the most expensive refinements.
Draft,
#[default]
Full,
}
#[derive(Clone, Copy, Debug)]
pub struct RenderRequest {
/// Output must fit in `max_w × max_h` (aspect preserved).
pub max_w: usize,
pub max_h: usize,
pub quality: Quality,
/// Show the crop frame's content only (true) or the whole uncropped image (crop tool active).
pub apply_crop: bool,
/// A diagnostic view drawn over the result (e.g. Point Color's "visualize range").
pub overlay: Overlay,
/// Colour space of the result (exports; previews stay sRGB).
pub space: OutputSpace,
/// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports).
pub depth: OutputDepth,
/// Soft proofing (CPU only; see [`Proof`]).
pub proof: Option<Proof>,
/// Render only this window of the output that `max_w × max_h` describes (a zoomed view):
/// the result is that window's pixels, as in the whole render. See [`PixelWindow`].
pub window: Option<PixelWindow>,
/// Previews for a monitor with a display profile: render 8-bit into its primaries instead of
/// `space` (see [`DisplaySpace`]; ignored by deep renders).
pub display: Option<DisplaySpace>,
}
/// A window of the (virtual) full output, in its pixels: what a zoomed view needs, rendered
/// without materializing the whole frame. The output frame is the one `max_w × max_h` fits, after
/// crop, straighten and flips, as drawn. Out-of-range windows are clamped into it. Spatial stages
/// (clarity, dehaze, sharpening…) read their neighbours, so ask for a margin around what is kept.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PixelWindow {
pub x: usize,
pub y: usize,
pub w: usize,
pub h: usize,
}
impl PixelWindow {
/// This window inside a `full_w × full_h` frame: at least one pixel, wholly inside.
pub fn clamped(self, full_w: usize, full_h: usize) -> PixelWindow {
let (fw, fh) = (full_w.max(1), full_h.max(1));
let x = self.x.min(fw - 1);
let y = self.y.min(fh - 1);
PixelWindow { x, y, w: self.w.clamp(1, fw - x), h: self.h.clamp(1, fh - y) }
}
}
impl RenderRequest {
pub fn fit(max_w: usize, max_h: usize) -> Self {
Self {
max_w,
max_h,
quality: Quality::Full,
apply_crop: true,
overlay: Overlay::None,
space: OutputSpace::Srgb,
depth: OutputDepth::U8,
proof: None,
window: None,
display: None,
}
}
}
pub struct Rendered {
/// The result, 8-bit (for deep renders: `deep` reduced to 8 bits, display-encoded).
pub image: Rgba8,
pub histogram: Histogram,
/// The high-bit-depth result when [`RenderRequest::depth`] asks for one.
pub deep: Option<DeepImage>,
}
/// Everything the per-pixel stage needs, precomputed at output resolution.
///
/// The image and planes are computed *before exposure* (so they can be reused while exposure is
/// dragged): the per-pixel stage multiplies the image by `gain` and shifts log planes by `ev`.
/// Spatial filters on log luminance are shift-equivariant, so this equals filtering after exposure.
pub(crate) struct Prepared {
pub img: Arc<Rgb32f>,
/// Log2 luminance relative to middle grey (before exposure: add `ev`).
pub log_l: Arc<Plane>,
pub base: Arc<Plane>,
pub clarity_blur: Option<Arc<Plane>>,
pub texture_blur: Option<Arc<Plane>>,
pub dark: Option<Arc<Plane>>,
/// Blurred chromaticity (`rgb / Y`) for local Moiré / Noise.
pub chroma_blur: Option<Arc<Rgb32f>>,
/// Airlight of `dark` (before exposure).
pub air: f32,
pub masks: Vec<masks::Evaluated>,
/// Output pixels per unit of the source long edge.
pub px_per_long: f64,
}
/// Output size for a source of `src_w × src_h` under `s`, fitting `max_w × max_h`.
pub fn output_size(src_w: usize, src_h: usize, s: &DevelopSettings, req: &RenderRequest) -> (usize, usize) {
geometry::Frame::new(src_w, src_h, s, req.apply_crop).fit(req.max_w, req.max_h)
}
/// Size of the cropped output of a `src_w × src_h` source under `s` at the source's own resolution
/// (what a full-size export produces).
pub fn native_output_size(src_w: usize, src_h: usize, s: &DevelopSettings) -> (f64, f64) {
geometry::Frame::new(src_w, src_h, s, true).native_size()
}
/// The geometric frame `render` uses for `src` (lens data, automatic CA estimate included).
pub fn frame_for(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, apply_crop: bool) -> geometry::Frame {
let mut frame = geometry::Frame::with_lens(src.width, src.height, s, apply_crop, info.lens.as_ref());
if s.optics.remove_ca && s.section_enabled("optics") {
frame.add_lateral_ca(optics::estimate_lateral_ca(src));
}
frame
}
/// Intermediate results of recent renders of one view, reused by [`render_cached`].
///
/// Each stage is keyed by exactly the inputs it depends on: the resampled source by the source
/// buffer, framing and output size; the white-balanced, retouched, denoised image by those plus
/// white balance, spots and noise reduction; each spatial plane by that plus its own radius. So a
/// tone or colour slider drag (or exposure) re-runs only the per-pixel stage, a clarity drag skips
/// resampling and noise reduction, and so on. Holds the last few output sizes (a draft-size and a
/// full-size render of the same view both stay warm).
pub struct StageCache {
entries: Mutex<Vec<CacheEntry>>,
capacity: usize,
/// Another renderer's per-view state (the GPU renderer keeps its device-resident stages here).
ext: Mutex<Option<Arc<dyn Any + Send + Sync>>>,
}
impl Default for StageCache {
fn default() -> Self {
StageCache { entries: Mutex::new(Vec::new()), capacity: 2, ext: Mutex::new(None) }
}
}
impl StageCache {
/// Drop everything (e.g. when memory is needed).
pub fn clear(&self) {
self.lock().clear();
*self.ext.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
/// Per-view state of type `T` kept alongside this cache (created on first use). Lets another
/// renderer of the same view (e.g. `lightcraft-gpu`) keep its own stage cache with this one.
pub fn extension<T: Any + Send + Sync + Default>(&self) -> Arc<T> {
let mut g = self.ext.lock().unwrap_or_else(|e| e.into_inner());
if let Some(e) = g.as_ref()
&& let Ok(t) = e.clone().downcast::<T>()
{
return t;
}
let t = Arc::new(T::default());
*g = Some(t.clone());
t
}
/// The per-view state of type `T` if there is one (unlike [`Self::extension`], never creates it).
pub fn peek_extension<T: Any + Send + Sync>(&self) -> Option<Arc<T>> {
self.ext.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|e| e.clone().downcast::<T>().ok())
}
/// Bytes of the intermediate images held (each buffer counted once; the sources they were
/// made from belong to their owner and are not counted).
pub fn bytes(&self) -> usize {
fn size<T>(i: &lightcraft_raster::Image<T>) -> usize {
i.data.len() * std::mem::size_of::<T>()
}
let mut seen: Vec<usize> = Vec::new();
let mut total = 0;
let mut add = |p: usize, n: usize| {
if !seen.contains(&p) {
seen.push(p);
total += n;
}
};
for e in self.lock().iter() {
add(Arc::as_ptr(&e.sampled) as usize, size(&e.sampled));
if let Some((_, l)) = &e.lin {
add(Arc::as_ptr(l) as usize, size(l));
}
let pl = &e.planes;
let planes = pl.log_l.iter().chain(pl.base.iter().map(|x| &x.1)).chain(pl.clarity.iter().map(|x| &x.1));
for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)) {
add(Arc::as_ptr(p) as usize, size(p));
}
}
total
}
/// Number of cached output sizes.
pub fn len(&self) -> usize {
self.lock().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<CacheEntry>> {
self.entries.lock().unwrap_or_else(|e| e.into_inner())
}
fn get(&self, src: &Arc<Rgb32f>, geo: u64) -> Option<CacheEntry> {
self.lock().iter().find(|e| e.geo == geo && Arc::ptr_eq(&e.src, src)).cloned()
}
fn put(&self, e: CacheEntry) {
let mut v = self.lock();
v.retain(|o| !(o.geo == e.geo && Arc::ptr_eq(&o.src, &e.src)));
v.push(e);
while v.len() > self.capacity {
v.remove(0);
}
}
}
#[derive(Clone)]
struct CacheEntry {
src: Arc<Rgb32f>,
geo: u64,
sampled: Arc<Rgb32f>,
lin: Option<(u64, Arc<Rgb32f>)>,
planes: local::Planes,
}
fn hash_of(parts: impl std::hash::Hash) -> u64 {
use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
BuildHasherDefault::<DefaultHasher>::default().hash_one(parts)
}
/// What a render resolves to before any pixel work: the effective settings (profile and Upright
/// applied), the geometric frame, the output size and the stage-cache keys. Shared by the CPU
/// renderer and the GPU renderer (`lightcraft-gpu`), so both key their caches identically.
pub struct Plan<'a> {
pub settings: Cow<'a, DevelopSettings>,
pub frame: geometry::Frame,
pub w: usize,
pub h: usize,
/// Output pixels per unit of the oriented source's long edge.
pub px_per_long: f64,
/// Long edge of the source buffer (px).
pub src_long: usize,
/// Key of the resampled source (together with the source buffer's identity).
pub geo: u64,
/// Key of the white-balanced, retouched, denoised image (and of the planes computed from it).
pub lin_key: u64,
/// [`Self::lin_key`] without the noise-reduction settings: draft renders reuse the smooth
/// planes of an image that differs from this one only in its noise reduction.
pub pre_nr_key: u64,
/// Red eye / pet eye corrections with their detected pupils, in output pixels.
pub eyes: Vec<redeye::EyeK>,
/// The whole frame's dehaze airlight, for a windowed render (which can't see the whole frame).
pub fixed_air: Option<f32>,
/// A windowed render works on a larger window when spots reach into it (see
/// [`spots::window_for_reads`]); this is the requested window inside the rendered one, which
/// the result is cut to.
pub keep: Option<PixelWindow>,
/// The source's segmentation mattes ([`SourceInfo::mattes`]).
pub mattes: Option<Arc<masks::Mattes>>,
}
/// Long edge of the small render a windowed render estimates the whole frame's airlight from.
const AIRLIGHT_PROXY_EDGE: usize = 384;
/// Resolve `s` against `src` for `req` (see [`Plan`]).
pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &RenderRequest) -> Plan<'a> {
let mut settings: Cow<'a, DevelopSettings> = match profiles::effective(s) {
Cow::Borrowed(b) => upright::resolve(src, info, b),
Cow::Owned(o) => Cow::Owned(upright::resolve(src, info, &o).into_owned()),
};
visualize::adjust_settings(req.overlay, &mut settings);
// a window must not choose a spot's source from the pixels it happens to hold: fix them
// from a fixed-size render of the whole frame first
if req.window.is_some() && settings.spots.iter().any(|sp| sp.source_offset.is_none()) {
let picked: Vec<Option<lightcraft_geom::Point>> =
settings.spots.iter().map(|sp| sp.source_offset.or_else(|| spots::pick_source(src, info, &settings, sp, None))).collect();
for (sp, o) in settings.to_mut().spots.iter_mut().zip(picked) {
sp.source_offset = o;
}
}
let s = &*settings;
let frame = frame_for(src, info, s, req.apply_crop);
let (full_w, full_h) = frame.fit(req.max_w, req.max_h);
// sizes and scale are those of the whole output; a window only narrows what is drawn
let px_per_long = frame.px_per_long(full_w);
let mut fixed_air = None;
let mut keep = None;
let (frame, w, h) = match req.window {
Some(win) => {
let win = win.clamped(full_w, full_h);
if s.section_enabled("effects") && (s.effects.dehaze != 0.0 || s.masks.iter().any(|m| m.adjust.dehaze != 0.0)) {
let k = (AIRLIGHT_PROXY_EDGE as f64 / full_w.max(full_h) as f64).min(1.0);
let (pw, ph) = (((full_w as f64 * k).round() as usize).max(1), ((full_h as f64 * k).round() as usize).max(1));
fixed_air = Some(local::frame_airlight(src, info, s, &frame, pw, ph));
}
// (spots grow the rendered window; only 8-bit renders are cut back to the request)
let work = if req.depth == OutputDepth::U8 { spots::window_for_reads(s, &frame, full_w, full_h, px_per_long, win) } else { win };
if work != win {
keep = Some(PixelWindow { x: win.x - work.x, y: win.y - work.y, ..win });
}
(frame.window(full_w, full_h, work), work.w, work.h)
}
None => (frame, full_w, full_h),
};
let src_long = src.width.max(src.height);
let geo = hash_of((format!("{frame:?}"), w, h));
let (wb_t, wb_tint) = local::effective_wb(info, s);
let eyes = redeye::resolve(src, &s.red_eye, s.orientation, &frame, w, h, px_per_long);
let d = &s.detail;
let pre_nr_key = hash_of((
geo,
[wb_t, wb_tint, info.as_shot_temp, info.as_shot_tint].map(f64::to_bits),
format!("{:?}", s.spots),
format!("{eyes:?}"),
// defringe runs in this stage
format!("{:?}", s.optics),
// and the local tone mapping
local_tone::enabled(info, s),
src_long,
));
let lin_key = hash_of((pre_nr_key, [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits)));
Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, pre_nr_key, eyes, fixed_air, keep, mattes: info.mattes.clone() }
}
/// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal).
pub fn lin_needs_cpu(s: &DevelopSettings, info: &SourceInfo) -> bool {
let o = &s.optics;
let defringe = s.section_enabled("optics") && (o.defringe_purple_amount > 0.0 || o.defringe_green_amount > 0.0);
defringe || !s.spots.is_empty() || local_tone::enabled(info, s)
}
/// The white-balanced, defringed, retouched image (before noise reduction): the CPU part of the
/// scene-linear stage, in place.
pub fn lin_cpu(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) {
let s = &*p.settings;
// (before the white balance: the gain's input is the as-shot development, as in the file)
local_tone::apply(img, info, p);
local::white_balance(img, info, s);
optics::defringe(img, s, p.px_per_long / optics::DEFRINGE_REF_LONG);
spots::apply(img, &s.spots, &p.frame, p.px_per_long);
redeye::apply(img, &p.eyes);
}
/// The settings a render of `s` for `req` uses: the HDR edit for an [`OutputDepth::F32Hdr`]
/// render, otherwise its SDR rendition (see [`DevelopSettings::sdr_rendition`]). Every renderer
/// (CPU and GPU) goes through this, so an SDR render of an HDR edit looks the same everywhere.
pub fn settings_for<'a>(s: &'a DevelopSettings, req: &RenderRequest) -> std::borrow::Cow<'a, DevelopSettings> {
if req.depth == OutputDepth::F32Hdr { std::borrow::Cow::Borrowed(s) } else { s.sdr_rendition() }
}
/// Long edge (pixels) of the small HDR render [`hdr_histogram`] measures.
pub const HDR_HISTOGRAM_EDGE: usize = 384;
/// The histogram of an HDR edit's HDR rendition for a preview request `req`: a small float HDR
/// render of the same framing ([`HDR_HISTOGRAM_EDGE`]), binned with SDR on the left half and the
/// whole HDR range ([`Hdr::MAX_EV_LIMIT`](lightcraft_develop::Hdr::MAX_EV_LIMIT) stops above SDR
/// white, whatever the headroom limit, so the scale stays put) on the right ([`Histogram::of_hdr`]). A preview of
/// an HDR edit shows its SDR rendition; this says how far its highlights go. `None` for SDR edits,
/// high-bit-depth, windowed and proofed requests (their histogram stays the image's).
pub fn hdr_histogram(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Option<Histogram> {
// a drag's drafts keep their own histogram: a second render per tick would slow them down
if !s.hdr.enabled || req.depth != OutputDepth::U8 || req.window.is_some() || req.proof.is_some() || req.quality == Quality::Draft {
return None;
}
let k = (HDR_HISTOGRAM_EDGE as f64 / req.max_w.max(req.max_h).max(1) as f64).min(1.0);
let size = |v: usize| ((v as f64 * k).round() as usize).max(1);
let hreq = RenderRequest {
max_w: size(req.max_w),
max_h: size(req.max_h),
depth: OutputDepth::F32Hdr,
overlay: Overlay::None,
proof: None,
window: None,
display: None,
..*req
};
let deep = render(src, info, s, &hreq).deep?;
let DeepSamples::F32(v) = &deep.samples else { return None };
Some(Histogram::of_hdr(v.as_chunks::<3>().0, lightcraft_develop::Hdr::MAX_EV_LIMIT as f32))
}
/// Render `src` with settings `s`.
pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered {
render_impl(Src::Borrowed(src), info, s, req, None)
}
/// [`render`], reusing (and refreshing) the intermediate results in `cache`. The output is
/// identical to [`render`]'s.
pub fn render_cached(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: &StageCache) -> Rendered {
render_impl(Src::Shared(src), info, s, req, Some(cache))
}
enum Src<'a> {
Borrowed(&'a Rgb32f),
Shared(&'a Arc<Rgb32f>),
}
fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: Option<&StageCache>) -> Rendered {
// sections switched off with their eye render as if at their defaults (issue #316)
let effective = s.effective();
let edit: &DevelopSettings = &effective;
let rendition = settings_for(edit, req);
let s: &DevelopSettings = &rendition;
// `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling.
let lap = |what: &str, t: &mut Option<std::time::Instant>| {
if let Some(t) = t {
eprintln!(" {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
*t = std::time::Instant::now();
}
};
let mut t = profiling().then(std::time::Instant::now);
let src_img: &Rgb32f = match &src {
Src::Borrowed(r) => r,
Src::Shared(a) => a,
};
let plan = plan(src_img, info, s, req);
let Plan { ref frame, w, h, px_per_long, src_long, geo, lin_key, .. } = plan;
let s = &*plan.settings;
let shared = match (&src, cache) {
(Src::Shared(a), Some(c)) => Some((*a, c)),
_ => None,
};
let cached = shared.and_then(|(a, c)| c.get(a, geo));
let sampled = match &cached {
Some(e) => e.sampled.clone(),
None => Arc::new(frame.sample(src_img, w, h)),
};
lap("sample", &mut t);
let lin = match cached.as_ref().and_then(|e| e.lin.clone()).filter(|(k, _)| *k == lin_key) {
Some((_, img)) => img,
None => {
// Without a cache the resampled buffer is ours: work on it in place.
let mut img = if shared.is_some() { (*sampled).clone() } else { Arc::unwrap_or_clone(sampled.clone()) };
lin_cpu(&mut img, info, &plan);
local::denoise(&mut img, s, src_long, frame.output_long(w, h));
Arc::new(img)
}
};
lap("wb/spots/nr", &mut t);
let mut planes = match cached.map(|e| e.planes) {
Some(p) if p.key == lin_key => p,
// A draft while noise reduction changes (a slider drag): the wide, edge-aware planes of the
// image before the change stand in (noise reduction barely moves a blur that wide); the
// per-pixel log luminance and the fine texture band are redone. Keyed so a full-quality
// render never takes them.
Some(p) if req.quality == Quality::Draft && p.pre_nr == plan.pre_nr_key => p.for_other_nr(),
_ => local::Planes { key: lin_key, pre_nr: plan.pre_nr_key, ..Default::default() },
};
let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes, plan.mattes.as_deref());
if let Some(a) = plan.fixed_air {
prep.air = a;
}
lap("prepare", &mut t);
if let Some((a, c)) = shared {
c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes });
}
if req.depth != OutputDepth::U8 {
let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth, req.proof);
let image = deep.to_rgba8();
let histogram = Histogram::of_srgb8(&image);
lap("finish (deep)", &mut t);
return Rendered { image, histogram, deep: Some(deep) };
}
let image = finish::finish(&prep, s, frame, info, req.space, req.display.as_ref(), req.proof);
lap("finish", &mut t);
let cut = |i: &Rgba8| match plan.keep {
Some(k) => i.crop(k.x, k.y, k.w, k.h),
None => i.clone(),
};
let histogram = match plan.keep {
Some(_) => Histogram::of_srgb8(&cut(&image)),
None => Histogram::of_srgb8(&image),
};
lap("histogram", &mut t);
let mut image = image;
let mask = overlay_alpha(req.overlay, &plan, &prep);
visualize::apply(&mut image, req.overlay, &plan, mask.as_ref());
let mut image = if plan.keep.is_some() { cut(&image) } else { image };
if req.overlay == Overlay::HdrRange && edit.hdr.enabled {
// the HDR rendition of the same request says how far above SDR white each pixel goes
let hreq = RenderRequest { depth: OutputDepth::F32Hdr, overlay: Overlay::None, proof: None, ..*req };
if let Some(hdr) = render(src_img, info, edit, &hreq).deep {
visualize::hdr_range(&mut image, &hdr, req.space.luma());
}
lap("visualize hdr", &mut t);
}
Rendered { image, histogram, deep: None }
}
/// The colour a colour-range mask would sample at normalized image point `p` (OkLab of the
/// exposed scene colours, as [`MaskShape::ColorRange`](lightcraft_develop::MaskShape) compares
/// them), averaged over 3 × 3 pixels of a render fitting `req`. `None` outside the image.
pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, p: lightcraft_geom::Point) -> Option<[f64; 3]> {
let plan = plan(src, info, s, req);
let mut img = plan.frame.sample(src, plan.w, plan.h);
lin_cpu(&mut img, info, &plan);
let o = plan.frame.norm_to_out(plan.w, plan.h).apply(p);
let (cx, cy) = (o.x.floor() as i64, o.y.floor() as i64);
if cx < 0 || cy < 0 || cx >= plan.w as i64 || cy >= plan.h as i64 {
return None;
}
let gain = (s.light.exposure as f32).exp2();
let mut acc = [0f64; 3];
let mut n = 0.0;
for y in (cy - 1).max(0)..=(cy + 1).min(plan.h as i64 - 1) {
for x in (cx - 1).max(0)..=(cx + 1).min(plan.w as i64 - 1) {
let c = img.data[y as usize * plan.w + x as usize].map(|v| v * gain);
let lab = lightcraft_color::perceptual::oklab_from_2020(masks::tonemap_for_select(c));
for k in 0..3 {
acc[k] += lab[k] as f64;
}
n += 1.0;
}
}
Some(acc.map(|v| v / n))
}
/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a
/// fresh evaluation.
fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane> {
let m = o.mask(&plan.settings)?;
if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
return Some(e.alpha.clone());
}
let ev = plan.settings.light.exposure as f32;
Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()))
}
/// Convenience: render a before/after pair side by side is up to the UI; this renders "before"
/// (default look, keeping the crop so framing matches, under the photo's own rendering process).
pub fn before_settings(s: &DevelopSettings) -> DevelopSettings {
let mut b = DevelopSettings { crop: s.crop, orientation: s.orientation, process: s.process, ..DevelopSettings::default() };
b.wb = lightcraft_develop::WhiteBalance { mode: lightcraft_develop::WbMode::AsShot, ..b.wb };
b
}
pub(crate) fn is_bw(s: &DevelopSettings) -> bool {
s.treatment == Treatment::Bw || s.profile.id == "lc.mono" || s.profile.id.starts_with("lc.bw.")
}
/// `LIGHTCRAFT_PROFILE` is set: print per-stage timings to stderr.
pub fn profiling() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some())
}
/// Run `f`, printing its duration under `LIGHTCRAFT_PROFILE`.
pub(crate) fn timed<R>(what: &str, f: impl FnOnce() -> R) -> R {
if !profiling() {
return f();
}
let t = std::time::Instant::now();
let r = f();
eprintln!(" {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
r
}
pub(crate) fn for_rows<T: Send>(data: &mut [T], w: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
par_rows(data, w, f)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_geometry;
#[cfg(test)]
mod tests_hdr;
#[cfg(test)]
mod tests_local;
#[cfg(test)]
mod tests_tone;
#[cfg(test)]
mod tests_window;