Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, measured (#3815)
* Camera Raw: Whites, Blacks, Shadows and Highlights closer to Photoshop's Measured as a black box on Photoshop 25.4's Camera Raw Filter. Whites and Blacks are global curves of the grey level: the measured rows per slider value, Whites first, after Shadows and Highlights. The clipping directions adapt to the image's black and white points (Whites up to 2.7 times stronger on a dark image, Blacks at least 1.2). On ten public-domain photos the mean error against Photoshop drops from 4.2-36.8 to 0.9-8.9 levels of 255. Shadows and Highlights are a local Laplacian filter (Paris et al. 2011, fast form of Aubry et al. 2014) on the log luminance, the method Adobe credits for process version 2012. Shadows raises pixels darker than their surroundings by an amount set by their own and their surroundings' tone, so big edges keep no halo, and keeps the 99th percentile; Highlights pulls pixels brighter than their surroundings toward them and keeps the 1st. The strengths are fitted to the photos (mean error 2.8-9.4 levels, was 3.7-14.3); Photoshop's exact operator was not recovered. The tone stages decode light with the document's curve: filter.cameraRaw stores encodingGamma (1.8 for ProPhoto, where raws open). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Camera Raw Shadows/Highlights: no contour lines on smooth gradients Twelve reference values over a raw's whole range left more than a stop between them, and the remapping changes within half a stop: coefficients interpolated across that gap drew contour lines in out-of-focus areas (Shadows +100 on a Nikon D4 raw). References are now an eighth of a stop apart over the image's tones (the deepest 0.1 % share the lowest), on a copy at most 1600 px across whose change comes back to full size by a guided upsampling, so a 16-megapixel image takes about 2 s per slider and the preview matches the result. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Camera Raw on raw opens: Shadows/Highlights in linear light, D4 look and black point as Photoshop Measured black-box on 29 Nikon D4 NEFs opened through Camera Raw in Photoshop 25.4 (sliders at +-100 and +50, exposure +-1) against our own linear development of the same files. - Shadows and Highlights of the open-time dialog now act while developing, in linear light before the tone curve, as Camera Raw does for raw files (exposure -1 gives the same gains). The log2 gain is slider/100 * C(B - anchor): B is a guided-filter base of the log2 luminance on a copy 1232 px on its long side, the anchor is image-adaptive (Shadows: log2 power mean p=1.5 of the linear luminance; Highlights: mean log2 luminance). New module photocraft_raw::tone; the preview undoes the camera curve to show the same (photocraft_raw::retone). - The D4 profile copies Camera Raw's default rendering: look matrix folded into the ForwardMatrix, per-channel curve, and an adaptive black point (0.33 x the 0.1th percentile of the luminance, Sensor::black_point). Against Photoshop on the 29 frames (1/4 size, log2 Y): default rendering median difference 0.29 -> 0.04 stop; correlation of the slider's effect Shadows +100 0.96, -100 0.91, Highlights -100 0.87, +100 0.85 (the previous filter model: 0.68 and 0.55 on DSG_3464). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: the open-time Camera Raw re-develop covers Shadows and Highlights Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Camera Raw raw-stage Shadows: keep shadow contrast as Photoshop does The user saw Photoshop's shadows sink deeper with more contrast. Measured on the 29 D4 NEFs: with Shadows +100 our shadow detail had 0.79 of Photoshop's local contrast and shadows sat 0.38 stop lighter. Two causes: - The black point now comes before the Shadows / Highlights gain, as in Camera Raw (lifting first let the lift wash out the black point's deep-shadow contrast). - The guided upsampling's epsilon goes from 0.01 to 1 stop^2: the change follows strong edges but stays smooth inside texture (with 0.01 it followed every detail and flattened it). Near strong edges the effect still matches Photoshop as well as elsewhere. Shadows +100: shadow detail 0.79 -> 0.91, shadow bias +0.38 -> +0.16 stop; effect correlation Shadows -100 0.906 -> 0.946, Highlights -100 0.870 -> 0.886, others unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, as Photoshop Measured black-box from Photoshop 25.4 opening 29 Nikon D4 NEFs through Camera Raw (descriptor keys Cl12, CrTx, Dhze; each at ±100 and +50, and +100 at exposure −1, 348 opens). All three act before the tone curve: the change at exposure −1 equals the one at 0 (correlation 0.98–0.99). - Clarity: log2 luminance split into bands between guided-filter bases (radii 2, 8, 32, 128 on the 1232 px work copy), each band scaled by a measured gain that follows its base's tone, plus a tone term; carried to full size with the Shadows / Highlights change. - Texture: Gaussian bands σ 0.5…16 on a half-size copy, each through its measured response (small detail raised more than strong edges, flat areas unchanged), guided-upsampled. - Dehaze: He, Sun and Tang's dark channel prior on linear ProPhoto (dark channel in Display P3, airlight from the brightest 0.1 % of it, guided-smoothed transmission), w 0.8 at +100; −100 a uniform veil. The black point stays the default rendering's. Against Photoshop, PhotoCraft's develop vs Photoshop's open on all 29 frames (median effect correlation, log2 luminance at 1/4 size, Texture at 1/2): Clarity +100 0.81, −100 0.85; Texture +100 0.85, −100 0.88; Dehaze +100 0.61 (0.78 in gamma 2.2: the crushed shadows differ), −100 0.96. Shadows / Highlights unchanged (0.964 / 0.886). The open-time Camera Raw dialog re-develops for them and its preview shows them (retone). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
6c7328b709
commit
bbe77354e0
|
|
@ -9,9 +9,10 @@
|
|||
//! with the guided filter); negative amounts add haze toward the atmospheric light.
|
||||
//! 3. **Tone** on luminance with colour ratios kept: Highlights / Shadows as exposure changes
|
||||
//! weighted by an edge-preserving base layer (guided filter: K. He, J. Sun, X. Tang, *Guided
|
||||
//! Image Filtering*, ECCV 2010), so local contrast survives; Whites / Blacks move the ends of
|
||||
//! the curve; Contrast is an S-curve about middle grey; Clarity (large radius, midtone
|
||||
//! weighted) and Texture (small radius) add band-pass local contrast.
|
||||
//! Image Filtering*, ECCV 2010), so local contrast survives; Contrast is an S-curve about
|
||||
//! middle grey; Clarity (large radius, midtone weighted) and Texture (small radius) add
|
||||
//! band-pass local contrast. Whites / Blacks follow as Photoshop's Camera Raw applies them:
|
||||
//! image-adaptive global curves measured on Photoshop 25.4 (see `whites_blacks`).
|
||||
//! 4. **Presence**: Vibrance (weighted toward less saturated colours) and Saturation.
|
||||
//! 5. **Tone Curve**: parametric regions (Highlights, Lights, Darks, Shadows with movable splits)
|
||||
//! then point curves (master and per channel), monotone cubic interpolation (F. Fritsch,
|
||||
|
|
@ -54,6 +55,38 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
|
|||
|
||||
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
|
||||
|
||||
mod local_tone;
|
||||
mod whites_blacks;
|
||||
|
||||
/// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8,
|
||||
/// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Encoding(f32);
|
||||
|
||||
impl Encoding {
|
||||
pub(crate) fn of(gamma: f32) -> Self {
|
||||
Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 })
|
||||
}
|
||||
pub(crate) fn decode(self, v: f32) -> f32 {
|
||||
if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) }
|
||||
}
|
||||
pub(crate) fn encode(self, v: f32) -> f32 {
|
||||
if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) }
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_encode(v: f32) -> f32 {
|
||||
linear_to_srgb(v)
|
||||
}
|
||||
|
||||
fn srgb_decode(v: f32) -> f32 {
|
||||
srgb_to_linear(v)
|
||||
}
|
||||
|
||||
fn luma_linear(c: [f32; 3]) -> f32 {
|
||||
luma(c)
|
||||
}
|
||||
|
||||
/// One colour grading wheel.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
|
|
@ -136,6 +169,9 @@ pub struct CameraRaw {
|
|||
pub seed: u32,
|
||||
/// Pixel-size multiplier for previews on downsampled proxies (radii in px scale by it).
|
||||
pub pixel_scale: f32,
|
||||
/// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8
|
||||
/// for ProPhoto). Set from the document's profile; Whites and Blacks act on light.
|
||||
pub encoding_gamma: f32,
|
||||
}
|
||||
|
||||
impl Default for CameraRaw {
|
||||
|
|
@ -191,6 +227,7 @@ impl Default for CameraRaw {
|
|||
vignette_style: "highlightPriority".into(),
|
||||
seed: 0,
|
||||
pixel_scale: 1.0,
|
||||
encoding_gamma: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -491,7 +528,11 @@ impl CameraRaw {
|
|||
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
|
||||
}
|
||||
fn tone_neutral(&self) -> bool {
|
||||
[self.contrast, self.highlights, self.shadows, self.whites, self.blacks, self.texture, self.clarity].iter().all(|v| *v == 0.0)
|
||||
self.local_tone_neutral() && self.whites == 0.0 && self.blacks == 0.0
|
||||
}
|
||||
/// Contrast, Highlights, Shadows, Texture and Clarity (the luminance stage).
|
||||
fn local_tone_neutral(&self) -> bool {
|
||||
[self.contrast, self.highlights, self.shadows, self.texture, self.clarity].iter().all(|v| *v == 0.0)
|
||||
}
|
||||
fn hsl_neutral(&self) -> bool {
|
||||
self.hsl_hue.iter().chain(&self.hsl_sat).chain(&self.hsl_lum).all(|v| *v == 0.0)
|
||||
|
|
@ -561,16 +602,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
|||
if p.dehaze != 0.0 {
|
||||
dehaze(px, w, h, p.dehaze / 100.0);
|
||||
}
|
||||
// 3. Tone on luminance.
|
||||
if !p.tone_neutral() {
|
||||
// 3. Tone. Highlights and Shadows: a local Laplacian filter (see `local_tone`).
|
||||
if p.highlights != 0.0 || p.shadows != 0.0 {
|
||||
local_tone::apply(px, w, h, p.shadows, p.highlights, Encoding::of(p.encoding_gamma));
|
||||
}
|
||||
if !p.local_tone_neutral() && [p.contrast, p.texture, p.clarity].iter().any(|v| *v != 0.0) {
|
||||
let l: Vec<f32> = px.iter().map(|q| luma([q[0], q[1], q[2]]).max(0.0)).collect();
|
||||
let lc: Vec<f32> = l.iter().map(|v| v.min(1.0)).collect();
|
||||
let base = if p.highlights != 0.0 || p.shadows != 0.0 {
|
||||
let r = ((long * 0.015) as usize).max(2);
|
||||
Some(guided_fast(&lc, &lc, w, h, r, 0.02))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005));
|
||||
let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps)));
|
||||
let k_con = p.contrast / 100.0 * 0.6;
|
||||
|
|
@ -580,12 +618,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
|||
let i = y * w + x;
|
||||
let l0 = l[i];
|
||||
let mut lv = lc[i];
|
||||
if let Some(b) = &base {
|
||||
let bv = b[i].clamp(0.0, 1.0);
|
||||
let hm = smoothstep(0.45, 0.95, bv);
|
||||
let sm = 1.0 - smoothstep(0.05, 0.5, bv);
|
||||
lv *= 2f32.powf(p.highlights / 100.0 * 1.2 * hm + p.shadows / 100.0 * 1.4 * sm);
|
||||
}
|
||||
if let Some(c) = &clar {
|
||||
let mw = 1.0 - (2.0 * lv.clamp(0.0, 1.0) - 1.0).powi(2);
|
||||
lv += p.clarity / 100.0 * 0.8 * (lc[i] - c[i]) * mw;
|
||||
|
|
@ -593,12 +625,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
|||
if let Some(t) = &tex {
|
||||
lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1);
|
||||
}
|
||||
if p.whites != 0.0 {
|
||||
lv += p.whites / 100.0 * 0.25 * smoothstep(0.4, 1.0, lv);
|
||||
}
|
||||
if p.blacks != 0.0 {
|
||||
lv += p.blacks / 100.0 * 0.15 * (1.0 - smoothstep(0.0, 0.45, lv));
|
||||
}
|
||||
if k_con != 0.0 {
|
||||
let xx = lv.clamp(0.0, 1.0);
|
||||
lv += -k_con * (tau * xx).sin() / tau;
|
||||
|
|
@ -611,6 +637,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
|||
}
|
||||
});
|
||||
}
|
||||
// 3b. Whites and Blacks: global curves adapted to the image's black and white points.
|
||||
if p.whites != 0.0 || p.blacks != 0.0 {
|
||||
let enc = Encoding::of(p.encoding_gamma);
|
||||
let (bp, wp) = luminance_points(px, w, h, enc);
|
||||
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
|
||||
whites_blacks::apply(px, w, xw, xb, enc);
|
||||
}
|
||||
// 4–7: per-pixel colour stages.
|
||||
let need_color = p.vibrance != 0.0 || p.saturation != 0.0 || !p.curves_neutral() || !p.hsl_neutral() || !p.grade_neutral();
|
||||
if need_color {
|
||||
|
|
@ -732,6 +765,36 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
|||
}
|
||||
}
|
||||
|
||||
/// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the
|
||||
/// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a
|
||||
/// preview proxy and the full image agree).
|
||||
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) {
|
||||
let f = w.max(h).div_ceil(256).max(1);
|
||||
let (sw, sh) = (w.div_ceil(f), h.div_ceil(f));
|
||||
let mut sum = vec![[0.0f64; 4]; sw * sh];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let q = px[y * w + x];
|
||||
let s = &mut sum[(y / f) * sw + x / f];
|
||||
for c in 0..3 {
|
||||
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
|
||||
}
|
||||
s[3] += 1.0;
|
||||
}
|
||||
}
|
||||
let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
|
||||
if cells.is_empty() {
|
||||
return (0.0, 1.0);
|
||||
}
|
||||
let pct = |mut v: Vec<f32>, q: f32| {
|
||||
v.sort_by(f32::total_cmp);
|
||||
v[((v.len() - 1) as f32 * q).round() as usize]
|
||||
};
|
||||
let bp = pct(cells.iter().map(|c| luma(*c)).collect(), 0.03);
|
||||
let wp = pct(cells.iter().map(|c| c[0].max(c[1]).max(c[2])).collect(), 0.995);
|
||||
(linear_to_srgb(bp), linear_to_srgb(wp))
|
||||
}
|
||||
|
||||
fn value_noise(x: f32, y: f32, seed: u64) -> f32 {
|
||||
let (xi, yi) = (x.floor(), y.floor());
|
||||
let (tx, ty) = (x - xi, y - yi);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,428 @@
|
|||
//! Shadows and Highlights as a local Laplacian filter.
|
||||
//!
|
||||
//! Adobe has said Camera Raw's process version 2012 rests on local Laplacian filters (S. Paris,
|
||||
//! S. W. Hasinoff, J. Kautz, *Local Laplacian Filters: Edge-aware Image Processing with a
|
||||
//! Laplacian Pyramid*, SIGGRAPH 2011). This follows that paper, in its fast form (M. Aubry et
|
||||
//! al., *Fast Local Laplacian Filters*, ACM TOG 2014: the remapping sampled at a few reference
|
||||
//! values, each pyramid coefficient interpolated between them), on the log2 luminance:
|
||||
//!
|
||||
//! - **Shadows** raises pixels darker than their surroundings, by an amount set by their own and
|
||||
//! their surroundings' place in the image's range (1st to 99th percentile); **Highlights**
|
||||
//! pulls pixels brighter than their surroundings toward them. Differences under a threshold
|
||||
//! are left alone, so fine detail stays.
|
||||
//! - Shadows then keeps the image's 99th percentile, Highlights its 1st (Photoshop keeps the
|
||||
//! brightest content under Shadows and the darkest under Highlights).
|
||||
//! - A uniform image is unchanged, edges keep no halo, and the result does not depend on the
|
||||
//! image size.
|
||||
//!
|
||||
//! The strengths were fitted to Photoshop 25.4's Camera Raw Filter on ten public-domain photos
|
||||
//! (black-box measurements, no Adobe code or data). It is an approximation: on those photos the
|
||||
//! mean error against Photoshop is 2.8–9.4 levels (of 255) where the previous heuristic's was
|
||||
//! 3.7–14.3.
|
||||
|
||||
use super::{Encoding, luma};
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
/// Spacing of the fast filter's reference values in stops. Its remapping changes within about
|
||||
/// half a stop, and coefficients interpolated across wider gaps show as contour lines on smooth
|
||||
/// gradients (12 references over a whole raw's range left visible steps; at an eighth of a stop a
|
||||
/// ramp's slope varies by under a third, as the remapping itself makes it).
|
||||
const REF_STEP: f32 = 0.125;
|
||||
/// The filter runs on a copy at most this many pixels across; the change it makes is brought back
|
||||
/// to full size by a guided upsampling, so a preview and the full image agree.
|
||||
const WORK_SIDE: usize = 1600;
|
||||
/// Knots of the strength curves over the surroundings' place in the image range (0 … 1).
|
||||
const KNOTS: usize = 5;
|
||||
|
||||
/// Shadows: how far a pixel darker than its surroundings rises, in stops per unit slider, as
|
||||
/// a function of its own place in the image range (`own`) times a factor of its surroundings'
|
||||
/// place (`around`); it rises fully once it is `sigma` stops below them. The lift depends on
|
||||
/// the pixel's tone, not on the size of the difference, so it is the same at every pyramid
|
||||
/// level and a big edge keeps no halo. Lowering (negative values) is `down` times as strong.
|
||||
const SHADOWS: Lift = Lift { own: [-0.017, 0.466, 0.886, 1.221, -0.081], around: [4.566, 4.067, 1.088, 0.149, 0.878], sigma: 0.571, down: 0.808 };
|
||||
const HIGHLIGHTS: Side = Side { pull: [0.179, 0.141, 0.196, 0.629, 2.07], push: [0.156, 0.08, 0.175, 0.049, 1.261], sigma: 0.05 };
|
||||
|
||||
/// See [`SHADOWS`].
|
||||
struct Lift {
|
||||
own: [f32; KNOTS],
|
||||
around: [f32; KNOTS],
|
||||
sigma: f32,
|
||||
down: f32,
|
||||
}
|
||||
|
||||
/// Highlights: strengths per unit slider over the surroundings' place in the image range, for
|
||||
/// lowering (`pull`, toward the surroundings) and raising (`push`), and the detail threshold in
|
||||
/// stops.
|
||||
struct Side {
|
||||
pull: [f32; KNOTS],
|
||||
push: [f32; KNOTS],
|
||||
sigma: f32,
|
||||
}
|
||||
|
||||
/// One channel, `w × h`.
|
||||
struct Plane {
|
||||
w: usize,
|
||||
h: usize,
|
||||
v: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Mirrors `i` into `0..n` (the edge sample is not repeated).
|
||||
fn mirror(i: isize, n: usize) -> usize {
|
||||
let n = n as isize;
|
||||
if n == 1 {
|
||||
return 0;
|
||||
}
|
||||
let p = 2 * (n - 1);
|
||||
let m = i.rem_euclid(p);
|
||||
(if m >= n { p - m } else { m }) as usize
|
||||
}
|
||||
|
||||
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
|
||||
|
||||
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
|
||||
fn blur_step(p: &Plane, step: usize) -> Plane {
|
||||
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
|
||||
// Columns first at the kept rows, then rows at the kept columns.
|
||||
let mut tmp = vec![0.0f32; p.w * h2];
|
||||
par_rows(&mut tmp, p.w, 1, |y2, row| {
|
||||
let y = (y2 * step) as isize;
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
|
||||
}
|
||||
});
|
||||
let mut out = vec![0.0f32; w2 * h2];
|
||||
par_rows(&mut out, w2, 1, |y2, row| {
|
||||
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
|
||||
for (x2, o) in row.iter_mut().enumerate() {
|
||||
let x = (x2 * step) as isize;
|
||||
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
|
||||
}
|
||||
});
|
||||
Plane { w: w2, h: h2, v: out }
|
||||
}
|
||||
|
||||
/// The next coarser level.
|
||||
fn down(p: &Plane) -> Plane {
|
||||
blur_step(p, 2)
|
||||
}
|
||||
|
||||
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
|
||||
fn up(p: &Plane, w: usize, h: usize) -> Plane {
|
||||
let mut z = Plane { w, h, v: vec![0.0; w * h] };
|
||||
for y in 0..p.h.min(h.div_ceil(2)) {
|
||||
for x in 0..p.w.min(w.div_ceil(2)) {
|
||||
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
|
||||
}
|
||||
}
|
||||
blur_step(&z, 1)
|
||||
}
|
||||
|
||||
/// Levels down to about 2 pixels on the short side.
|
||||
fn depth(w: usize, h: usize) -> usize {
|
||||
let mut s = w.min(h);
|
||||
let mut n = 1;
|
||||
while s > 2 {
|
||||
s = s.div_ceil(2);
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn gaussian(p: Plane, n: usize) -> Vec<Plane> {
|
||||
let mut g = vec![p];
|
||||
for _ in 1..n {
|
||||
let next = down(&g[g.len() - 1]);
|
||||
g.push(next);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// The local Laplacian filter of `u` with `remap(value, reference)`; the coarsest level is kept.
|
||||
fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
|
||||
let n = depth(u.w, u.h);
|
||||
let g = gaussian(Plane { w: u.w, h: u.h, v: u.v.clone() }, n);
|
||||
// References span the image's tones; the deepest 0.1 % (noise down to the floor) share the
|
||||
// lowest reference instead of stretching the spacing.
|
||||
let hi = u.v.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
|
||||
let lo = quantile(&u.v, 0.001).min(hi);
|
||||
if hi - lo <= 1e-6 || (hi - lo).is_nan() {
|
||||
return Plane { w: u.w, h: u.h, v: u.v.clone() };
|
||||
}
|
||||
let refs = (((hi - lo) / REF_STEP).ceil() as usize + 1).clamp(2, 96);
|
||||
let step = (hi - lo) / (refs - 1) as f32;
|
||||
let mut lap: Vec<Vec<f32>> = g[..n - 1].iter().map(|p| vec![0.0; p.v.len()]).collect();
|
||||
for k in 0..refs {
|
||||
let r = lo + step * k as f32;
|
||||
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
|
||||
par_rows(&mut m.v, u.w, 1, |_, row| {
|
||||
for v in row.iter_mut() {
|
||||
*v = remap(*v, r);
|
||||
}
|
||||
});
|
||||
let gr = gaussian(m, n);
|
||||
for l in 0..n - 1 {
|
||||
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
|
||||
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
|
||||
par_rows(&mut lap[l], lw, 1, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = y * lw + x;
|
||||
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
|
||||
if wgt > 0.0 {
|
||||
*o += wgt * (fine[i] - coarse.v[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut o = Plane { w: g[n - 1].w, h: g[n - 1].h, v: g[n - 1].v.clone() };
|
||||
for l in (0..n - 1).rev() {
|
||||
let mut next = up(&o, g[l].w, g[l].h);
|
||||
for (a, b) in next.v.iter_mut().zip(&lap[l]) {
|
||||
*a += b;
|
||||
}
|
||||
o = next;
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The `q` quantile (0 … 1) of `v`.
|
||||
fn quantile(v: &[f32], q: f32) -> f32 {
|
||||
let mut s = v.to_vec();
|
||||
s.sort_by(f32::total_cmp);
|
||||
s[((s.len() - 1) as f32 * q).round() as usize]
|
||||
}
|
||||
|
||||
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
|
||||
fn range(u: &Plane) -> (f32, f32) {
|
||||
let f = u.w.max(u.h).div_ceil(256).max(1);
|
||||
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
|
||||
let mut sum = vec![(0.0f64, 0u32); sw * sh];
|
||||
for y in 0..u.h {
|
||||
for x in 0..u.w {
|
||||
let c = &mut sum[(y / f) * sw + x / f];
|
||||
c.0 += f64::from(u.v[y * u.w + x]);
|
||||
c.1 += 1;
|
||||
}
|
||||
}
|
||||
let cells: Vec<f32> = sum.iter().filter(|c| c.1 > 0).map(|c| (c.0 / f64::from(c.1)) as f32).collect();
|
||||
(quantile(&cells, 0.01), quantile(&cells, 0.99))
|
||||
}
|
||||
|
||||
/// `d` past the detail threshold: about 0 below `sigma`, `d − sigma` well above it.
|
||||
fn excess(d: f32, sigma: f32) -> f32 {
|
||||
d - sigma * (d / sigma).tanh()
|
||||
}
|
||||
|
||||
fn knot(t: f32, k: &[f32; KNOTS]) -> f32 {
|
||||
let x = t.clamp(0.0, 1.0) * (KNOTS - 1) as f32;
|
||||
let i = (x as usize).min(KNOTS - 2);
|
||||
k[i] + (k[i + 1] - k[i]) * (x - i as f32)
|
||||
}
|
||||
|
||||
/// The Highlights pass on the log luminance `u` (`amount` −1 … 1): pixels brighter than their
|
||||
/// surroundings move toward them (or away, for positive values) by the part of the difference
|
||||
/// past `sigma`, then the darkest content is put back where it was.
|
||||
fn highlights_pass(u: &Plane, amount: f32, side: &Side) -> Plane {
|
||||
let (lo, hi) = range(u);
|
||||
let span = (hi - lo).max(1.0);
|
||||
let strengths = if amount < 0.0 { &side.pull } else { &side.push };
|
||||
let o = llf(u, |x, g| x + knot((g - lo) / span, strengths) * amount * excess((x - g).max(0.0), side.sigma));
|
||||
let shift = quantile(&u.v, 0.01) - quantile(&o.v, 0.01);
|
||||
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
|
||||
}
|
||||
|
||||
/// The Shadows pass on the log luminance `u` (`amount` −1 … 1).
|
||||
fn shadows_pass(u: &Plane, amount: f32) -> Plane {
|
||||
let (lo, hi) = range(u);
|
||||
let span = (hi - lo).max(1.0);
|
||||
let k = if amount > 0.0 { amount } else { amount * SHADOWS.down };
|
||||
let o = llf(u, |x, g| {
|
||||
let below = ((g - x) / SHADOWS.sigma).clamp(0.0, 1.0);
|
||||
if below == 0.0 {
|
||||
return x;
|
||||
}
|
||||
let w = below * below * (3.0 - 2.0 * below);
|
||||
x + k * knot((x - lo) / span, &SHADOWS.own) * knot((g - lo) / span, &SHADOWS.around) * w
|
||||
});
|
||||
// Keep the brightest content.
|
||||
let shift = quantile(&u.v, 0.99) - quantile(&o.v, 0.99);
|
||||
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
|
||||
}
|
||||
|
||||
/// Applies Shadows and Highlights (−100 … 100) to straight RGBA pixels encoded with `enc`.
|
||||
pub(crate) fn apply(px: &mut [[f32; 4]], w: usize, h: usize, shadows: f32, highlights: f32, enc: Encoding) {
|
||||
if (shadows == 0.0 && highlights == 0.0) || w < 2 || h < 2 {
|
||||
return;
|
||||
}
|
||||
const FLOOR: f32 = 1.0 / 65536.0;
|
||||
let u = Plane { w, h, v: px.iter().map(|q| (luma([0, 1, 2].map(|c| enc.decode(q[c]))).max(0.0) + FLOOR).log2()).collect() };
|
||||
// The filter on a copy at most WORK_SIDE across (box-averaged in log).
|
||||
let f = w.max(h).div_ceil(WORK_SIDE).max(1);
|
||||
let small = if f == 1 { Plane { w, h, v: u.v.clone() } } else { shrink(&u, f) };
|
||||
let mut o = Plane { w: small.w, h: small.h, v: small.v.clone() };
|
||||
if highlights != 0.0 {
|
||||
o = highlights_pass(&o, highlights.clamp(-100.0, 100.0) / 100.0, &HIGHLIGHTS);
|
||||
}
|
||||
if shadows != 0.0 {
|
||||
o = shadows_pass(&o, shadows.clamp(-100.0, 100.0) / 100.0);
|
||||
}
|
||||
// The change in log luminance, at full size.
|
||||
let change: Vec<f32> = if f == 1 {
|
||||
o.v.iter().zip(&u.v).map(|(a, b)| a - b).collect()
|
||||
} else {
|
||||
let d: Vec<f32> = o.v.iter().zip(&small.v).map(|(a, b)| a - b).collect();
|
||||
guided_upsample(&small, &d, &u)
|
||||
};
|
||||
par_rows(px, w, 1, |y, row| {
|
||||
for (x, q) in row.iter_mut().enumerate() {
|
||||
let gain = change[y * w + x].exp2();
|
||||
for c in 0..3 {
|
||||
q[c] = enc.encode(enc.decode(q[c]) * gain);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `u` averaged over `f × f` blocks.
|
||||
fn shrink(u: &Plane, f: usize) -> Plane {
|
||||
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
|
||||
let mut v = vec![0.0f32; sw * sh];
|
||||
par_rows(&mut v, sw, 1, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let (x0, y0) = (x * f, y * f);
|
||||
let (x1, y1) = ((x0 + f).min(u.w), (y0 + f).min(u.h));
|
||||
let mut s = 0.0f64;
|
||||
for yy in y0..y1 {
|
||||
for xx in x0..x1 {
|
||||
s += f64::from(u.v[yy * u.w + xx]);
|
||||
}
|
||||
}
|
||||
*o = (s / ((x1 - x0) * (y1 - y0)) as f64) as f32;
|
||||
}
|
||||
});
|
||||
Plane { w: sw, h: sh, v }
|
||||
}
|
||||
|
||||
/// The change `d` computed on `small` brought to the size of `full`, following its edges: a local
|
||||
/// linear model of the change against the log luminance (the guided filter of K. He, J. Sun,
|
||||
/// *Fast Guided Filter*, 2015), fitted on the small copy and evaluated on the full one.
|
||||
fn guided_upsample(small: &Plane, d: &[f32], full: &Plane) -> Vec<f32> {
|
||||
let (sw, sh) = (small.w, small.h);
|
||||
let r = 2;
|
||||
let eps = 0.01;
|
||||
let mean = |v: &[f32]| super::box_mean(v, sw, sh, r);
|
||||
let ud: Vec<f32> = small.v.iter().zip(d).map(|(a, b)| a * b).collect();
|
||||
let uu: Vec<f32> = small.v.iter().map(|a| a * a).collect();
|
||||
let (mu, md, mud, muu) = (mean(&small.v), mean(d), mean(&ud), mean(&uu));
|
||||
let a: Vec<f32> = (0..sw * sh).map(|i| (mud[i] - mu[i] * md[i]) / (muu[i] - mu[i] * mu[i] + eps)).collect();
|
||||
let b: Vec<f32> = (0..sw * sh).map(|i| md[i] - a[i] * mu[i]).collect();
|
||||
let (ma, mb) = (mean(&a), mean(&b));
|
||||
let (fx, fy) = (sw as f32 / full.w as f32, sh as f32 / full.h as f32);
|
||||
let mut out = vec![0.0f32; full.w * full.h];
|
||||
par_rows(&mut out, full.w, 1, |y, row| {
|
||||
let sy = (y as f32 + 0.5) * fy - 0.5;
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let sx = (x as f32 + 0.5) * fx - 0.5;
|
||||
let av = crate::photo_util::bilinear(&ma, sw, sh, 1, 0, sx, sy);
|
||||
let bv = crate::photo_util::bilinear(&mb, sw, sh, 1, 0, sx, sy);
|
||||
*o = av * full.v[y * full.w + x] + bv;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SRGB: Encoding = Encoding(0.0);
|
||||
|
||||
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
|
||||
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_image_is_unchanged() {
|
||||
let mut px = grey(64, 48, |_, _| 0.4);
|
||||
apply(&mut px, 64, 48, 100.0, -100.0, SRGB);
|
||||
assert!(px.iter().all(|q| (q[0] - 0.4).abs() < 1e-5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_raise_a_dark_area_without_a_halo() {
|
||||
// A 0.1 | 0.6 step (the dark half is the image's black point, which Photoshop barely
|
||||
// moves either): the bright half stays, and no halo forms on either side of the edge.
|
||||
let (w, h) = (256, 128);
|
||||
let mut px = grey(w, h, |x, _| if x < w / 2 { 0.1 } else { 0.6 });
|
||||
apply(&mut px, w, h, 100.0, 0.0, SRGB);
|
||||
let row = &px[64 * w..65 * w];
|
||||
assert!((row[w / 2 + 1][0] - 0.6).abs() < 0.002 && (row[w - 5][0] - 0.6).abs() < 0.002);
|
||||
let (far, near) = (row[10][0], row[w / 2 - 1][0]);
|
||||
assert!((far - 0.1).abs() < 0.004 && (far - near).abs() < 0.004, "{far} {near}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grey_moves_as_in_photoshop() {
|
||||
// Mid-grey with 1 % black (0.015) and white (0.97) corners, Photoshop 25.4: Shadows +100
|
||||
// takes the grey from 127.5 to 165.3, Highlights −100 to 89.6 (of 255).
|
||||
let n = 256;
|
||||
let image = |c: f32| {
|
||||
grey(n, n, |x, y| {
|
||||
if x >= n - 26 && y >= n - 26 {
|
||||
0.97
|
||||
} else if x >= n - 26 && y < 26 {
|
||||
0.015
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
};
|
||||
for (s, hl, want) in [(100.0, 0.0, 165.3), (0.0, -100.0, 89.6)] {
|
||||
let mut px = image(0.5);
|
||||
apply(&mut px, n, n, s, hl, SRGB);
|
||||
let got = px[175 * n + 100][0] * 255.0;
|
||||
assert!((got - want).abs() < 5.0, "{s} {hl}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_lower_a_bright_area_and_keep_the_darkest() {
|
||||
let (w, h) = (192, 192);
|
||||
let mut px = grey(w, h, |x, y| if (64..128).contains(&x) && (64..128).contains(&y) { 0.8 } else { 0.2 });
|
||||
apply(&mut px, w, h, 0.0, -100.0, SRGB);
|
||||
assert!(px[96 * w + 96][0] < 0.75, "{}", px[96 * w + 96][0]);
|
||||
assert!((px[5 * w + 5][0] - 0.2).abs() < 0.01, "{}", px[5 * w + 5][0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_gradients_stay_smooth() {
|
||||
// A soft ramp over eight stops, as in an out-of-focus background. Coefficients interpolated
|
||||
// between references too far apart drew contour lines across such ramps (Shadows +100 on
|
||||
// a D4 raw): the output must rise steadily, without ripples.
|
||||
let (w, h) = (640, 48);
|
||||
let mut px = grey(w, h, |x, _| crate::photo_util::linear_to_srgb((x as f32 / (w - 1) as f32 * 8.0 - 9.0).exp2()));
|
||||
for (s, hl) in [(100.0, 0.0), (0.0, -100.0), (-100.0, 100.0)] {
|
||||
let mut p = px.clone();
|
||||
apply(&mut p, w, h, s, hl, SRGB);
|
||||
let row: Vec<f32> = (0..w).map(|x| crate::photo_util::srgb_to_linear(p[24 * w + x][0]).max(1e-6).log2()).collect();
|
||||
let steps: Vec<f32> = row.windows(2).map(|d| d[1] - d[0]).collect();
|
||||
assert!(steps.iter().all(|d| *d > 0.0), "{s} {hl}: the ramp reverses");
|
||||
let jumps: Vec<f32> = steps.windows(2).map(|d| (d[1] - d[0]).abs()).collect();
|
||||
let ripple = jumps[16..jumps.len() - 16].iter().fold(0.0f32, |a, b| a.max(*b));
|
||||
// The ramp rises 0.0125 stops per pixel; its slope may change by a third at most.
|
||||
assert!(ripple < 0.004, "{s} {hl}: slope jumps by {ripple} stops per pixel");
|
||||
}
|
||||
px.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_result_does_not_depend_on_the_image_size() {
|
||||
let at = |n: usize| {
|
||||
let mut px = grey(n, n, |x, y| if x < n / 3 || y < n / 4 { 0.08 } else { 0.7 });
|
||||
apply(&mut px, n, n, 70.0, -60.0, SRGB);
|
||||
[px[(n / 8) * n + n / 8][0], px[(3 * n / 4) * n + 3 * n / 4][0]]
|
||||
};
|
||||
let (a, b) = (at(96), at(384));
|
||||
assert!((a[0] - b[0]).abs() < 0.02 && (a[1] - b[1]).abs() < 0.02, "{a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
//! Whites and Blacks as Adobe Camera Raw (process version 2012) applies them.
|
||||
//!
|
||||
//! Measured as a black box on the Camera Raw Filter of Photoshop 25.4: grey blocks with chosen
|
||||
//! histograms went through the filter by script and came back exactly; no Adobe code or data
|
||||
//! was used.
|
||||
//!
|
||||
//! - Both are global curves of the grey level, applied after Shadows and Highlights, Whites
|
||||
//! first. On an image whose darkest and lightest percent reach black and white the curve of a
|
||||
//! slider value `x` is the measured row at `x` of [`WHITES`] or [`BLACKS`].
|
||||
//! - The direction that only compresses (Whites down, Blacks up) is that fixed curve on every
|
||||
//! image. The direction that clips (Whites up, Blacks down) adapts: the row used is
|
||||
//! `x · A(BP, WP)`. On the block images BP and WP are their darkest and lightest levels; on
|
||||
//! photographs Photoshop's amounts follow best with BP the 3rd percentile of the luminance and
|
||||
//! WP the 99.5th of the brightest channel, and Blacks at least 1.2 times. A low
|
||||
//! white point makes Whites +100 stronger (up to 2.7 times), a raised black point Blacks
|
||||
//! (up to 1.45 times at −40). Whites rows past +100 come from images where `A > 1`.
|
||||
//! - Not reproduced: past −40 Photoshop's Blacks grows faster on images with a raised black
|
||||
//! point (on one spanning 0.6–1 its −100 blacks out everything below 0.74, here below about
|
||||
//! 0.15); its curves there are not rows of the full-range family.
|
||||
//! - Colour: the clipping directions act on each channel, the compressing ones scale the pixel
|
||||
//! by the curve's change of its luminance (closest to Photoshop on saturated colours).
|
||||
|
||||
use super::Encoding;
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
/// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded).
|
||||
const STEPS: usize = 33;
|
||||
/// The first row's internal amount; rows are 10 apart.
|
||||
const WHITES_FROM: f32 = -100.0;
|
||||
const BLACKS_FROM: f32 = -100.0;
|
||||
// Measured levels; some happen to lie near π/8 and π/4.
|
||||
/// Whites curves at internal amounts -100, -90, …, 270 (rows) over grey levels 0, 1/32, …, 1.
|
||||
#[allow(clippy::approx_constant)]
|
||||
static WHITES: [[f32; 33]; 38] = [
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2391, 0.2671, 0.2946, 0.3215, 0.3480, 0.3738, 0.3995, 0.4256, 0.4517, 0.4774, 0.5028,
|
||||
0.5278, 0.5524, 0.5768, 0.6008, 0.6245, 0.6482, 0.6717, 0.6957, 0.7207, 0.7483, 0.7814, 0.8263, 0.8949, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2392, 0.2672, 0.2947, 0.3217, 0.3482, 0.3742, 0.4000, 0.4263, 0.4526, 0.4785, 0.5042,
|
||||
0.5296, 0.5548, 0.5797, 0.6044, 0.6291, 0.6539, 0.6789, 0.7046, 0.7317, 0.7617, 0.7969, 0.8425, 0.9074, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2108, 0.2393, 0.2673, 0.2950, 0.3221, 0.3487, 0.3748, 0.4008, 0.4274, 0.4540, 0.4803, 0.5064,
|
||||
0.5323, 0.5580, 0.5837, 0.6093, 0.6350, 0.6610, 0.6875, 0.7149, 0.7438, 0.7756, 0.8122, 0.8576, 0.9184, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2109, 0.2395, 0.2677, 0.2954, 0.3227, 0.3495, 0.3759, 0.4022, 0.4291, 0.4561, 0.4828, 0.5094,
|
||||
0.5360, 0.5624, 0.5888, 0.6154, 0.6423, 0.6695, 0.6973, 0.7261, 0.7566, 0.7897, 0.8271, 0.8716, 0.9280, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1525, 0.1820, 0.2111, 0.2398, 0.2682, 0.2961, 0.3237, 0.3508, 0.3775, 0.4042, 0.4317, 0.4591, 0.4864, 0.5136,
|
||||
0.5408, 0.5680, 0.5953, 0.6229, 0.6508, 0.6791, 0.7082, 0.7382, 0.7698, 0.8037, 0.8412, 0.8843, 0.9365, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0927, 0.1227, 0.1527, 0.1822, 0.2116, 0.2404, 0.2690, 0.2972, 0.3251, 0.3527, 0.3799, 0.4071, 0.4351, 0.4630, 0.4910, 0.5189,
|
||||
0.5469, 0.5749, 0.6031, 0.6316, 0.6605, 0.6899, 0.7199, 0.7509, 0.7832, 0.8174, 0.8544, 0.8958, 0.9439, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0927, 0.1229, 0.1529, 0.1827, 0.2122, 0.2414, 0.2702, 0.2989, 0.3272, 0.3553, 0.3831, 0.4110, 0.4396, 0.4682, 0.4968, 0.5254,
|
||||
0.5542, 0.5830, 0.6121, 0.6414, 0.6712, 0.7014, 0.7322, 0.7637, 0.7964, 0.8305, 0.8668, 0.9062, 0.9503, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0622, 0.0928, 0.1231, 0.1534, 0.1834, 0.2132, 0.2427, 0.2720, 0.3011, 0.3300, 0.3587, 0.3872, 0.4160, 0.4452, 0.4745, 0.5038, 0.5332,
|
||||
0.5627, 0.5923, 0.6221, 0.6522, 0.6826, 0.7134, 0.7447, 0.7765, 0.8092, 0.8429, 0.8781, 0.9155, 0.9559, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0623, 0.0930, 0.1236, 0.1541, 0.1844, 0.2145, 0.2445, 0.2744, 0.3041, 0.3337, 0.3632, 0.3925, 0.4221, 0.4520, 0.4820, 0.5120, 0.5421,
|
||||
0.5723, 0.6026, 0.6330, 0.6636, 0.6946, 0.7257, 0.7572, 0.7890, 0.8214, 0.8545, 0.8885, 0.9237, 0.9608, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0624, 0.0934, 0.1242, 0.1551, 0.1858, 0.2164, 0.2470, 0.2775, 0.3080, 0.3383, 0.3687, 0.3989, 0.4293, 0.4600, 0.4906, 0.5213, 0.5520,
|
||||
0.5827, 0.6136, 0.6445, 0.6755, 0.7067, 0.7380, 0.7694, 0.8011, 0.8330, 0.8652, 0.8978, 0.9310, 0.9651, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
|
||||
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0626, 0.0940, 0.1253, 0.1567, 0.1881, 0.2195, 0.2510, 0.2825, 0.3140, 0.3456, 0.3772, 0.4089, 0.4405, 0.4720, 0.5036, 0.5351, 0.5667,
|
||||
0.5983, 0.6298, 0.6613, 0.6929, 0.7244, 0.7559, 0.7874, 0.8189, 0.8503, 0.8816, 0.9128, 0.9438, 0.9742, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0627, 0.0941, 0.1257, 0.1573, 0.1890, 0.2208, 0.2526, 0.2846, 0.3167, 0.3488, 0.3811, 0.4134, 0.4456, 0.4777, 0.5098, 0.5419, 0.5741,
|
||||
0.6062, 0.6383, 0.6704, 0.7025, 0.7345, 0.7664, 0.7983, 0.8301, 0.8617, 0.8931, 0.9241, 0.9543, 0.9829, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0628, 0.0944, 0.1262, 0.1582, 0.1903, 0.2225, 0.2550, 0.2876, 0.3204, 0.3534, 0.3867, 0.4200, 0.4529, 0.4858, 0.5186, 0.5516, 0.5845,
|
||||
0.6174, 0.6502, 0.6830, 0.7157, 0.7482, 0.7807, 0.8131, 0.8451, 0.8769, 0.9082, 0.9385, 0.9673, 0.9928, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0629, 0.0948, 0.1269, 0.1594, 0.1920, 0.2249, 0.2582, 0.2917, 0.3256, 0.3597, 0.3942, 0.4289, 0.4628, 0.4966, 0.5305, 0.5644, 0.5983,
|
||||
0.6321, 0.6658, 0.6994, 0.7327, 0.7660, 0.7989, 0.8316, 0.8639, 0.8956, 0.9263, 0.9553, 0.9815, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0631, 0.0953, 0.1280, 0.1610, 0.1944, 0.2282, 0.2625, 0.2973, 0.3325, 0.3683, 0.4045, 0.4407, 0.4758, 0.5109, 0.5460, 0.5811, 0.6161,
|
||||
0.6510, 0.6857, 0.7201, 0.7541, 0.7879, 0.8213, 0.8543, 0.8864, 0.9175, 0.9468, 0.9733, 0.9950, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0633, 0.0961, 0.1293, 0.1632, 0.1976, 0.2327, 0.2684, 0.3048, 0.3419, 0.3798, 0.4183, 0.4562, 0.4928, 0.5294, 0.5660, 0.6024, 0.6387,
|
||||
0.6748, 0.7104, 0.7456, 0.7803, 0.8145, 0.8480, 0.8807, 0.9121, 0.9417, 0.9684, 0.9907, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0300, 0.0637, 0.0971, 0.1313, 0.1663, 0.2020, 0.2387, 0.2763, 0.3149, 0.3546, 0.3952, 0.4365, 0.4763, 0.5149, 0.5532, 0.5914, 0.6294, 0.6670,
|
||||
0.7041, 0.7406, 0.7763, 0.8113, 0.8456, 0.8788, 0.9104, 0.9401, 0.9667, 0.9888, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0300, 0.0641, 0.0984, 0.1338, 0.1703, 0.2079, 0.2468, 0.2870, 0.3286, 0.3717, 0.4161, 0.4602, 0.5024, 0.5430, 0.5833, 0.6232, 0.6627, 0.7015,
|
||||
0.7394, 0.7764, 0.8123, 0.8472, 0.8808, 0.9126, 0.9420, 0.9681, 0.9896, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0302, 0.0647, 0.1003, 0.1374, 0.1760, 0.2160, 0.2579, 0.3016, 0.3472, 0.3949, 0.4436, 0.4908, 0.5355, 0.5784, 0.6207, 0.6622, 0.7029, 0.7424,
|
||||
0.7806, 0.8176, 0.8530, 0.8868, 0.9185, 0.9474, 0.9726, 0.9926, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0304, 0.0656, 0.1029, 0.1423, 0.1837, 0.2271, 0.2731, 0.3215, 0.3726, 0.4262, 0.4791, 0.5294, 0.5769, 0.6220, 0.6660, 0.7086, 0.7498, 0.7893,
|
||||
0.8272, 0.8631, 0.8969, 0.9281, 0.9560, 0.9795, 0.9967, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0305, 0.0679, 0.1094, 0.1509, 0.1968, 0.2459, 0.2956, 0.3510, 0.4094, 0.4680, 0.5248, 0.5781, 0.6278, 0.6742, 0.7195, 0.7624, 0.8031, 0.8417,
|
||||
0.8779, 0.9113, 0.9414, 0.9675, 0.9886, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0306, 0.0702, 0.1160, 0.1623, 0.2144, 0.2709, 0.3243, 0.3885, 0.4556, 0.5199, 0.5807, 0.6369, 0.6884, 0.7355, 0.7799, 0.8217, 0.8606, 0.8964,
|
||||
0.9288, 0.9570, 0.9803, 0.9958, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0308, 0.0725, 0.1225, 0.1737, 0.2320, 0.2960, 0.3641, 0.4393, 0.5133, 0.5825, 0.6460, 0.7032, 0.7544, 0.8003, 0.8426, 0.8817, 0.9168, 0.9475,
|
||||
0.9733, 0.9924, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0322, 0.0751, 0.1303, 0.1918, 0.2594, 0.3348, 0.4189, 0.5044, 0.5841, 0.6565, 0.7208, 0.7771, 0.8262, 0.8692, 0.9074, 0.9408, 0.9686, 0.9900,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0331, 0.0818, 0.1482, 0.2171, 0.2977, 0.3892, 0.4865, 0.5779, 0.6602, 0.7322, 0.7943, 0.8470, 0.8915, 0.9286, 0.9600, 0.9844, 0.9996, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0340, 0.0884, 0.1661, 0.2544, 0.3540, 0.4658, 0.5729, 0.6681, 0.7496, 0.8183, 0.8748, 0.9208, 0.9568, 0.9832, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.3356, 0.5799, 0.6166, 0.6166, 0.6166, 0.6166, 0.6624, 0.7556, 0.8321, 0.8935, 0.9411, 0.9755, 0.9970, 0.9998, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.5007, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.1655, 0.1655, 0.2972, 0.4728, 0.6327, 0.7616, 0.8598, 0.9317, 0.9790, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0457, 0.1830, 0.3836, 0.5790, 0.7379, 0.8549, 0.9360, 0.9869, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0520, 0.2329, 0.4864, 0.6989, 0.8449, 0.9403, 0.9884, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0773, 0.3027, 0.6040, 0.8058, 0.9302, 0.9930, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.1454, 0.4738, 0.7615, 0.9074, 0.9908, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0964, 0.5416, 0.8589, 0.9897, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.1538, 0.1538, 0.7080, 0.9596, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.8439, 0.8439, 0.8439, 0.9993, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.9228, 0.9228, 0.9228, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
];
|
||||
/// Blacks curves at internal amounts -100, …, 100 in steps of 10.
|
||||
#[allow(clippy::approx_constant)]
|
||||
static BLACKS: [[f32; 33]; 21] = [
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0290, 0.0572, 0.0879, 0.1222, 0.1580, 0.1935, 0.2287, 0.2636, 0.2988, 0.3355, 0.3741, 0.4151, 0.4571,
|
||||
0.4990, 0.5406, 0.5818, 0.6226, 0.6631, 0.7031, 0.7424, 0.7811, 0.8192, 0.8568, 0.8937, 0.9300, 0.9655, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0000, 0.0015, 0.0363, 0.0640, 0.0956, 0.1300, 0.1651, 0.2000, 0.2345, 0.2686, 0.3026, 0.3369, 0.3727, 0.4105, 0.4495, 0.4885,
|
||||
0.5272, 0.5657, 0.6039, 0.6419, 0.6796, 0.7169, 0.7538, 0.7903, 0.8264, 0.8621, 0.8974, 0.9322, 0.9664, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0000, 0.0346, 0.0625, 0.0934, 0.1271, 0.1616, 0.1959, 0.2298, 0.2635, 0.2969, 0.3301, 0.3638, 0.3990, 0.4357, 0.4727, 0.5094,
|
||||
0.5460, 0.5824, 0.6187, 0.6547, 0.6906, 0.7262, 0.7615, 0.7965, 0.8313, 0.8657, 0.8998, 0.9336, 0.9671, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0235, 0.0554, 0.0850, 0.1175, 0.1514, 0.1852, 0.2187, 0.2519, 0.2849, 0.3178, 0.3505, 0.3837, 0.4183, 0.4537, 0.4891, 0.5243,
|
||||
0.5594, 0.5944, 0.6292, 0.6639, 0.6985, 0.7329, 0.7671, 0.8010, 0.8347, 0.8683, 0.9016, 0.9347, 0.9676, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0020, 0.0438, 0.0724, 0.1035, 0.1365, 0.1699, 0.2030, 0.2359, 0.2686, 0.3011, 0.3336, 0.3659, 0.3986, 0.4327, 0.4669, 0.5011, 0.5352,
|
||||
0.5692, 0.6031, 0.6369, 0.6706, 0.7043, 0.7378, 0.7711, 0.8043, 0.8373, 0.8702, 0.9029, 0.9355, 0.9679, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0230, 0.0567, 0.0865, 0.1182, 0.1510, 0.1839, 0.2164, 0.2489, 0.2812, 0.3133, 0.3455, 0.3775, 0.4100, 0.4433, 0.4767, 0.5100, 0.5433,
|
||||
0.5765, 0.6096, 0.6426, 0.6756, 0.7086, 0.7414, 0.7741, 0.8067, 0.8392, 0.8716, 0.9039, 0.9361, 0.9682, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0368, 0.0670, 0.0976, 0.1294, 0.1619, 0.1943, 0.2265, 0.2586, 0.2906, 0.3225, 0.3544, 0.3862, 0.4184, 0.4512, 0.4839, 0.5166, 0.5492,
|
||||
0.5818, 0.6144, 0.6469, 0.6793, 0.7117, 0.7441, 0.7763, 0.8085, 0.8406, 0.8727, 0.9046, 0.9365, 0.9683, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0444, 0.0749, 0.1058, 0.1377, 0.1699, 0.2020, 0.2339, 0.2657, 0.2975, 0.3292, 0.3609, 0.3926, 0.4245, 0.4568, 0.4891, 0.5213, 0.5535,
|
||||
0.5857, 0.6179, 0.6499, 0.6820, 0.7140, 0.7460, 0.7779, 0.8098, 0.8416, 0.8734, 0.9052, 0.9368, 0.9685, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0086, 0.0511, 0.0817, 0.1128, 0.1445, 0.1763, 0.2080, 0.2397, 0.2713, 0.3029, 0.3344, 0.3660, 0.3975, 0.4292, 0.4611, 0.4930, 0.5249, 0.5567,
|
||||
0.5886, 0.6204, 0.6522, 0.6839, 0.7157, 0.7475, 0.7791, 0.8108, 0.8424, 0.8740, 0.9056, 0.9371, 0.9686, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0198, 0.0564, 0.0874, 0.1186, 0.1501, 0.1817, 0.2133, 0.2447, 0.2762, 0.3076, 0.3390, 0.3705, 0.4018, 0.4333, 0.4649, 0.4965, 0.5281, 0.5597,
|
||||
0.5912, 0.6227, 0.6543, 0.6858, 0.7173, 0.7488, 0.7802, 0.8117, 0.8431, 0.8745, 0.9059, 0.9373, 0.9686, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
|
||||
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0313, 0.0650, 0.0979, 0.1303, 0.1624, 0.1942, 0.2260, 0.2576, 0.2891, 0.3206, 0.3520, 0.3833, 0.4146, 0.4455, 0.4763, 0.5070, 0.5378, 0.5684,
|
||||
0.5991, 0.6298, 0.6605, 0.6912, 0.7219, 0.7526, 0.7833, 0.8141, 0.8450, 0.8758, 0.9067, 0.9377, 0.9688, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0326, 0.0677, 0.1022, 0.1357, 0.1686, 0.2010, 0.2332, 0.2652, 0.2969, 0.3286, 0.3601, 0.3915, 0.4227, 0.4532, 0.4835, 0.5138, 0.5440, 0.5741,
|
||||
0.6042, 0.6344, 0.6645, 0.6946, 0.7248, 0.7550, 0.7853, 0.8156, 0.8461, 0.8766, 0.9072, 0.9380, 0.9689, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0341, 0.0706, 0.1066, 0.1413, 0.1749, 0.2079, 0.2405, 0.2728, 0.3048, 0.3366, 0.3682, 0.3996, 0.4307, 0.4608, 0.4906, 0.5203, 0.5500, 0.5796,
|
||||
0.6092, 0.6387, 0.6683, 0.6979, 0.7276, 0.7573, 0.7871, 0.8170, 0.8472, 0.8773, 0.9077, 0.9382, 0.9690, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0354, 0.0736, 0.1113, 0.1472, 0.1814, 0.2149, 0.2479, 0.2805, 0.3127, 0.3446, 0.3763, 0.4077, 0.4385, 0.4681, 0.4974, 0.5267, 0.5558, 0.5849,
|
||||
0.6139, 0.6429, 0.6720, 0.7011, 0.7303, 0.7595, 0.7889, 0.8184, 0.8481, 0.8780, 0.9081, 0.9384, 0.9690, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0368, 0.0768, 0.1162, 0.1531, 0.1881, 0.2221, 0.2554, 0.2882, 0.3206, 0.3527, 0.3843, 0.4158, 0.4461, 0.4753, 0.5041, 0.5328, 0.5615, 0.5900,
|
||||
0.6185, 0.6470, 0.6755, 0.7041, 0.7328, 0.7616, 0.7906, 0.8197, 0.8491, 0.8787, 0.9085, 0.9386, 0.9691, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0381, 0.0802, 0.1214, 0.1593, 0.1949, 0.2293, 0.2630, 0.2961, 0.3286, 0.3607, 0.3924, 0.4236, 0.4536, 0.4823, 0.5106, 0.5388, 0.5669, 0.5949,
|
||||
0.6229, 0.6509, 0.6789, 0.7070, 0.7352, 0.7636, 0.7922, 0.8209, 0.8500, 0.8793, 0.9089, 0.9388, 0.9691, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0394, 0.0837, 0.1267, 0.1656, 0.2018, 0.2367, 0.2707, 0.3039, 0.3365, 0.3686, 0.4003, 0.4314, 0.4608, 0.4890, 0.5168, 0.5445, 0.5721, 0.5996,
|
||||
0.6271, 0.6546, 0.6822, 0.7098, 0.7375, 0.7655, 0.7937, 0.8221, 0.8509, 0.8799, 0.9092, 0.9390, 0.9692, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0407, 0.0875, 0.1322, 0.1720, 0.2088, 0.2441, 0.2784, 0.3118, 0.3444, 0.3765, 0.4082, 0.4389, 0.4679, 0.4955, 0.5229, 0.5501, 0.5771, 0.6042,
|
||||
0.6312, 0.6582, 0.6853, 0.7124, 0.7398, 0.7673, 0.7952, 0.8233, 0.8517, 0.8804, 0.9096, 0.9391, 0.9692, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0421, 0.0915, 0.1379, 0.1786, 0.2160, 0.2516, 0.2861, 0.3196, 0.3523, 0.3844, 0.4159, 0.4462, 0.4747, 0.5019, 0.5287, 0.5554, 0.5820, 0.6085,
|
||||
0.6351, 0.6616, 0.6882, 0.7149, 0.7419, 0.7690, 0.7965, 0.8243, 0.8524, 0.8810, 0.9099, 0.9393, 0.9693, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0436, 0.0957, 0.1438, 0.1853, 0.2232, 0.2592, 0.2938, 0.3274, 0.3601, 0.3922, 0.4235, 0.4533, 0.4813, 0.5080, 0.5343, 0.5605, 0.5866, 0.6127,
|
||||
0.6387, 0.6648, 0.6910, 0.7173, 0.7439, 0.7707, 0.7978, 0.8253, 0.8532, 0.8815, 0.9102, 0.9394, 0.9693, 1.0000,
|
||||
],
|
||||
];
|
||||
const BPS: [f32; 9] = [0.0000, 0.0200, 0.0500, 0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000];
|
||||
const WPS: [f32; 8] = [0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000, 0.9000, 1.0000];
|
||||
static AW: [[f32; 8]; 9] = [
|
||||
[1.7750, 2.3875, 2.4000, 2.1625, 1.9438, 1.6125, 1.3875, 1.0000],
|
||||
[1.8125, 2.5250, 2.3937, 2.1688, 1.9375, 1.6062, 1.3938, 0.9875],
|
||||
[2.7000, 2.4688, 2.2750, 2.0688, 1.8875, 1.6000, 1.3875, 0.9938],
|
||||
[2.3250, 2.3250, 2.1875, 1.9750, 1.7937, 1.5188, 1.3250, 1.0437],
|
||||
[2.0187, 2.0187, 2.0187, 1.8625, 1.7063, 1.4062, 1.2250, 0.9250],
|
||||
[1.7500, 1.7500, 1.7500, 1.7500, 1.6438, 1.3562, 1.1687, 0.8625],
|
||||
[1.4937, 1.4937, 1.4937, 1.4937, 1.4937, 1.2875, 1.1000, 0.7875],
|
||||
[1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.0375, 0.7562],
|
||||
[0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.7562],
|
||||
];
|
||||
static AB: [[f32; 8]; 9] = [
|
||||
[0.9938, 0.9812, 1.0000, 1.0125, 0.9563, 0.9688, 1.0437, 1.0000],
|
||||
[1.0437, 1.0250, 1.0125, 1.0000, 1.0000, 1.0375, 1.0000, 0.9938],
|
||||
[1.2312, 1.2063, 1.1875, 1.1625, 1.1438, 1.1438, 1.1375, 1.1250],
|
||||
[1.2500, 1.2500, 1.2312, 1.2125, 1.2125, 1.2375, 1.2375, 1.2375],
|
||||
[1.1750, 1.1750, 1.1750, 1.1750, 1.2063, 1.2688, 1.3125, 1.3375],
|
||||
[1.1812, 1.1812, 1.1812, 1.1812, 1.2125, 1.2812, 1.3313, 1.4125],
|
||||
[1.2375, 1.2375, 1.2375, 1.2375, 1.2375, 1.3062, 1.3562, 1.4375],
|
||||
[1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3687, 1.4500],
|
||||
[1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.4312],
|
||||
];
|
||||
|
||||
/// A curve row at a fractional internal amount `x`, at grey level `v` (both interpolated).
|
||||
fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 {
|
||||
if v >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32);
|
||||
let i = (r as usize).min(rows.len() - 2);
|
||||
let t = r - i as f32;
|
||||
let at = |row: &[f32; STEPS]| {
|
||||
let p = v.max(0.0) * (STEPS - 1) as f32;
|
||||
let j = (p as usize).min(STEPS - 2);
|
||||
let s = p - j as f32;
|
||||
row[j] + (row[j + 1] - row[j]) * s
|
||||
};
|
||||
at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t
|
||||
}
|
||||
|
||||
/// Whites at internal amount `x` (−100 … 270) of grey level `v`.
|
||||
pub(crate) fn whites(v: f32, x: f32) -> f32 {
|
||||
curve(&WHITES, WHITES_FROM, x, v)
|
||||
}
|
||||
|
||||
/// Blacks at internal amount `x` (−100 … 100) of grey level `v`.
|
||||
pub(crate) fn blacks(v: f32, x: f32) -> f32 {
|
||||
curve(&BLACKS, BLACKS_FROM, x, v)
|
||||
}
|
||||
|
||||
/// The least Blacks adaptation on photographs.
|
||||
const PHOTO_BLACKS: f32 = 1.2;
|
||||
|
||||
/// Bilinear lookup of an adaptation table at black point `bp` and white point `wp`.
|
||||
fn adapt(table: &[[f32; 8]; 9], bp: f32, wp: f32) -> f32 {
|
||||
let idx = |grid: &[f32], v: f32| {
|
||||
let v = v.clamp(grid[0], grid[grid.len() - 1]);
|
||||
let i = grid.windows(2).position(|w| v <= w[1]).unwrap_or(grid.len() - 2);
|
||||
(i, (v - grid[i]) / (grid[i + 1] - grid[i]))
|
||||
};
|
||||
let (i, s) = idx(&BPS, bp);
|
||||
let (j, t) = idx(&WPS, wp);
|
||||
let row = |i: usize| table[i][j] * (1.0 - t) + table[i][j + 1] * t;
|
||||
row(i) * (1.0 - s) + row(i + 1) * s
|
||||
}
|
||||
|
||||
/// The internal amounts of the Whites and Blacks sliders (−100 … 100) for an image whose 1st
|
||||
/// and 99th luminance percentiles are the grey levels `bp` and `wp` (sRGB-encoded).
|
||||
pub(crate) fn amounts(whites: f32, blacks: f32, bp: f32, wp: f32) -> (f32, f32) {
|
||||
let w = if whites > 0.0 { whites * adapt(&AW, bp, wp) } else { whites };
|
||||
// On photographs Photoshop's Blacks is about 1.2 times the block images' amount even where
|
||||
// their black point is 0 (ten public-domain photos: 1.08–1.36).
|
||||
let b = if blacks < 0.0 { blacks * adapt(&AB, bp, wp).max(PHOTO_BLACKS) } else { blacks };
|
||||
(w.clamp(-100.0, 270.0), b.clamp(-100.0, 100.0))
|
||||
}
|
||||
|
||||
/// Applies Whites and Blacks (internal amounts `w`, `b`) to straight RGBA pixels (`width` per
|
||||
/// row) encoded with `enc`. Values above 1 (float documents) keep their excess.
|
||||
pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Encoding) {
|
||||
if w == 0.0 && b == 0.0 {
|
||||
return;
|
||||
}
|
||||
// A grey level's new level, both sRGB-encoded.
|
||||
let grey = |v: f32| {
|
||||
let v = if w != 0.0 { whites(v, w) } else { v };
|
||||
if b != 0.0 { blacks(v, b) } else { v }
|
||||
};
|
||||
let per_channel = w > 0.0 || b < 0.0;
|
||||
par_rows(px, width, 1, |_, row| {
|
||||
for q in row.iter_mut() {
|
||||
let lin = [0, 1, 2].map(|c| enc.decode(q[c]));
|
||||
if per_channel {
|
||||
for c in 0..3 {
|
||||
let over = (lin[c] - 1.0).max(0.0);
|
||||
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
|
||||
}
|
||||
} else {
|
||||
let y = super::luma_linear(lin).max(0.0);
|
||||
if y <= 1e-7 {
|
||||
continue;
|
||||
}
|
||||
let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
|
||||
let k = y2 / y;
|
||||
for c in 0..3 {
|
||||
q[c] = enc.encode(lin[c] * k);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn full_range_rows_match_photoshop() {
|
||||
// Photoshop 25.4 on a 64-level block image spanning black to white (sRGB levels).
|
||||
assert!((whites(97.1 / 255.0, 100.0) * 255.0 - 137.4).abs() < 0.6);
|
||||
assert!((whites(194.3 / 255.0, -100.0) * 255.0 - 167.6).abs() < 0.6);
|
||||
assert!((blacks(97.1 / 255.0, -100.0) * 255.0 - 60.0).abs() < 0.6);
|
||||
assert!((blacks(48.6 / 255.0, 100.0) * 255.0 - 67.0).abs() < 0.6);
|
||||
// The compressing directions keep black and white.
|
||||
assert_eq!(whites(1.0, -100.0), 1.0);
|
||||
assert_eq!(blacks(0.0, 100.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clipping_directions_adapt_to_the_image() {
|
||||
// A dark, low-contrast image needs more: Whites +50 acts like +94.5 on [0.05, 0.6].
|
||||
let (w, _) = amounts(50.0, 0.0, 0.05, 0.6);
|
||||
assert!((w - 94.5).abs() < 2.0, "{w}");
|
||||
// A raised black point strengthens Blacks −40 to −58 on [0.6, 1].
|
||||
let (_, b) = amounts(0.0, -40.0, 0.6, 1.0);
|
||||
assert!((b + 58.0).abs() < 1.0, "{b}");
|
||||
// Never below the photographs' 1.2.
|
||||
assert!((amounts(0.0, -50.0, 0.0, 1.0).1 + 60.0).abs() < 1e-3);
|
||||
// The compressing directions never adapt.
|
||||
assert_eq!(amounts(-50.0, 50.0, 0.3, 0.6), (-50.0, 50.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -369,7 +369,28 @@ fn adaptive_wide_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}))
|
||||
}
|
||||
|
||||
/// The transfer curve Camera Raw decodes `doc`'s values with ([`CameraRaw::encoding_gamma`]):
|
||||
/// ProPhoto's gamma 1.8 (raws open in it), Adobe RGB's 563/256, 1 for linear sRGB, else 0 for
|
||||
/// the sRGB curve.
|
||||
pub fn encoding_gamma(doc: &Document) -> f32 {
|
||||
use photocraft_cms::Builtin;
|
||||
let profile = crate::color_cmds::document_profile(doc);
|
||||
[(Builtin::ProPhotoCompat, 1.8), (Builtin::AdobeRgbCompat, 563.0 / 256.0), (Builtin::LinearSrgb, 1.0)]
|
||||
.into_iter()
|
||||
.find(|(b, _)| profile.same_colors(b.profile()))
|
||||
.map_or(0.0, |(_, g)| g)
|
||||
}
|
||||
|
||||
fn camera_raw_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The document's transfer curve goes with the settings, so a Smart Filter keeps it.
|
||||
let mut p = p.clone();
|
||||
if p.get("encodingGamma").is_none()
|
||||
&& let Some(g) = s.active().map(|d| encoding_gamma(&d.doc)).filter(|g| *g > 0.0)
|
||||
&& let Value::Object(m) = &mut p
|
||||
{
|
||||
m.insert("encodingGamma".into(), json!((f64::from(g) * 1e4).round() / 1e4));
|
||||
}
|
||||
let p = &p;
|
||||
let cr = raw_params(RAW, p)?;
|
||||
// New settings are strict; stored Smart Filters re-apply through the lenient `raw_params`.
|
||||
cr.validate().map_err(|e| bad(RAW, e))?;
|
||||
|
|
@ -583,6 +604,32 @@ mod tests {
|
|||
assert!((p[0] - p[2]).abs() < 0.01, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_raw_decodes_light_with_the_documents_curve() {
|
||||
// A raw opens in ProPhoto (gamma 1.8): the filter keeps that with its settings, so Whites,
|
||||
// Blacks, Shadows and Highlights work on the same light as when it was applied.
|
||||
let mut s = session(16);
|
||||
s.execute("edit.assignProfile", json!({"profile": "prophoto-compat"})).unwrap();
|
||||
assert_eq!(encoding_gamma(&s.active().unwrap().doc), 1.8);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute(RAW, json!({"shadows": 40, "whites": -30})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
let filters = match &d.doc.layer(d.active_layer.unwrap()).unwrap().content {
|
||||
LayerContent::Smart(so) => so.smart_filters.clone(),
|
||||
_ => panic!("smart object expected"),
|
||||
};
|
||||
assert_eq!(filters[0].params["encodingGamma"], json!(1.8));
|
||||
// sRGB documents use the sRGB curve, the default, and store nothing.
|
||||
let mut s = session(8);
|
||||
assert_eq!(encoding_gamma(&s.active().unwrap().doc), 0.0);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute(RAW, json!({"shadows": 40})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
if let LayerContent::Smart(so) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content {
|
||||
assert!(so.smart_filters[0].params.get("encodingGamma").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_raw_rejects_an_area_too_large_to_develop() {
|
||||
// #962: `area.width() * area.height()` overflowed u32 on a legal 65536×65536 canvas,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use std::sync::Arc;
|
|||
use photocraft_cms::{Builtin, Intent, Profile, Transform};
|
||||
use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType};
|
||||
use photocraft_raw::{CameraLook, DevelopOptions, Limits, PreviewColorSpace, RawError, Reference, Sensor, WhiteBalance};
|
||||
pub use photocraft_raw::{Tone, retone};
|
||||
|
||||
use crate::flat::image_to_document;
|
||||
use crate::{ImportResult, IoError};
|
||||
|
|
@ -41,8 +42,9 @@ fn upright_preview(raw: &[u8], jpeg: &[u8]) -> Result<Image, IoError> {
|
|||
Ok(img.oriented(o)?)
|
||||
}
|
||||
|
||||
/// Camera Raw's Temperature, Tint and Exposure, applied while developing the sensor data
|
||||
/// (the open-time Camera Raw dialog) instead of to the developed RGB pixels.
|
||||
/// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze,
|
||||
/// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the
|
||||
/// developed RGB pixels.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct RawTuning {
|
||||
/// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`.
|
||||
|
|
@ -51,11 +53,23 @@ pub struct RawTuning {
|
|||
pub tint: f32,
|
||||
/// EV, as `CameraRaw::exposure`.
|
||||
pub exposure: f32,
|
||||
/// −100…100, as `CameraRaw::shadows`: in linear light before the tone curve, as Camera Raw
|
||||
/// does for raw files ([`photocraft_raw::Tone`]).
|
||||
pub shadows: f32,
|
||||
/// −100…100, as `CameraRaw::highlights`.
|
||||
pub highlights: f32,
|
||||
/// −100…100, as `CameraRaw::clarity`.
|
||||
pub clarity: f32,
|
||||
/// −100…100, as `CameraRaw::texture`.
|
||||
pub texture: f32,
|
||||
/// −100…100, as `CameraRaw::dehaze`.
|
||||
pub dehaze: f32,
|
||||
}
|
||||
|
||||
/// Develops a raw file with `tuning` applied at the raw stage: Exposure as develop exposure,
|
||||
/// Temperature / Tint as gains on the camera's as-shot white-balance multipliers (the same
|
||||
/// gains the Camera Raw filter applies to RGB). The second value is `false` when the file has
|
||||
/// Shadows, Highlights, Clarity, Texture and Dehaze in linear light before the tone curve, Temperature / Tint as gains on the
|
||||
/// camera's as-shot white-balance multipliers (the same gains the Camera Raw filter applies to
|
||||
/// RGB). The second value is `false` when the file has
|
||||
/// no as-shot white balance: Temperature / Tint were then not applied, and the caller keeps
|
||||
/// them as RGB adjustments.
|
||||
pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<(ImportResult, bool), IoError> {
|
||||
|
|
@ -65,13 +79,18 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<
|
|||
return Err(IoError::Raw(RawError::Malformed(format!("exposure {} EV is out of range", tuning.exposure))));
|
||||
}
|
||||
let sensor = photocraft_raw::decode(bytes, &limits()).map_err(IoError::Raw)?;
|
||||
let mut opts = DevelopOptions { limits: limits(), ..Default::default() };
|
||||
// Use the same camera rendering as normal import. Fit at the camera's default WB and
|
||||
// exposure before applying user controls, so the fit cannot cancel their adjustments.
|
||||
if sensor.color.calibrations.is_empty() {
|
||||
opts.camera_look = fit_preview_look(bytes, &sensor, &opts).ok();
|
||||
}
|
||||
opts.exposure = exposure;
|
||||
let slider = |v: f32| if v.is_finite() { v.clamp(-100.0, 100.0) } else { 0.0 };
|
||||
let tone = photocraft_raw::Tone {
|
||||
shadows: slider(tuning.shadows),
|
||||
highlights: slider(tuning.highlights),
|
||||
clarity: slider(tuning.clarity),
|
||||
texture: slider(tuning.texture),
|
||||
dehaze: slider(tuning.dehaze),
|
||||
};
|
||||
// The colour rendering the default open used (a look fitted to the camera's JPEG for files
|
||||
// without colour calibration), so re-developing changes only what the sliders change.
|
||||
let camera_look = default_look(bytes, &sensor);
|
||||
let mut opts = DevelopOptions { limits: limits(), exposure, tone, camera_look, ..Default::default() };
|
||||
let wb_applied = match sensor.camera_wb {
|
||||
Some(m) => {
|
||||
let g = wb_gains(tuning.temperature, tuning.tint);
|
||||
|
|
@ -86,10 +105,45 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<
|
|||
} else {
|
||||
"estimated white balance".into()
|
||||
};
|
||||
let summary = format!("developed in Camera Raw (exposure {:+.2} EV, {wb})", tuning.exposure);
|
||||
let tone = [("shadows", tone.shadows), ("highlights", tone.highlights), ("clarity", tone.clarity), ("texture", tone.texture), ("dehaze", tone.dehaze)]
|
||||
.iter()
|
||||
.filter(|(_, v)| *v != 0.0)
|
||||
.map(|(k, v)| format!(", {k} {v:+.0}"))
|
||||
.collect::<String>();
|
||||
let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb})", tuning.exposure);
|
||||
Ok((developed_document(name, format, dev, &summary)?, wb_applied))
|
||||
}
|
||||
|
||||
/// The tone curve `bytes` develop with by default: the fitted look's for a file without colour
|
||||
/// calibration (see [`import_raw_with`]), else the camera profile's (empty: none, or not a raw
|
||||
/// this crate decodes). For previews of the open-time raw-stage sliders
|
||||
/// ([`photocraft_raw::retone`]), in linear-light knots.
|
||||
pub fn tone_curve(bytes: &[u8]) -> Vec<[f32; 2]> {
|
||||
let Ok(sensor) = photocraft_raw::decode(bytes, &limits()) else { return Vec::new() };
|
||||
match default_look(bytes, &sensor) {
|
||||
Some(look) => look.tone.as_ref().map(look_knots).unwrap_or_default(),
|
||||
None => sensor.tone_curve,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fitted look's tone curve as knots for [`photocraft_raw::retone`] (which reads them
|
||||
/// log-linearly): sampled densely enough that the two agree within a fraction of a code value.
|
||||
fn look_knots(t: &photocraft_raw::ToneCurve) -> Vec<[f32; 2]> {
|
||||
const N: usize = 256;
|
||||
(1..=N).map(|i| (i as f64 / N as f64).powi(2)).map(|x| [x as f32, t.eval(x) as f32]).collect()
|
||||
}
|
||||
|
||||
/// The look a default open develops `bytes` with: fitted to the embedded JPEG when the file has
|
||||
/// no colour calibration, is not monochrome and the fit clearly helps (as [`import_raw_with`]
|
||||
/// does), else `None`. It is fitted at the camera's own white balance and exposure, so a
|
||||
/// re-develop's sliders are not fitted away.
|
||||
fn default_look(bytes: &[u8], sensor: &Sensor) -> Option<CameraLook> {
|
||||
if !sensor.color.calibrations.is_empty() || sensor.is_monochrome() {
|
||||
return None;
|
||||
}
|
||||
fit_preview_look(bytes, sensor, &DevelopOptions { limits: limits(), ..Default::default() }).ok()
|
||||
}
|
||||
|
||||
/// "Canon" + "Canon EOS 80D" and "NIKON CORPORATION" + "NIKON D3200" read as the model alone:
|
||||
/// the model already starts with the make's first word.
|
||||
fn camera_name(make: Option<&str>, model: Option<&str>) -> String {
|
||||
|
|
|
|||
|
|
@ -214,7 +214,11 @@ fn camera_raw_tuning_keeps_the_default_look_and_applies_user_gains_after_fitting
|
|||
assert!(fitted(&normal.warnings));
|
||||
let sensor = decode(&bytes, &Default::default()).unwrap();
|
||||
let look = fit_preview_look(&bytes, &sensor, &DevelopOptions::default()).unwrap();
|
||||
for tuning in [RawTuning::default(), RawTuning { exposure: 0.5, ..Default::default() }, RawTuning { exposure: -0.5, temperature: 20.0, tint: -15.0 }] {
|
||||
for tuning in [
|
||||
RawTuning::default(),
|
||||
RawTuning { exposure: 0.5, ..Default::default() },
|
||||
RawTuning { exposure: -0.5, temperature: 20.0, tint: -15.0, ..Default::default() },
|
||||
] {
|
||||
let (got, applied) = import_raw_tuned("camera.nef", &bytes, &tuning).unwrap();
|
||||
assert!(applied && fitted(&got.warnings) && !fallback(&got.warnings), "{:?}", got.warnings);
|
||||
assert_eq!(got.document.depth, SampleType::U16);
|
||||
|
|
@ -283,3 +287,28 @@ fn truncated_raws_with_a_fittable_preview_do_not_panic() {
|
|||
let _ = photocraft_io::raw::import_raw_tuned("x.nef", &b[..n], &Default::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// The open-time Camera Raw re-develop keeps the colour rendering the default open used: a raw
|
||||
/// without calibration gets the same look fitted to its JPEG, so neutral settings give the same
|
||||
/// pixels, and the previews' tone curve is the look's.
|
||||
#[test]
|
||||
fn a_re_develop_keeps_the_fitted_look() {
|
||||
let (w, h) = (480, 320);
|
||||
let cam = patches(w, h);
|
||||
let nef = nef_uncompressed(&cam, w, h, Some(camera_rendering(&cam, w, h)));
|
||||
let opened = import("DSC_0003.NEF", &nef).unwrap();
|
||||
let (again, _) = photocraft_io::raw::import_raw_tuned("DSC_0003.NEF", &nef, &Default::default()).unwrap();
|
||||
assert!(fitted(&again.warnings), "{:?}", again.warnings);
|
||||
let pixels = |d: &photocraft_doc::Document| {
|
||||
let s = d.layers[0].surface().unwrap();
|
||||
let mut px = vec![[0.0f32; 4]; w * h];
|
||||
s.read_rgba_into(photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &mut px);
|
||||
px
|
||||
};
|
||||
assert_eq!(pixels(&opened.document), pixels(&again.document));
|
||||
let curve = photocraft_io::raw::tone_curve(&nef);
|
||||
assert!(curve.len() > 2 && curve.windows(2).all(|k| k[1][0] > k[0][0] && k[1][1] >= k[0][1]), "{curve:?}");
|
||||
// The camera's brightening curve: mid-grey comes out lighter.
|
||||
let mid = curve.iter().find(|k| k[0] >= 0.18).unwrap();
|
||||
assert!(mid[1] > mid[0], "{mid:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,14 @@ brightened shadows locally (Active D-Lighting), which one global curve cannot fo
|
|||
(preview decode included) takes about 50–70 ms on a 24 MP file. It is an estimate of the camera's
|
||||
look, not a measured calibration: picture styles and hue-dependent rendering are approximated.
|
||||
|
||||
Cameras with a profile measured against Photoshop's Camera Raw (`src/cameras.rs`: the Nikon D4) carry
|
||||
a calibration, so no look is fitted for them. On 6 D4 NEFs against Photoshop 25.4's default Camera
|
||||
Raw rendering (quarter size, medians over the frames) the measured profile is 0.048 stop from its
|
||||
luminance with a mean CIE76 ΔE of 1.8; the look fitted to the embedded JPEG would be 0.30 stop and
|
||||
ΔE 6.4, as it follows the camera's picture style instead. The open-time re-develop
|
||||
(`photocraft-io`'s `import_raw_tuned`) and the tone curve its previews use (`tone_curve`) take the
|
||||
same look as the default open, so changing a slider does not change the colour rendering.
|
||||
|
||||
## Tools
|
||||
|
||||
`cargo run --release -p photocraft-raw --example rawinfo -- [--dump] [--demosaic ahd] [--png DIR] FILE...`
|
||||
|
|
|
|||
|
|
@ -11,10 +11,13 @@
|
|||
//! (2 px of optical black on the left, 50 + 8 on the right show as a purple stripe otherwise). The
|
||||
//! data is already black-subtracted (the darkest image pixels reach 0). Green saturates at
|
||||
//! 15546–15672 while red and blue reach 15687–16383, so one level of 15520 makes clipped
|
||||
//! highlights white without shifting the white balance. Matrix and curve were fitted to the
|
||||
//! camera's embedded JPEGs (Picture Control Standard, Active D-Lighting off; 52 frames, smooth
|
||||
//! areas, 4 × 4 means): on 15 held-out frames the development is 7.2/255 per channel off the
|
||||
//! camera's JPEG on average, against 34 without a profile.
|
||||
//! highlights white without shifting the white balance. Matrix, curve and black point copy
|
||||
//! Photoshop Camera Raw's default rendering of these files, measured black-box: 29 NEFs opened
|
||||
//! in Photoshop 25.4 against our own linear development of the same data (8 × 8 means, pixels
|
||||
//! clipped in neither). The matrix is our first ForwardMatrix (fitted to the camera's embedded
|
||||
//! JPEGs) times a linear-ProPhoto look matrix whose rows sum to 1; the curve applies per channel
|
||||
//! after it; the black point follows the frame's darkest tones ([`crate::tone`]). Median
|
||||
//! luminance difference to Camera Raw: 0.04 stop (0.29 with the camera-JPEG curve).
|
||||
//!
|
||||
//! Bodies whose full profile is not measured yet can still get their measured image area — the
|
||||
//! same alignment method, see [`IMAGE_AREAS`] — so their frames crop like the camera's JPEG.
|
||||
|
|
@ -22,39 +25,63 @@
|
|||
use crate::color::{Calibration, ColorInfo, IDENTITY, Mat3};
|
||||
use crate::sensor::{Rect, Sensor};
|
||||
|
||||
const D4_FORWARD: Mat3 = [[0.644598, 0.0703598, 0.249262], [0.342249, 0.518929, 0.138822], [0.0568265, -0.269568, 1.03795]];
|
||||
const D4_FORWARD: Mat3 = [[0.639454, 0.260359, 0.0643991], [0.273094, 0.796492, -0.0695779], [0.00965321, -0.131427, 0.94699]];
|
||||
/// A third of the 0.1th percentile of the luminance, as Camera Raw renders D4 NEFs by default.
|
||||
const D4_BLACK_POINT: f32 = 0.33;
|
||||
const D4_COLOR: Mat3 = [[0.895936, -0.218092, -0.185988], [-1.09736, 2.06898, -0.0131889], [-0.26977, 0.393884, 0.687829]];
|
||||
const D4_TONE: [[f32; 2]; 31] = [
|
||||
[0.005524272, 0.00285028],
|
||||
[0.006569503, 0.00357083],
|
||||
[0.0078125, 0.00447912],
|
||||
[0.009290681, 0.00605813],
|
||||
[0.01104854, 0.00841445],
|
||||
[0.01313901, 0.011536],
|
||||
[0.015625, 0.0165399],
|
||||
[0.01858136, 0.0230234],
|
||||
[0.02209709, 0.0319205],
|
||||
[0.02627801, 0.0434858],
|
||||
[0.03125, 0.0582138],
|
||||
[0.03716272, 0.0771963],
|
||||
[0.04419417, 0.0993894],
|
||||
[0.05255603, 0.125058],
|
||||
[0.0625, 0.157207],
|
||||
[0.07432544, 0.194929],
|
||||
[0.08838835, 0.236109],
|
||||
[0.1051121, 0.279155],
|
||||
[0.125, 0.329365],
|
||||
[0.1486509, 0.388405],
|
||||
[0.1767767, 0.457944],
|
||||
[0.2102241, 0.52456],
|
||||
[0.25, 0.598051],
|
||||
[0.2973018, 0.668794],
|
||||
[0.3535534, 0.749954],
|
||||
[0.4204482, 0.815443],
|
||||
[0.5, 0.870167],
|
||||
[0.5946036, 0.907869],
|
||||
[std::f32::consts::FRAC_1_SQRT_2, 0.939351],
|
||||
[0.8408964, 0.970567],
|
||||
const D4_TONE: [[f32; 2]; 53] = [
|
||||
[0.00012207031, 7.44377e-05],
|
||||
[0.00014516688, 8.44049e-05],
|
||||
[0.00017263349, 9.57067e-05],
|
||||
[0.00020529698, 0.000108523],
|
||||
[0.00024414062, 0.000123055],
|
||||
[0.00029033376, 0.000123202],
|
||||
[0.00034526698, 0.000123348],
|
||||
[0.00041059396, 0.000142387],
|
||||
[0.00048828125, 0.000164366],
|
||||
[0.0005806675, 0.00020246],
|
||||
[0.00069053395, 0.000249382],
|
||||
[0.0008211879, 0.000310083],
|
||||
[0.0009765625, 0.000385559],
|
||||
[0.001161335, 0.000474115],
|
||||
[0.0013810679, 0.00058301],
|
||||
[0.0016423758, 0.000717142],
|
||||
[0.001953125, 0.000882133],
|
||||
[0.00232267, 0.00109054],
|
||||
[0.0027621358, 0.00134818],
|
||||
[0.0032847517, 0.00163655],
|
||||
[0.00390625, 0.0019866],
|
||||
[0.00464534, 0.00235945],
|
||||
[0.0055242716, 0.00280228],
|
||||
[0.0065695033, 0.00342616],
|
||||
[0.0078125, 0.00418894],
|
||||
[0.00929068, 0.00540942],
|
||||
[0.011048543, 0.0069855],
|
||||
[0.013139007, 0.00959286],
|
||||
[0.015625, 0.0131734],
|
||||
[0.01858136, 0.0181471],
|
||||
[0.022097087, 0.0249987],
|
||||
[0.026278013, 0.0336416],
|
||||
[0.03125, 0.0452726],
|
||||
[0.03716272, 0.0591889],
|
||||
[0.044194173, 0.077383],
|
||||
[0.052556027, 0.0990869],
|
||||
[0.0625, 0.126878],
|
||||
[0.07432544, 0.161688],
|
||||
[0.088388346, 0.206049],
|
||||
[0.10511205, 0.256563],
|
||||
[0.125, 0.31946],
|
||||
[0.14865088, 0.382637],
|
||||
[0.17677669, 0.458308],
|
||||
[0.2102241, 0.531942],
|
||||
[0.25, 0.617405],
|
||||
[0.29730177, 0.683279],
|
||||
[0.35355338, 0.756182],
|
||||
[0.4204482, 0.809073],
|
||||
[0.5, 0.865664],
|
||||
[0.59460354, 0.900869],
|
||||
[std::f32::consts::FRAC_1_SQRT_2, 0.937507],
|
||||
[0.8408964, 0.956434],
|
||||
[1.0, 1.0],
|
||||
];
|
||||
|
||||
|
|
@ -71,6 +98,8 @@ struct Profile {
|
|||
forward: Mat3,
|
||||
color: Mat3,
|
||||
tone: &'static [[f32; 2]],
|
||||
/// Camera Raw's adaptive black-point factor ([`Sensor::black_point`]).
|
||||
black_point: f32,
|
||||
}
|
||||
|
||||
const PROFILES: &[Profile] = &[Profile {
|
||||
|
|
@ -83,6 +112,7 @@ const PROFILES: &[Profile] = &[Profile {
|
|||
forward: D4_FORWARD,
|
||||
color: D4_COLOR,
|
||||
tone: &D4_TONE,
|
||||
black_point: D4_BLACK_POINT,
|
||||
}];
|
||||
|
||||
/// A measured image area of a body whose remaining profile has not been
|
||||
|
|
@ -134,6 +164,7 @@ pub(crate) fn apply(s: &mut Sensor) {
|
|||
..ColorInfo::default()
|
||||
};
|
||||
s.tone_curve = p.tone.to_vec();
|
||||
s.black_point = p.black_point;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -164,6 +195,7 @@ mod tests {
|
|||
baseline_exposure: 0.0,
|
||||
gain_maps: Vec::new(),
|
||||
tone_curve: Vec::new(),
|
||||
black_point: 0.0,
|
||||
warnings: vec!["NEF: black level not recorded in a documented tag; assumed 0".into()],
|
||||
}
|
||||
}
|
||||
|
|
@ -181,6 +213,7 @@ mod tests {
|
|||
assert_eq!(s.color.calibrations.len(), 1);
|
||||
assert_eq!(s.color.calibrations[0].forward_matrix, Some(D4_FORWARD));
|
||||
assert_eq!(s.tone_curve.len(), D4_TONE.len());
|
||||
assert_eq!(s.black_point, D4_BLACK_POINT);
|
||||
assert!(s.warnings.is_empty(), "the D4's black level is known: {:?}", s.warnings);
|
||||
// The profile gives a colour conversion for the as-shot white balance.
|
||||
assert!(s.color.balanced_to_xyz_d50([0.5, 1.0, 0.7]).is_some());
|
||||
|
|
|
|||
|
|
@ -293,6 +293,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
|
|||
baseline_exposure: 0.0,
|
||||
gain_maps: Vec::new(),
|
||||
tone_curve: Vec::new(),
|
||||
black_point: 0.0,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
//! Raw development: linearize, subtract black, scale to white, white
|
||||
//! balance, clip highlights, demosaic, camera → XYZ (D50) → linear ProPhoto,
|
||||
//! exposure, gamma 1.8 encode to 16 bits, orientation.
|
||||
//! exposure, Dehaze, Shadows / Highlights / Clarity / Texture and the black
|
||||
//! point (`tone.rs`), the profile's tone curve, gamma 1.8 encode to 16 bits,
|
||||
//! orientation.
|
||||
|
||||
use crate::color::{self, Mat3};
|
||||
use crate::demosaic::{Demosaic, PAD, Padded, Pattern, demosaic, demosaic_cfa};
|
||||
use crate::error::{RawError, Result};
|
||||
use crate::look::{CameraLook, ToneCurve};
|
||||
use crate::sensor::Sensor;
|
||||
use crate::tone::{Haze, Tone, ToneMap, black_level};
|
||||
use crate::{Limits, RawFormat, par};
|
||||
|
||||
/// White balance choice.
|
||||
|
|
@ -30,6 +33,9 @@ pub struct DevelopOptions {
|
|||
pub exposure: f64,
|
||||
/// Apply the file's orientation (rotate / flip the pixels).
|
||||
pub orient: bool,
|
||||
/// Camera Raw's Shadows, Highlights, Clarity, Texture and Dehaze, applied in linear light
|
||||
/// before the tone curve.
|
||||
pub tone: Tone,
|
||||
pub limits: Limits,
|
||||
/// A colour rendering fitted to the camera's own JPEG ([`crate::fit_look`]). When set it
|
||||
/// replaces the file's colour calibration (or the neutral fallback) and adds its tone curve.
|
||||
|
|
@ -43,6 +49,7 @@ impl Default for DevelopOptions {
|
|||
white_balance: WhiteBalance::default(),
|
||||
exposure: 0.0,
|
||||
orient: true,
|
||||
tone: Tone::default(),
|
||||
limits: Limits::default(),
|
||||
camera_look: None,
|
||||
}
|
||||
|
|
@ -330,7 +337,24 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result<Developed> {
|
|||
}
|
||||
}
|
||||
|
||||
// Camera → ProPhoto, exposure, the look's tone curve, gamma 1.8, 16 bits.
|
||||
// Camera → ProPhoto, exposure, Dehaze, Shadows / Highlights / Clarity / Texture and the black
|
||||
// point, the look's (or the profile's) tone curve, gamma 1.8, 16 bits.
|
||||
let y_row = color::rgb_to_xyz(color::ROMM, color::D50_XY)[1].map(|v| v as f32);
|
||||
let lum = |v: &[f32; 3]| y_row[0] * v[0] + y_row[1] * v[1] + y_row[2] * v[2];
|
||||
// Monochrome: the one sample, grey; no colour matrix.
|
||||
let pro = |p: &[f32]| if mono { [grey_gain * p[0]; 3] } else { [0, 1, 2].map(|k| out_m[k][0] * p[0] + out_m[k][1] * p[1] + out_m[k][2] * p[2]) };
|
||||
let haze = Haze::new(w, h, |i| pro(&rgb[i * 3..i * 3 + 3]), opts.tone.dehaze);
|
||||
// Linear ProPhoto of pixel `i` after Dehaze.
|
||||
let linear = |i: usize, p: &[f32]| {
|
||||
let v = pro(p);
|
||||
haze.as_ref().map_or(v, |z| z.apply(i % w, i / w, v))
|
||||
};
|
||||
let mut map = ToneMap::new(w, h, |i| lum(&linear(i, &rgb[i * 3..i * 3 + 3])), opts.tone, s.black_point);
|
||||
if let (Some(m), Some(_)) = (&mut map, &haze) {
|
||||
// The black point belongs to the default rendering: Dehaze does not move it (taken after
|
||||
// Dehaze it fell to almost 0, and Dehaze darkened less than Photoshop's).
|
||||
m.black = black_level(w, h, |i| lum(&pro(&rgb[i * 3..i * 3 + 3])), s.black_point);
|
||||
}
|
||||
let mut out = vec![0u16; w * h * 3];
|
||||
let lut = match look {
|
||||
Some(l) => GammaLut::with_tone(l.tone.as_ref()),
|
||||
|
|
@ -339,13 +363,16 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result<Developed> {
|
|||
par::chunks_mut(&mut out, band * w * 3, |b, chunk| {
|
||||
let start = b * band * w * 3;
|
||||
let src = &rgb[start..start + chunk.len()];
|
||||
for (o, p) in chunk.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<3>().0) {
|
||||
if mono {
|
||||
*o = [lut.encode(grey_gain * p[0]); 3];
|
||||
continue;
|
||||
for (i, (o, p)) in chunk.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<3>().0).enumerate() {
|
||||
let mut v = linear(b * band * w + i, p);
|
||||
if let Some(m) = &map {
|
||||
let g = m.gain(i % w, b * band + i / w, lum(&v));
|
||||
// The black point first, as Camera Raw: lifting before it would flatten the
|
||||
// deepest shadows' contrast.
|
||||
v = v.map(|c| m.unblack(c) * g);
|
||||
}
|
||||
for k in 0..3 {
|
||||
o[k] = lut.encode(out_m[k][0] * p[0] + out_m[k][1] * p[1] + out_m[k][2] * p[2]);
|
||||
o[k] = lut.encode(v[k]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -453,7 +480,7 @@ impl GammaLut {
|
|||
|
||||
/// `curve` at linear `v`: linear interpolation between the points in log₂ of the input, a line
|
||||
/// through 0 below the first point, the last output beyond the last.
|
||||
fn tone(curve: &[[f32; 2]], v: f32) -> f32 {
|
||||
pub(crate) fn tone(curve: &[[f32; 2]], v: f32) -> f32 {
|
||||
let (Some(first), Some(last)) = (curve.first(), curve.last()) else { return v };
|
||||
if v <= first[0] {
|
||||
return if first[0] > 0.0 { v * first[1] / first[0] } else { first[1] };
|
||||
|
|
|
|||
|
|
@ -271,6 +271,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
|
|||
baseline_exposure: if baseline_exposure.is_finite() { baseline_exposure.clamp(-10.0, 10.0) } else { 0.0 },
|
||||
gain_maps,
|
||||
tone_curve: Vec::new(),
|
||||
black_point: 0.0,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ mod sensor;
|
|||
mod sony;
|
||||
mod tiff;
|
||||
mod tiffep;
|
||||
mod tone;
|
||||
|
||||
#[cfg(feature = "testgen")]
|
||||
pub mod testgen;
|
||||
|
|
@ -76,6 +77,7 @@ pub use crate::look::{CameraLook, LookRejected, PROXY_EDGE, Reference, ToneCurve
|
|||
pub use crate::opcodes::GainMap;
|
||||
pub use crate::preview::{Preview, PreviewColorSpace, embedded_preview, embedded_preview_color_space, embedded_previews};
|
||||
pub use crate::sensor::{BlackLevels, Cfa, Rect, Sensor};
|
||||
pub use crate::tone::{Tone, retone};
|
||||
|
||||
use crate::tiff::{Tiff, tag};
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
|
|||
baseline_exposure: 0.0,
|
||||
gain_maps: Vec::new(),
|
||||
tone_curve: Vec::new(),
|
||||
black_point: 0.0,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -302,6 +302,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Sensor> {
|
|||
baseline_exposure,
|
||||
gain_maps: Vec::new(),
|
||||
tone_curve: Vec::new(),
|
||||
black_point: 0.0,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
|
|||
baseline_exposure: 0.0,
|
||||
gain_maps: Vec::new(),
|
||||
tone_curve: Vec::new(),
|
||||
black_point: 0.0,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,10 @@ pub struct Sensor {
|
|||
/// output]` pairs in linear ProPhoto, applied per channel after the colour conversion and
|
||||
/// exposure. Below the first point the curve is a line through 0; empty: no curve.
|
||||
pub tone_curve: Vec<[f32; 2]>,
|
||||
/// Camera Raw's adaptive black point, for profiles that copy its default rendering: this
|
||||
/// factor times the 0.1th percentile of the image's linear luminance is subtracted before the
|
||||
/// tone curve (see `tone.rs`). 0: no black point.
|
||||
pub black_point: f32,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result<Sen
|
|||
baseline_exposure: 0.0,
|
||||
gain_maps: Vec::new(),
|
||||
tone_curve: Vec::new(),
|
||||
black_point: 0.0,
|
||||
warnings,
|
||||
};
|
||||
crate::cameras::apply(&mut sensor);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,730 @@
|
|||
//! Camera Raw's Shadows, Highlights, Clarity, Texture and Dehaze on raw files, applied while
|
||||
//! developing (in linear light, before the tone curve), and its adaptive black point.
|
||||
//!
|
||||
//! Measured black-box from Photoshop 25.4 opening 29 Nikon D4 NEFs through Camera Raw (each slider
|
||||
//! at ±100 and +50, exposure ±1), against PhotoCraft's own linear development of the same files:
|
||||
//!
|
||||
//! * The change is a gain in linear light: exposure −1 gives the same gains, so Camera Raw applies
|
||||
//! it before its tone curve.
|
||||
//! * log₂ of the gain is `slider / 100 · C(B − anchor)`. `B` is an edge-preserving base of the log₂
|
||||
//! luminance (K. He's guided filter, radius 32 and ε 1 stop² on a copy 1232 px on its long
|
||||
//! side): whole areas move together and edges stay sharp. The change is linear in the slider;
|
||||
//! each direction has its own curve `C`.
|
||||
//! * The anchor follows the image, so exposure changes nothing: for Shadows the log₂ power mean
|
||||
//! (p = 1.5) of the linear luminance, for Highlights the mean of the log₂ luminance.
|
||||
//!
|
||||
//! On frames left out of the fit the curves explain 80–90 % of the per-pixel change (a fixed,
|
||||
//! absolute anchor: 60–75 %).
|
||||
//!
|
||||
//! The black point: Camera Raw's default rendering follows the frame's darkest tones. A third of
|
||||
//! the 0.1th percentile of the linear luminance is subtracted; that halves the deep-shadow
|
||||
//! error of a fixed curve. It comes before the Shadows / Highlights gain, as in Camera Raw:
|
||||
//! the other order left lifted shadows 0.2 stop lighter and flatter than Photoshop's.
|
||||
//!
|
||||
//! Clarity, Texture and Dehaze were measured the same way (each at ±100 and +50, and +100 at
|
||||
//! exposure −1): all three act before the tone curve (the scene-domain change at exposure −1
|
||||
//! correlates 0.98–0.99 with the one at exposure 0) and follow the image, not absolute levels.
|
||||
//!
|
||||
//! * **Clarity** changes detail at every scale above a few pixels: log₂ luminance is split into
|
||||
//! bands between edge-preserving bases (guided filter, radii 2, 8, 32 and 128 on the work copy,
|
||||
//! ε 1 stop²), each band scaled by a gain that depends on its base's tone relative to the
|
||||
//! frame's mean, plus a tone term. +100 roughly doubles midtone detail and darkens darker areas;
|
||||
//! −100 flattens the detail of midtones and highlights and leaves the shadows' alone. On frames
|
||||
//! left out of the fit the change correlates 0.81 (+100) and 0.92 (−100) with Photoshop's;
|
||||
//! +50 is half of +100.
|
||||
//! * **Texture** works on fine detail only: bands between Gaussians of σ 1…32 px (full size), each
|
||||
//! through its own measured response, so small detail is raised more than strong edges
|
||||
//! (computed at half size and carried to full size by guided upsampling).
|
||||
//! * **Dehaze** follows K. He, J. Sun and X. Tang's dark channel prior (*Single Image Haze Removal
|
||||
//! Using Dark Channel Prior*, 2009): the airlight `A` is the mean colour of the 0.1 % of pixels
|
||||
//! with the brightest dark channel, the transmission `t = 1 − w · dark` from the dark channel
|
||||
//! (minimum over RGB of `I / A` and over a 17 px window on the work copy) smoothed by a guided
|
||||
//! filter, and each channel becomes `(I − A) / t + A`, with `w` 0.8 at +100 (0.7 fits the
|
||||
//! per-pixel change best, 0.85 the overall darkening). −100 adds a uniform veil,
|
||||
//! `I (1 − w) + A w`, `w` 0.22. The black point stays the default rendering's.
|
||||
|
||||
use crate::par;
|
||||
|
||||
mod tables;
|
||||
|
||||
/// Camera Raw's Basic-panel sliders that act on raw data before the tone curve, −100…100.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Tone {
|
||||
pub shadows: f32,
|
||||
pub highlights: f32,
|
||||
pub clarity: f32,
|
||||
pub texture: f32,
|
||||
pub dehaze: f32,
|
||||
}
|
||||
|
||||
impl Tone {
|
||||
/// Every slider at 0.
|
||||
pub const NEUTRAL: Tone = Tone { shadows: 0.0, highlights: 0.0, clarity: 0.0, texture: 0.0, dehaze: 0.0 };
|
||||
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
self.gain_neutral() && self.dehaze == 0.0
|
||||
}
|
||||
|
||||
/// No luminance gain: Shadows, Highlights, Clarity and Texture at 0.
|
||||
fn gain_neutral(&self) -> bool {
|
||||
self.work_neutral() && self.texture == 0.0
|
||||
}
|
||||
|
||||
/// Nothing to compute on the work copy: Shadows, Highlights and Clarity at 0.
|
||||
fn work_neutral(&self) -> bool {
|
||||
self.shadows == 0.0 && self.highlights == 0.0 && self.clarity == 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Long side of the copy the base is computed on, and the guided filter's radius and ε there.
|
||||
const WORK_SIDE: usize = 1232;
|
||||
const BASE_RADIUS: f32 = 32.0;
|
||||
const BASE_EPS: f32 = 1.0;
|
||||
/// ε (stops²) of the guided upsampling that carries the change to full size.
|
||||
const UPSAMPLE_EPS: f32 = 1.0;
|
||||
/// The curves' first knot (stops from the anchor) and knot spacing.
|
||||
const LO: f32 = -12.0;
|
||||
const STEP: f32 = 0.25;
|
||||
|
||||
// log₂ gain at +100 / −100 against B − anchor, knots LO, LO + STEP, … (measured; flat or
|
||||
// extended linearly beyond the measured range, never steeper than 0.8 stop per stop so tones
|
||||
// keep their order).
|
||||
const SHADOWS_UP: [f32; 73] = [
|
||||
2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142,
|
||||
2.142, 2.142, 2.142, 2.142, 2.138, 2.117, 2.074, 2.010, 1.918, 1.797, 1.668, 1.546, 1.429, 1.315, 1.204, 1.093, 0.979, 0.860, 0.734, 0.604, 0.482, 0.378,
|
||||
0.288, 0.211, 0.157, 0.127, 0.104, 0.078, 0.056, 0.046, 0.044, 0.044, 0.044, 0.044, 0.044, 0.041, 0.030, 0.014, 0.003, 0.000, 0.000, 0.000, 0.000, 0.000,
|
||||
0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000,
|
||||
];
|
||||
const SHADOWS_DOWN: [f32; 73] = [
|
||||
-2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365,
|
||||
-2.365, -2.365, -2.365, -2.365, -2.346, -2.270, -2.155, -2.078, -2.054, -2.031, -1.973, -1.872, -1.739, -1.604, -1.483, -1.372, -1.266, -1.161, -1.053,
|
||||
-0.940, -0.821, -0.696, -0.570, -0.455, -0.356, -0.270, -0.197, -0.146, -0.117, -0.098, -0.076, -0.057, -0.048, -0.046, -0.046, -0.046, -0.046, -0.046,
|
||||
-0.043, -0.032, -0.014, -0.003, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000,
|
||||
];
|
||||
const HIGHLIGHTS_UP: [f32; 73] = [
|
||||
0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000,
|
||||
0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.001, 0.002, 0.003, 0.004, 0.008, 0.017, 0.030, 0.043, 0.055, 0.070, 0.085, 0.097, 0.106,
|
||||
0.114, 0.120, 0.127, 0.142, 0.167, 0.199, 0.239, 0.301, 0.381, 0.470, 0.569, 0.677, 0.796, 0.925, 1.056, 1.176, 1.268, 1.319, 1.339, 1.347, 1.351, 1.357,
|
||||
1.364, 1.372, 1.380, 1.388, 1.396, 1.404, 1.412,
|
||||
];
|
||||
#[allow(clippy::approx_constant)] // measured values, one of them happens to be close to e
|
||||
const HIGHLIGHTS_DOWN: [f32; 73] = [
|
||||
0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000,
|
||||
0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, -0.004, -0.020, -0.046, -0.068, -0.086, -0.104, -0.116, -0.118, -0.118, -0.118, -0.118, -0.118,
|
||||
-0.119, -0.120, -0.123, -0.127, -0.131, -0.144, -0.169, -0.204, -0.245, -0.301, -0.375, -0.452, -0.537, -0.639, -0.753, -0.872, -0.997, -1.132, -1.294,
|
||||
-1.475, -1.637, -1.751, -1.836, -1.936, -2.059, -2.191, -2.323, -2.454, -2.586, -2.718, -2.850,
|
||||
];
|
||||
|
||||
/// Clarity: the work-copy radii of the bases between its bands, and their guided-filter ε.
|
||||
const CLARITY_RADII: [f32; 4] = [2.0, 8.0, 32.0, 128.0];
|
||||
const CLARITY_EPS: f32 = 1.0;
|
||||
/// Texture: long side of its copy, the Gaussians' σ there, and the guided upsampling's ε (small:
|
||||
/// the change must follow fine detail into the full-size pixels).
|
||||
const TEXTURE_SIDE: usize = 2464;
|
||||
const TEXTURE_SIGMAS: [f32; 6] = [0.5, 1.0, 2.0, 4.0, 8.0, 16.0];
|
||||
const TEXTURE_UPSAMPLE_EPS: f32 = 1e-4;
|
||||
/// Dehaze: dark-channel window radius and guided smoothing radius (work copy), its ε, the
|
||||
/// smallest transmission, and the haze weight at +100 and −100.
|
||||
const HAZE_RADIUS: f32 = 8.0;
|
||||
const HAZE_SMOOTH: f32 = 64.0;
|
||||
const HAZE_EPS: f32 = 1e-3;
|
||||
const HAZE_T0: f32 = 0.1;
|
||||
const HAZE_UP: f32 = 0.8;
|
||||
const HAZE_DOWN: f32 = 0.22;
|
||||
/// Linear ProPhoto → linear Display P3 (both D50): the dark channel was measured in Photoshop's
|
||||
/// Display P3 output.
|
||||
const PRO_TO_P3: [[f32; 3]; 3] = [[1.632638, -0.379816, -0.252821], [-0.153846, 1.166859, -0.013013], [0.010511, -0.062886, 1.052375]];
|
||||
|
||||
/// A change of log₂ luminance computed on a reduced copy, carried to full size along the image's
|
||||
/// own edges (guided upsampling): a local linear model `change = a · log₂ Y + b` per reduced pixel.
|
||||
struct Upsampled {
|
||||
sw: usize,
|
||||
sh: usize,
|
||||
a: Vec<f32>,
|
||||
b: Vec<f32>,
|
||||
/// log₂ luminance floor (the reduced copy's 0.1th percentile).
|
||||
floor: f32,
|
||||
}
|
||||
|
||||
impl Upsampled {
|
||||
/// The change at full-size pixel (`x`, `y`) of a `w × h` image, of linear luminance `lum`.
|
||||
fn change(&self, w: usize, h: usize, x: usize, y: usize, lum: f32) -> f32 {
|
||||
let l = if lum > 0.0 { lum.log2().max(self.floor) } else { self.floor };
|
||||
bilinear(&self.a, self.sw, self.sh, w, h, x, y) * l + bilinear(&self.b, self.sw, self.sh, w, h, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` (`sw × sh`) at full-size pixel (`x`, `y`) of a `w × h` image, bilinearly.
|
||||
fn bilinear(v: &[f32], sw: usize, sh: usize, w: usize, h: usize, x: usize, y: usize) -> f32 {
|
||||
let sx = ((x as f32 + 0.5) * sw as f32 / w as f32 - 0.5).clamp(0.0, (sw - 1) as f32);
|
||||
let sy = ((y as f32 + 0.5) * sh as f32 / h as f32 - 0.5).clamp(0.0, (sh - 1) as f32);
|
||||
let (x0, y0) = (sx as usize, sy as usize);
|
||||
let (x1, y1) = ((x0 + 1).min(sw - 1), (y0 + 1).min(sh - 1));
|
||||
let (fx, fy) = (sx - x0 as f32, sy - y0 as f32);
|
||||
let top = v[y0 * sw + x0] * (1.0 - fx) + v[y0 * sw + x1] * fx;
|
||||
let bottom = v[y1 * sw + x0] * (1.0 - fx) + v[y1 * sw + x1] * fx;
|
||||
top * (1.0 - fy) + bottom * fy
|
||||
}
|
||||
|
||||
/// Box means of `f` over `k × k` cells, `k` the smallest factor that brings the long side to
|
||||
/// `side` or less: (`sw`, `sh`, the means).
|
||||
fn reduce<const N: usize>(w: usize, h: usize, side: usize, f: impl Fn(usize) -> [f32; N] + Sync) -> (usize, usize, Vec<[f32; N]>) {
|
||||
let k = w.max(h).div_ceil(side).max(1);
|
||||
let (sw, sh) = (w.div_ceil(k), h.div_ceil(k));
|
||||
let rows = par::map(sh, |sy| {
|
||||
let (y0, y1) = (sy * k, ((sy + 1) * k).min(h));
|
||||
(0..sw)
|
||||
.map(|sx| {
|
||||
let (x0, x1) = (sx * k, ((sx + 1) * k).min(w));
|
||||
let mut s = [0.0f64; N];
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let v = f(y * w + x);
|
||||
for c in 0..N {
|
||||
s[c] += f64::from(v[c].max(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = ((x1 - x0) * (y1 - y0)) as f64;
|
||||
s.map(|v| (v / n) as f32)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
(sw, sh, rows.concat())
|
||||
}
|
||||
|
||||
/// The luminance change of one image (Shadows, Highlights, Clarity on the work copy; Texture on
|
||||
/// a half-size copy) and the black point.
|
||||
pub(crate) struct ToneMap {
|
||||
w: usize,
|
||||
h: usize,
|
||||
work: Option<Upsampled>,
|
||||
texture: Option<Upsampled>,
|
||||
/// Linear black point to subtract (0: none).
|
||||
pub(crate) black: f32,
|
||||
}
|
||||
|
||||
impl ToneMap {
|
||||
/// `lum(i)`: linear luminance of full-size pixel `i` (row-major, `w × h`). `black_point`: the
|
||||
/// camera profile's black-point factor (see [`crate::Sensor::black_point`]). `None` when
|
||||
/// nothing changes. Dehaze is not part of it (see [`Haze`]).
|
||||
pub(crate) fn new(w: usize, h: usize, lum: impl Fn(usize) -> f32 + Sync, tone: Tone, black_point: f32) -> Option<Self> {
|
||||
if w == 0 || h == 0 || (tone.gain_neutral() && black_point <= 0.0) {
|
||||
return None;
|
||||
}
|
||||
let (sw, sh, y) = reduce(w, h, WORK_SIDE, |i| [lum(i)]);
|
||||
let y: Vec<f32> = y.into_iter().map(|[v]| v).collect();
|
||||
let p01 = quantile(&y, 0.001).max(2f32.powi(-20));
|
||||
let black = if black_point > 0.0 { black_point * p01 } else { 0.0 };
|
||||
let work = (!tone.work_neutral()).then(|| {
|
||||
let l: Vec<f32> = y.iter().map(|v| v.max(p01).log2()).collect();
|
||||
let n = l.len() as f64;
|
||||
let geo = (l.iter().map(|v| f64::from(*v)).sum::<f64>() / n) as f32;
|
||||
let scale = sw.max(sh) as f32 / WORK_SIDE as f32;
|
||||
let mut change = vec![0.0f32; l.len()];
|
||||
if tone.shadows != 0.0 || tone.highlights != 0.0 {
|
||||
let pm = ((l.iter().map(|v| f64::from(v.exp2()).powf(1.5)).sum::<f64>() / n).log2() / 1.5) as f32;
|
||||
let r = ((BASE_RADIUS * scale).round() as usize).max(1);
|
||||
let base = guided(&l, &l, sw, sh, r, BASE_EPS);
|
||||
for (c, b) in change.iter_mut().zip(&base) {
|
||||
*c += shadows(tone.shadows, b - pm) + highlights(tone.highlights, b - geo);
|
||||
}
|
||||
}
|
||||
if tone.clarity != 0.0 {
|
||||
clarity(&mut change, &l, sw, sh, geo, scale, tone.clarity);
|
||||
}
|
||||
// Fit change ≈ a · l + b locally, averaged; evaluated at full size. The large ε lets the
|
||||
// change follow strong edges only: inside texture it stays smooth, so fine detail keeps
|
||||
// its contrast as in Camera Raw (with ε 0.01 shadow detail lost a fifth of it; with ε 4 the change bled ~16 px over 3-stop edges).
|
||||
let (a, b) = linear_fit(&l, &change, sw, sh, 2, UPSAMPLE_EPS);
|
||||
Upsampled { sw, sh, a, b, floor: p01.log2() }
|
||||
});
|
||||
let texture = (tone.texture != 0.0).then(|| texture(w, h, &lum, tone.texture));
|
||||
Some(ToneMap { w, h, work, texture, black })
|
||||
}
|
||||
|
||||
/// The gain at full-size pixel (`x`, `y`) of linear luminance `lum`.
|
||||
pub(crate) fn gain(&self, x: usize, y: usize, lum: f32) -> f32 {
|
||||
if self.work.is_none() && self.texture.is_none() {
|
||||
return 1.0;
|
||||
}
|
||||
let c = [&self.work, &self.texture].iter().filter_map(|u| u.as_ref()).map(|u| u.change(self.w, self.h, x, y, lum)).sum::<f32>();
|
||||
c.clamp(-6.0, 6.0).exp2()
|
||||
}
|
||||
|
||||
/// Black-point subtraction of one linear value.
|
||||
#[inline]
|
||||
pub(crate) fn unblack(&self, v: f32) -> f32 {
|
||||
if self.black > 0.0 { ((v - self.black) / (1.0 - self.black)).max(0.0) } else { v }
|
||||
}
|
||||
}
|
||||
|
||||
/// The black point of an image of luminance `lum` for the profile's factor (0: none): `factor`
|
||||
/// times the 0.1th percentile of the work copy's linear luminance.
|
||||
pub(crate) fn black_level(w: usize, h: usize, lum: impl Fn(usize) -> f32 + Sync, factor: f32) -> f32 {
|
||||
if w == 0 || h == 0 || factor <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (_, _, y) = reduce(w, h, WORK_SIDE, |i| [lum(i)]);
|
||||
let y: Vec<f32> = y.into_iter().map(|[v]| v).collect();
|
||||
factor * quantile(&y, 0.001).max(2f32.powi(-20))
|
||||
}
|
||||
|
||||
/// The slider's table for its direction and its weight 0…1.
|
||||
fn direction<T>(s: f32, up: &'static T, down: &'static T) -> (f32, &'static T) {
|
||||
let s = s.clamp(-100.0, 100.0) / 100.0;
|
||||
if s >= 0.0 { (s, up) } else { (-s, down) }
|
||||
}
|
||||
|
||||
/// A table on evenly spaced knots `lo`, `lo + step`, …, read linearly, flat beyond its ends.
|
||||
fn even<const N: usize>(c: &[f32; N], lo: f32, step: f32, x: f32) -> f32 {
|
||||
let t = ((x - lo) / step).clamp(0.0, (N - 1) as f32);
|
||||
let i = (t as usize).min(N - 2);
|
||||
c[i] + (c[i + 1] - c[i]) * (t - i as f32)
|
||||
}
|
||||
|
||||
/// Adds Clarity `s` to `change`: bands of `l` between guided bases, each scaled by a gain that
|
||||
/// follows its base's tone (relative to `geo`), and a tone term of the coarsest base.
|
||||
fn clarity(change: &mut [f32], l: &[f32], sw: usize, sh: usize, geo: f32, scale: f32, s: f32) {
|
||||
let (k, t) = direction(s, &tables::CLARITY_UP, &tables::CLARITY_DOWN);
|
||||
let mut prev = l.to_vec();
|
||||
for (band, r) in CLARITY_RADII.iter().enumerate() {
|
||||
let base = guided(l, l, sw, sh, ((r * scale).round() as usize).max(1), CLARITY_EPS);
|
||||
for ((c, p), b) in change.iter_mut().zip(&prev).zip(&base) {
|
||||
*c += k * (p - b) * even(&t.bands[band], tables::CLARITY_TONE_LO, tables::CLARITY_TONE_STEP, b - geo);
|
||||
}
|
||||
prev = base;
|
||||
}
|
||||
for (c, b) in change.iter_mut().zip(&prev) {
|
||||
*c += k * (even(&t.base, tables::CLARITY_BASE_LO, tables::CLARITY_BASE_STEP, b - geo) + t.offset);
|
||||
}
|
||||
}
|
||||
|
||||
/// Texture `s` on a half-size copy: each Gaussian band of log₂ luminance through its measured
|
||||
/// response (zero for flat areas), carried to full size.
|
||||
fn texture(w: usize, h: usize, lum: &(impl Fn(usize) -> f32 + Sync), s: f32) -> Upsampled {
|
||||
let (sw, sh, y) = reduce(w, h, TEXTURE_SIDE, |i| [lum(i)]);
|
||||
let y: Vec<f32> = y.into_iter().map(|[v]| v).collect();
|
||||
let p01 = quantile(&y, 0.001).max(2f32.powi(-20));
|
||||
let l: Vec<f32> = y.iter().map(|v| v.max(p01).log2()).collect();
|
||||
let scale = sw.max(sh) as f32 / TEXTURE_SIDE as f32;
|
||||
let (k, t) = direction(s, &tables::TEXTURE_UP, &tables::TEXTURE_DOWN);
|
||||
let mut change = vec![0.0f32; l.len()];
|
||||
let mut prev = l.clone();
|
||||
for (band, sigma) in TEXTURE_SIGMAS.iter().enumerate() {
|
||||
let base = gauss(&l, sw, sh, sigma * scale);
|
||||
let zero = knots(&t[band], 0.0);
|
||||
for ((c, p), b) in change.iter_mut().zip(&prev).zip(&base) {
|
||||
*c += k * (knots(&t[band], p - b) - zero);
|
||||
}
|
||||
prev = base;
|
||||
}
|
||||
let (a, b) = linear_fit(&l, &change, sw, sh, 2, TEXTURE_UPSAMPLE_EPS);
|
||||
Upsampled { sw, sh, a, b, floor: p01.log2() }
|
||||
}
|
||||
|
||||
/// Texture's band response on the knots [`tables::TEXTURE_KNOTS`], read linearly, flat beyond.
|
||||
fn knots(c: &[f32; 11], d: f32) -> f32 {
|
||||
let k = &tables::TEXTURE_KNOTS;
|
||||
let d = d.clamp(k[0], k[10]);
|
||||
let i = k.partition_point(|v| *v <= d).clamp(1, 10);
|
||||
let f = (d - k[i - 1]) / (k[i] - k[i - 1]);
|
||||
c[i - 1] + (c[i] - c[i - 1]) * f
|
||||
}
|
||||
|
||||
/// Gaussian blur of `v` (`w × h`), σ `sigma`, radius ⌈3σ⌉, mirrored edges.
|
||||
fn gauss(v: &[f32], w: usize, h: usize, sigma: f32) -> Vec<f32> {
|
||||
if sigma <= 0.05 {
|
||||
return v.to_vec();
|
||||
}
|
||||
let r = (3.0 * sigma).ceil() as isize;
|
||||
let mut k: Vec<f32> = (-r..=r).map(|x| (-(x * x) as f32 / (2.0 * sigma * sigma)).exp()).collect();
|
||||
let sum: f32 = k.iter().sum();
|
||||
k.iter_mut().for_each(|v| *v /= sum);
|
||||
let rows = par::map(h, |y| {
|
||||
let src = &v[y * w..(y + 1) * w];
|
||||
(0..w as isize).map(|x| k.iter().enumerate().map(|(j, kv)| kv * src[mirror(x + j as isize - r, w)]).sum::<f32>()).collect::<Vec<f32>>()
|
||||
});
|
||||
let tmp = rows.concat();
|
||||
let cols = par::map(w, |x| {
|
||||
(0..h as isize).map(|y| k.iter().enumerate().map(|(j, kv)| kv * tmp[mirror(y + j as isize - r, h) * w + x]).sum::<f32>()).collect::<Vec<f32>>()
|
||||
});
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
for (x, col) in cols.iter().enumerate() {
|
||||
for (y, v) in col.iter().enumerate() {
|
||||
out[y * w + x] = *v;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Minimum over a `(2r + 1)²` window, edges clamped.
|
||||
fn min_filter(v: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
|
||||
let rows = par::map(h, |y| {
|
||||
let src = &v[y * w..(y + 1) * w];
|
||||
(0..w).map(|x| src[x.saturating_sub(r)..=(x + r).min(w - 1)].iter().fold(f32::MAX, |a, b| a.min(*b))).collect::<Vec<f32>>()
|
||||
});
|
||||
let tmp = rows.concat();
|
||||
let cols =
|
||||
par::map(w, |x| (0..h).map(|y| (y.saturating_sub(r)..=(y + r).min(h - 1)).map(|yy| tmp[yy * w + x]).fold(f32::MAX, f32::min)).collect::<Vec<f32>>());
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
for (x, col) in cols.iter().enumerate() {
|
||||
for (y, v) in col.iter().enumerate() {
|
||||
out[y * w + x] = *v;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Camera Raw's Dehaze on linear ProPhoto RGB (see the module docs).
|
||||
pub(crate) struct Haze {
|
||||
w: usize,
|
||||
h: usize,
|
||||
sw: usize,
|
||||
sh: usize,
|
||||
/// Transmission per work-copy pixel (Dehaze > 0); empty for the veil of Dehaze < 0.
|
||||
t: Vec<f32>,
|
||||
/// Airlight, linear ProPhoto.
|
||||
air: [f32; 3],
|
||||
/// Weight of the uniform veil (Dehaze < 0).
|
||||
veil: f32,
|
||||
}
|
||||
|
||||
impl Haze {
|
||||
/// `px(i)`: linear ProPhoto RGB of full-size pixel `i`. `None` at 0.
|
||||
pub(crate) fn new(w: usize, h: usize, px: impl Fn(usize) -> [f32; 3] + Sync, amount: f32) -> Option<Self> {
|
||||
let amount = amount.clamp(-100.0, 100.0) / 100.0;
|
||||
if w == 0 || h == 0 || amount == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (sw, sh, small) = reduce(w, h, WORK_SIDE, px);
|
||||
let p3 = |v: &[f32; 3]| PRO_TO_P3.map(|row| (row[0] * v[0] + row[1] * v[1] + row[2] * v[2]).max(0.0));
|
||||
let small_p3: Vec<[f32; 3]> = small.iter().map(p3).collect();
|
||||
// Airlight: mean colour of the 0.1 % of pixels with the brightest dark channel.
|
||||
let dc: Vec<f32> = small_p3.iter().map(|c| c[0].min(c[1]).min(c[2])).collect();
|
||||
let top = (dc.len() / 1000).max(1);
|
||||
let mut idx: Vec<usize> = (0..dc.len()).collect();
|
||||
idx.select_nth_unstable_by(top - 1, |a, b| dc[*b].total_cmp(&dc[*a]));
|
||||
let mut air = [0.0f32; 3];
|
||||
for i in &idx[..top] {
|
||||
for c in 0..3 {
|
||||
air[c] += small[*i][c] / top as f32;
|
||||
}
|
||||
}
|
||||
let air = air.map(|v| v.max(1e-6));
|
||||
if amount < 0.0 {
|
||||
return Some(Haze { w, h, sw, sh, t: Vec::new(), air, veil: HAZE_DOWN * -amount });
|
||||
}
|
||||
let air_p3 = p3(&air).map(|v| v.max(1e-6));
|
||||
let scale = sw.max(sh) as f32 / WORK_SIDE as f32;
|
||||
let dark0: Vec<f32> = small_p3.iter().map(|c| (0..3).map(|k| c[k] / air_p3[k]).fold(f32::MAX, f32::min)).collect();
|
||||
let dark = min_filter(&dark0, sw, sh, ((HAZE_RADIUS * scale).round() as usize).max(1));
|
||||
let y = crate::color::rgb_to_xyz(crate::color::ROMM, crate::color::D50_XY)[1].map(|v| v as f32);
|
||||
let lum = |c: &[f32; 3]| y[0] * c[0] + y[1] * c[1] + y[2] * c[2];
|
||||
let air_y = lum(&air).max(1e-6);
|
||||
let guide: Vec<f32> = small.iter().map(|c| lum(c) / air_y).collect();
|
||||
let smooth = guided(&guide, &dark, sw, sh, ((HAZE_SMOOTH * scale).round() as usize).max(1), HAZE_EPS);
|
||||
let wgt = HAZE_UP * amount;
|
||||
let t = smooth.iter().map(|d| (1.0 - wgt * d).max(HAZE_T0)).collect();
|
||||
Some(Haze { w, h, sw, sh, t, air, veil: 0.0 })
|
||||
}
|
||||
|
||||
/// Dehaze at full-size pixel (`x`, `y`) of linear ProPhoto `v`.
|
||||
pub(crate) fn apply(&self, x: usize, y: usize, v: [f32; 3]) -> [f32; 3] {
|
||||
if self.t.is_empty() {
|
||||
return [0, 1, 2].map(|c| v[c] * (1.0 - self.veil) + self.air[c] * self.veil);
|
||||
}
|
||||
let t = bilinear(&self.t, self.sw, self.sh, self.w, self.h, x, y).max(HAZE_T0);
|
||||
[0, 1, 2].map(|c| ((v[c] - self.air[c]) / t + self.air[c]).max(0.0))
|
||||
}
|
||||
}
|
||||
|
||||
fn curve(c: &[f32; 73], d: f32) -> f32 {
|
||||
let t = ((d - LO) / STEP).clamp(0.0, (c.len() - 1) as f32);
|
||||
let i = (t as usize).min(c.len() - 2);
|
||||
let f = t - i as f32;
|
||||
c[i] + (c[i + 1] - c[i]) * f
|
||||
}
|
||||
|
||||
/// log₂ gain of Shadows `s` (−100…100) at `d` stops from its anchor.
|
||||
fn shadows(s: f32, d: f32) -> f32 {
|
||||
let s = s.clamp(-100.0, 100.0) / 100.0;
|
||||
if s > 0.0 {
|
||||
s * curve(&SHADOWS_UP, d)
|
||||
} else if s < 0.0 {
|
||||
-s * curve(&SHADOWS_DOWN, d)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// log₂ gain of Highlights `h` (−100…100) at `d` stops from its anchor.
|
||||
fn highlights(h: f32, d: f32) -> f32 {
|
||||
let h = h.clamp(-100.0, 100.0) / 100.0;
|
||||
if h > 0.0 {
|
||||
h * curve(&HIGHLIGHTS_UP, d)
|
||||
} else if h < 0.0 {
|
||||
-h * curve(&HIGHLIGHTS_DOWN, d)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The `q` quantile (0…1) of `v`.
|
||||
fn quantile(v: &[f32], q: f32) -> f32 {
|
||||
let mut s = v.to_vec();
|
||||
let k = ((s.len() - 1) as f32 * q).round() as usize;
|
||||
let (_, m, _) = s.select_nth_unstable_by(k, |a, b| a.total_cmp(b));
|
||||
*m
|
||||
}
|
||||
|
||||
/// Index `i` mirrored into `0..n` (half-sample symmetric: … 1 0 | 0 1 … n−1 | n−1 n−2 …).
|
||||
fn mirror(i: isize, n: usize) -> usize {
|
||||
let n = n as isize;
|
||||
let p = i.rem_euclid(2 * n);
|
||||
(if p < n { p } else { 2 * n - 1 - p }) as usize
|
||||
}
|
||||
|
||||
/// Box mean of radius `r` with mirrored edges.
|
||||
fn box_mean(v: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
|
||||
let k = (2 * r + 1) as f64;
|
||||
let ri = r as isize;
|
||||
let rows = par::map(h, |y| {
|
||||
let src = &v[y * w..(y + 1) * w];
|
||||
let mut acc: f64 = (-ri..=ri).map(|i| f64::from(src[mirror(i, w)])).sum();
|
||||
let mut out = vec![0.0f32; w];
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
*o = (acc / k) as f32;
|
||||
let x = x as isize;
|
||||
acc += f64::from(src[mirror(x + ri + 1, w)]) - f64::from(src[mirror(x - ri, w)]);
|
||||
}
|
||||
out
|
||||
});
|
||||
let tmp = rows.concat();
|
||||
let cols = par::map(w, |x| {
|
||||
let at = |y: isize| f64::from(tmp[mirror(y, h) * w + x]);
|
||||
let mut acc: f64 = (-ri..=ri).map(at).sum();
|
||||
let mut out = vec![0.0f32; h];
|
||||
for (y, o) in out.iter_mut().enumerate() {
|
||||
*o = (acc / k) as f32;
|
||||
let y = y as isize;
|
||||
acc += at(y + ri + 1) - at(y - ri);
|
||||
}
|
||||
out
|
||||
});
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
for (x, col) in cols.iter().enumerate() {
|
||||
for (y, v) in col.iter().enumerate() {
|
||||
out[y * w + x] = *v;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Local linear fit `p ≈ a · i + b` over boxes of radius `r` (regularized by `eps`), with `a`
|
||||
/// and `b` box-averaged (K. He, J. Sun, X. Tang, *Guided Image Filtering*, 2010).
|
||||
fn linear_fit(i: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> (Vec<f32>, Vec<f32>) {
|
||||
let ip: Vec<f32> = i.iter().zip(p).map(|(a, b)| a * b).collect();
|
||||
let ii: Vec<f32> = i.iter().map(|a| a * a).collect();
|
||||
let (mi, mp, mip, mii) = (box_mean(i, w, h, r), box_mean(p, w, h, r), box_mean(&ip, w, h, r), box_mean(&ii, w, h, r));
|
||||
let a: Vec<f32> = (0..w * h).map(|k| (mip[k] - mi[k] * mp[k]) / ((mii[k] - mi[k] * mi[k]).max(0.0) + eps)).collect();
|
||||
let b: Vec<f32> = (0..w * h).map(|k| mp[k] - a[k] * mi[k]).collect();
|
||||
(box_mean(&a, w, h, r), box_mean(&b, w, h, r))
|
||||
}
|
||||
|
||||
/// Guided filter of `p` with guide `i`.
|
||||
fn guided(i: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
|
||||
let (a, b) = linear_fit(i, p, w, h, r, eps);
|
||||
a.iter().zip(&b).zip(i).map(|((a, b), i)| a * i + b).collect()
|
||||
}
|
||||
|
||||
/// The raw-stage sliders ([`Tone`]) on an image [`develop`](crate::develop) already made (gamma-1.8 ProPhoto
|
||||
/// RGBA, 0…1) with the profile tone `curve`, for a preview of the open-time setting: the curve is
|
||||
/// undone, the change applied in linear light and the curve redone. The developed pixels already
|
||||
/// carry the black point, so the change's map is computed from the luminance after it rather
|
||||
/// than before; that only differs in the deepest shadows.
|
||||
pub fn retone(px: &mut [[f32; 4]], w: usize, h: usize, curve: &[[f32; 2]], tone: Tone) {
|
||||
if tone.is_neutral() || w == 0 || h == 0 || px.len() < w * h {
|
||||
return;
|
||||
}
|
||||
let forward = |v: f32| crate::develop::tone(curve, v.clamp(0.0, 1.0)).clamp(0.0, 1.0).powf(1.0 / 1.8);
|
||||
// The inverse from a table over s = √v (the encoding is steep near 0).
|
||||
const N: usize = 4096;
|
||||
let table: Vec<f32> = (0..=N).map(|i| forward((i as f32 / N as f32).powi(2))).collect();
|
||||
let inverse = |e: f32| {
|
||||
let i = table.partition_point(|t| *t < e).clamp(1, N);
|
||||
let (a, b) = (table[i - 1], table[i]);
|
||||
let f = if b > a { ((e - a) / (b - a)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
((i - 1) as f32 + f) / N as f32
|
||||
};
|
||||
let mut lin: Vec<[f32; 3]> = px.iter().map(|p| [0, 1, 2].map(|k| inverse(p[k]).powi(2))).collect();
|
||||
if let Some(haze) = Haze::new(w, h, |i| lin[i], tone.dehaze) {
|
||||
for (i, l) in lin.iter_mut().enumerate() {
|
||||
*l = haze.apply(i % w, i / w, *l);
|
||||
}
|
||||
}
|
||||
let y = crate::color::rgb_to_xyz(crate::color::ROMM, crate::color::D50_XY)[1].map(|v| v as f32);
|
||||
let lum = |c: &[f32; 3]| y[0] * c[0] + y[1] * c[1] + y[2] * c[2];
|
||||
let map = ToneMap::new(w, h, |i| lum(&lin[i]), tone, 0.0);
|
||||
for (i, (p, l)) in px.iter_mut().zip(&lin).enumerate() {
|
||||
let g = map.as_ref().map_or(1.0, |m| m.gain(i % w, i / w, lum(l)));
|
||||
for k in 0..3 {
|
||||
p[k] = forward(l[k] * g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SHADOWS: Tone = Tone { shadows: 100.0, ..Tone::NEUTRAL };
|
||||
const HIGHLIGHTS: Tone = Tone { highlights: -100.0, ..Tone::NEUTRAL };
|
||||
|
||||
/// Three vertical bands of linear luminance 2⁻⁷ | 2⁻⁴ | 2⁻¹, times `scale`.
|
||||
fn bands(w: usize, h: usize, scale: f32) -> Vec<f32> {
|
||||
(0..w * h).map(|i| scale * [2f32.powi(-7), 2f32.powi(-4), 0.5][(i % w) * 3 / w]).collect()
|
||||
}
|
||||
|
||||
fn gains(y: &[f32], w: usize, h: usize, tone: Tone) -> Vec<f32> {
|
||||
let Some(m) = ToneMap::new(w, h, |i| y[i], tone, 0.0) else { return vec![1.0; w * h] };
|
||||
(0..w * h).map(|i| m.gain(i % w, i / w, y[i])).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_lift_the_dark_area_and_keep_the_bright_one() {
|
||||
let (w, h) = (300, 120);
|
||||
let g = gains(&bands(w, h, 1.0), w, h, SHADOWS);
|
||||
let (dark, mid, bright) = (g[60 * w + 50], g[60 * w + 150], g[60 * w + 250]);
|
||||
// 5 stops below the anchor: about +2 stops, as Camera Raw lifts such areas; 1 stop above it: almost nothing.
|
||||
assert!(dark.log2() > 1.8 && dark.log2() < 2.3, "{dark}");
|
||||
assert!(mid > 1.0 && mid < dark, "{mid}");
|
||||
assert!(bright.log2() < 0.06, "{bright}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_lower_the_bright_area_and_keep_the_dark_one() {
|
||||
let (w, h) = (300, 120);
|
||||
let g = gains(&bands(w, h, 1.0), w, h, HIGHLIGHTS);
|
||||
let (dark, bright) = (g[60 * w + 50], g[60 * w + 250]);
|
||||
// 3 stops above the anchor about −1 stop; 3 below a slight darkening (Camera Raw lowers a
|
||||
// high-contrast frame as a whole a little too).
|
||||
assert!(dark.log2() < 0.0 && dark.log2() > -0.15, "{dark}");
|
||||
assert!(bright.log2() < -0.8 && bright.log2() > -1.3, "{bright}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposure_changes_nothing() {
|
||||
let (w, h) = (300, 120);
|
||||
for tone in [SHADOWS, HIGHLIGHTS, Tone { shadows: -60.0, highlights: 40.0, clarity: 50.0, texture: -40.0, ..Tone::NEUTRAL }] {
|
||||
let (a, b) = (gains(&bands(w, h, 1.0), w, h, tone), gains(&bands(w, h, 0.25), w, h, tone));
|
||||
assert!(a.iter().zip(&b).all(|(a, b)| (a - b).abs() < 1e-3 * a), "{tone:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn areas_move_as_a_whole_without_a_halo() {
|
||||
// Next to an edge the change stays between the two areas' changes (no over- or undershoot)
|
||||
// and close to the change inside the area.
|
||||
let (w, h) = (600, 120);
|
||||
let g: Vec<f32> = gains(&bands(w, h, 1.0), w, h, SHADOWS).iter().map(|g| g.log2()).collect();
|
||||
let row = &g[60 * w..61 * w];
|
||||
let (dark, mid) = (row[w / 6], row[w / 2]);
|
||||
for (x, v) in row.iter().enumerate().take(w / 2).skip(w / 6) {
|
||||
assert!(*v <= dark + 1e-3 && *v >= mid - 1e-3, "{x}: {v} outside {mid}…{dark}");
|
||||
}
|
||||
// Within 0.3 stop: near strong edges the change matches Photoshop as well as elsewhere.
|
||||
assert!(dark - row[w / 3 - 2] < 0.3 && row[w / 3 + 2] - mid < 0.3, "{} {} | {} {}", dark, row[w / 3 - 2], row[w / 3 + 2], mid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_result_does_not_depend_on_the_image_size() {
|
||||
let probe = |w: usize, h: usize| {
|
||||
let g = gains(&bands(w, h, 1.0), w, h, SHADOWS);
|
||||
[w / 6, w / 2, 5 * w / 6].map(|x| g[(h / 2) * w + x].log2())
|
||||
};
|
||||
let (a, b) = (probe(4928, 1640), probe(1232, 410));
|
||||
assert!(a.iter().zip(&b).all(|(a, b)| (a - b).abs() < 0.02), "{a:?} vs {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_black_point_follows_the_darkest_tones() {
|
||||
let (w, h) = (300, 120);
|
||||
let y = bands(w, h, 1.0);
|
||||
let m = ToneMap::new(w, h, |i| y[i], Tone::default(), 0.33).unwrap();
|
||||
assert!((m.black - 0.33 * 2f32.powi(-7)).abs() < 1e-7, "{}", m.black);
|
||||
assert!(m.unblack(m.black * 0.5) == 0.0 && (m.unblack(1.0) - 1.0).abs() < 1e-6);
|
||||
assert!(ToneMap::new(w, h, |i| y[i], Tone::default(), 0.0).is_none());
|
||||
}
|
||||
|
||||
/// log₂ luminance of a `w × h` image: `base` plus a vertical sine of `amp` stops and period `period` px.
|
||||
fn stripes(w: usize, h: usize, base: f32, amp: f32, period: f32) -> Vec<f32> {
|
||||
(0..w * h).map(|i| (base + amp * ((i % w) as f32 * std::f32::consts::TAU / period).sin()).exp2()).collect()
|
||||
}
|
||||
|
||||
/// Peak-to-peak log₂ luminance over the middle row's central half, after the gains.
|
||||
fn swing(y: &[f32], g: &[f32], w: usize, h: usize) -> f32 {
|
||||
let row = (h / 2) * w;
|
||||
let v: Vec<f32> = (w / 4..3 * w / 4).map(|x| (y[row + x] * g[row + x]).log2()).collect();
|
||||
v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clarity_raises_midtone_detail_and_minus_flattens_it() {
|
||||
let (w, h) = (1232, 200);
|
||||
let y = stripes(w, h, -2.5, 0.25, 40.0);
|
||||
let before = swing(&y, &vec![1.0; w * h], w, h);
|
||||
let up = swing(&y, &gains(&y, w, h, Tone { clarity: 100.0, ..Tone::NEUTRAL }), w, h);
|
||||
let down = swing(&y, &gains(&y, w, h, Tone { clarity: -100.0, ..Tone::NEUTRAL }), w, h);
|
||||
// Measured: +100 roughly doubles midtone detail, −100 flattens it by more than a third.
|
||||
assert!(up > 1.5 * before && up < 3.5 * before, "{before} → {up}");
|
||||
assert!(down < 0.8 * before && down > 0.1 * before, "{before} → {down}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_raises_fine_detail_more_than_coarse_and_leaves_flat_areas() {
|
||||
let (w, h) = (2464, 160);
|
||||
let fine = stripes(w, h, -2.5, 0.1, 6.0);
|
||||
let coarse = stripes(w, h, -2.5, 0.1, 600.0);
|
||||
let gain = |y: &[f32]| swing(y, &gains(y, w, h, Tone { texture: 100.0, ..Tone::NEUTRAL }), w, h) / swing(y, &vec![1.0; w * h], w, h);
|
||||
let (f, c) = (gain(&fine), gain(&coarse));
|
||||
assert!(f > 1.15, "fine detail ×{f}");
|
||||
assert!(c < 1.05 && f > c + 0.1, "coarse ×{c}, fine ×{f}");
|
||||
let flat = vec![0.2f32; 400 * 300];
|
||||
assert!(gains(&flat, 400, 300, Tone { texture: 100.0, ..Tone::NEUTRAL }).iter().all(|g| (g - 1.0).abs() < 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dehaze_lowers_the_haze_and_minus_adds_a_veil() {
|
||||
// A pale, hazy sky (top) over a darker, colourful ground.
|
||||
let (w, h) = (400, 300);
|
||||
let px: Vec<[f32; 3]> = (0..w * h).map(|i| if i / w < h / 2 { [0.55, 0.6, 0.7] } else { [0.12, 0.08, 0.03] }).collect();
|
||||
let up = Haze::new(w, h, |i| px[i], 100.0).unwrap();
|
||||
let (sky, ground) = (up.apply(200, 40, px[40 * w + 200]), up.apply(200, 260, px[260 * w + 200]));
|
||||
// The airlight itself stays; what is darker than it moves away from it.
|
||||
assert!(sky.iter().zip(&px[40 * w + 200]).all(|(a, b)| (a - b).abs() < 0.02), "{sky:?}");
|
||||
assert!(ground[2] < 0.03 && ground[0] <= 0.12, "{ground:?}");
|
||||
let down = Haze::new(w, h, |i| px[i], -100.0).unwrap();
|
||||
let veiled = down.apply(200, 260, px[260 * w + 200]);
|
||||
assert!((0..3).all(|c| veiled[c] > px[260 * w + 200][c]), "{veiled:?}");
|
||||
assert!(Haze::new(w, h, |i| px[i], 0.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retone_changes_nothing_at_zero_and_matches_the_develop_stage() {
|
||||
let (w, h) = (300, 120);
|
||||
let y = bands(w, h, 1.0);
|
||||
let curve = [[0.01, 0.02], [0.1, 0.25], [1.0, 1.0]];
|
||||
let enc = |v: f32| crate::develop::tone(&curve, v).powf(1.0 / 1.8);
|
||||
let px: Vec<[f32; 4]> = y.iter().map(|v| [enc(*v), enc(*v), enc(*v), 1.0]).collect();
|
||||
let mut same = px.clone();
|
||||
retone(&mut same, w, h, &curve, Tone::default());
|
||||
assert_eq!(same, px);
|
||||
let mut out = px.clone();
|
||||
retone(&mut out, w, h, &curve, SHADOWS);
|
||||
let g = gains(&y, w, h, SHADOWS);
|
||||
for i in [60 * w + 50, 60 * w + 150, 60 * w + 250] {
|
||||
assert!((out[i][0] - enc(y[i] * g[i])).abs() < 0.003, "{i}: {} vs {}", out[i][0], enc(y[i] * g[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
//! Measured tables for Clarity and Texture (see the parent module's docs). Generated by
|
||||
//! photoshop-acr-re `tools/export_presence.py` from the fits in `tools/presfinal.py`.
|
||||
|
||||
/// Clarity's tone knots for the band gains (relative to the frame's mean log₂ luminance)
|
||||
/// and for the tone term.
|
||||
pub(super) const CLARITY_TONE_LO: f32 = -8.0;
|
||||
pub(super) const CLARITY_TONE_STEP: f32 = 2.0;
|
||||
pub(super) const CLARITY_BASE_LO: f32 = -8.0;
|
||||
pub(super) const CLARITY_BASE_STEP: f32 = 1.0;
|
||||
|
||||
/// One direction of Clarity at full strength: per band (finest first) the gain of the band
|
||||
/// against its base's tone, the tone term of the coarsest base, and a constant (log₂).
|
||||
pub(super) struct Clarity {
|
||||
pub(super) bands: [[f32; 7]; 4],
|
||||
pub(super) base: [f32; 13],
|
||||
pub(super) offset: f32,
|
||||
}
|
||||
|
||||
/// Clarity +100 (cross-validated effect correlation 0.81).
|
||||
pub(super) const CLARITY_UP: Clarity = Clarity {
|
||||
bands: [
|
||||
[0.9573, 0.9573, 0.9573, 1.5534, 1.3206, 1.0011, 1.1671],
|
||||
[1.0920, 1.0920, 1.0920, 1.1946, 0.9268, 0.5702, 1.1336],
|
||||
[0.7149, 0.7149, 0.7149, 0.9739, 0.8363, 0.5026, 0.6025],
|
||||
[0.9620, 0.9620, 0.9620, 0.5886, 0.4937, 0.2263, 0.7602],
|
||||
],
|
||||
base: [0.2924, 0.2924, 0.2924, 0.2924, 0.2924, 0.2924, 0.1063, 0.0520, 0.2993, 0.2779, -0.0507, -0.0348, -0.2987],
|
||||
offset: -0.2342,
|
||||
};
|
||||
|
||||
/// Clarity -100 (cross-validated effect correlation 0.92).
|
||||
pub(super) const CLARITY_DOWN: Clarity = Clarity {
|
||||
bands: [
|
||||
[-0.0449, -0.0449, -0.0449, 0.0021, -0.5728, -1.0000, -0.8334],
|
||||
[-0.0734, -0.0734, -0.0734, -0.0653, -0.7011, -1.0000, -0.8558],
|
||||
[-0.0462, -0.0462, -0.0462, -0.0394, -0.4121, -0.6502, -0.5797],
|
||||
[0.0527, 0.0527, 0.0527, -0.0505, 0.0206, 0.0066, 0.0594],
|
||||
],
|
||||
base: [-0.0179, -0.0179, -0.0179, -0.0179, -0.0179, -0.0179, -0.0079, -0.0138, 0.0165, 0.0167, 0.0509, 0.0485, 0.0390],
|
||||
offset: 0.0107,
|
||||
};
|
||||
|
||||
/// Texture's band-response knots (stops of band detail).
|
||||
pub(super) const TEXTURE_KNOTS: [f32; 11] = [-2.0000, -1.0000, -0.5000, -0.2500, -0.1000, 0.0000, 0.1000, 0.2500, 0.5000, 1.0000, 2.0000];
|
||||
|
||||
/// Texture +100: per band (σ 0.5 … 16 on the half-size copy) its change against
|
||||
/// the band's detail on [`TEXTURE_KNOTS`] (cross-validated correlation 0.89).
|
||||
pub(super) const TEXTURE_UP: [[f32; 11]; 6] = [
|
||||
[-0.1951, -0.1951, -0.1951, -0.1951, -0.0637, -0.0122, 0.0595, 0.2096, 0.2096, 0.2096, 0.2096],
|
||||
[-0.0598, -0.0598, -0.0598, -0.1444, -0.0747, -0.0089, 0.0477, 0.0790, 0.0494, 0.0494, 0.0494],
|
||||
[-0.0648, -0.0648, -0.0648, -0.1096, -0.0822, -0.0196, 0.0420, 0.0509, 0.0331, 0.0331, 0.0331],
|
||||
[-0.0886, -0.0886, -0.0886, -0.0958, -0.0526, 0.0136, 0.0836, 0.0817, 0.0851, 0.0851, 0.0851],
|
||||
[-0.0728, -0.0728, -0.0728, -0.0525, -0.0203, 0.0305, 0.0752, 0.0759, 0.0762, 0.0408, 0.0408],
|
||||
[-0.0140, -0.0140, 0.0051, 0.0180, 0.0141, 0.0207, 0.0181, 0.0092, 0.0050, -0.0051, -0.0051],
|
||||
];
|
||||
|
||||
/// Texture -100: per band (σ 0.5 … 16 on the half-size copy) its change against
|
||||
/// the band's detail on [`TEXTURE_KNOTS`] (cross-validated correlation 0.91).
|
||||
pub(super) const TEXTURE_DOWN: [[f32; 11]; 6] = [
|
||||
[0.0194, 0.0194, 0.0194, 0.0194, -0.1470, -0.2059, -0.2565, -0.3623, -0.3623, -0.3623, -0.3623],
|
||||
[0.1957, 0.1957, 0.1957, 0.2306, 0.1984, 0.1493, 0.0965, 0.0527, 0.0333, 0.0333, 0.0333],
|
||||
[0.0426, 0.0426, 0.0426, 0.0942, 0.0664, 0.0104, -0.0384, -0.0452, -0.0167, -0.0167, -0.0167],
|
||||
[0.0725, 0.0725, 0.0725, 0.0795, 0.0659, 0.0050, -0.0538, -0.0603, -0.0689, -0.0689, -0.0689],
|
||||
[0.0771, 0.0771, 0.0771, 0.0492, 0.0319, -0.0107, -0.0507, -0.0533, -0.0512, -0.0225, -0.0225],
|
||||
[0.0137, 0.0137, 0.0027, -0.0062, -0.0018, -0.0046, -0.0051, 0.0038, 0.0086, 0.0170, 0.0170],
|
||||
];
|
||||
|
|
@ -64,6 +64,9 @@ pub struct CameraRawDialog {
|
|||
pub render_ms: f64,
|
||||
/// Set for the open-time dialog ([`Target::OpenRaw`]).
|
||||
raw_open: Option<RawOpen>,
|
||||
/// The open-time dialog's camera tone curve: Shadows / Highlights act on the raw data there,
|
||||
/// in linear light before this curve, and the preview undoes it to show the same.
|
||||
raw_curve: Option<Vec<[f32; 2]>>,
|
||||
}
|
||||
|
||||
impl CameraRawDialog {
|
||||
|
|
@ -129,6 +132,11 @@ impl CameraRawDialog {
|
|||
let mut px = self.proxy.clone();
|
||||
let mut p = self.params.clone();
|
||||
p.pixel_scale = (self.pw as f32 / self.full_w.max(1) as f32).min(1.0);
|
||||
if let Some(curve) = &self.raw_curve {
|
||||
let tone = raw_stage(&p);
|
||||
photocraft_io::raw::retone(&mut px, self.pw, self.ph, curve, tone);
|
||||
(p.shadows, p.highlights, p.clarity, p.texture, p.dehaze) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
photocraft_algo::camera_raw::develop(&mut px, self.pw, self.ph, &p, self.float);
|
||||
if let Some(mask) = &self.selection {
|
||||
for ((out, original), coverage) in px.iter_mut().zip(&self.proxy).zip(mask) {
|
||||
|
|
@ -174,9 +182,10 @@ enum Target {
|
|||
|
||||
/// A raw file opened interactively, shown in Camera Raw before it is kept (as Photoshop opens
|
||||
/// raws in Adobe Camera Raw first). The document is already developed with the defaults; Open
|
||||
/// re-develops it when Temperature, Tint or Exposure changed (those act on the sensor data, see
|
||||
/// [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one Camera Raw
|
||||
/// step, then marks the document unmodified. Cancel closes it.
|
||||
/// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture or
|
||||
/// Dehaze changed (those act on the sensor data, see [`photocraft_io::raw::import_raw_tuned`])
|
||||
/// and applies the other settings as one Camera Raw step, then marks the document unmodified.
|
||||
/// Cancel closes it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RawOpen {
|
||||
pub document: photocraft_doc::DocId,
|
||||
|
|
@ -216,8 +225,15 @@ fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Resul
|
|||
app.session.set_active(index);
|
||||
let (layer, surf, _) = crate::distort_ui::active_pixels(app)?;
|
||||
open_pixels(app, ctx, layer, surf, CameraRaw::default(), Coverage::None, Target::OpenRaw)?;
|
||||
let curve = match (&raw.bytes, &raw.path) {
|
||||
(Some(b), _) => Some(photocraft_io::raw::tone_curve(b)),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
(None, Some(p)) => std::fs::read(p).ok().map(|b| photocraft_io::raw::tone_curve(&b)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(d) = app.camera_raw.as_mut() {
|
||||
d.layer_name = raw.name.clone();
|
||||
d.raw_curve = curve;
|
||||
d.raw_open = Some(raw);
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -256,6 +272,11 @@ pub struct Redevelop {
|
|||
filter_step: bool,
|
||||
}
|
||||
|
||||
/// The sliders a raw open applies while developing, before the tone curve (as Camera Raw does).
|
||||
fn raw_stage(p: &CameraRaw) -> photocraft_io::raw::Tone {
|
||||
photocraft_io::raw::Tone { shadows: p.shadows, highlights: p.highlights, clarity: p.clarity, texture: p.texture, dehaze: p.dehaze }
|
||||
}
|
||||
|
||||
/// Open: re-develop for raw-stage settings, apply the rest, keep the document unmodified.
|
||||
///
|
||||
/// Re-developing reads and decodes the whole raw again, so in the desktop app (background jobs
|
||||
|
|
@ -264,7 +285,16 @@ pub struct Redevelop {
|
|||
/// (tests, the web) it runs inline.
|
||||
fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) -> Result<Value, String> {
|
||||
let index = document_index(app, raw.document).ok_or("the raw document was closed")?;
|
||||
let tuning = photocraft_io::raw::RawTuning { temperature: params.temperature, tint: params.tint, exposure: params.exposure };
|
||||
let tuning = photocraft_io::raw::RawTuning {
|
||||
temperature: params.temperature,
|
||||
tint: params.tint,
|
||||
exposure: params.exposure,
|
||||
shadows: params.shadows,
|
||||
highlights: params.highlights,
|
||||
clarity: params.clarity,
|
||||
texture: params.texture,
|
||||
dehaze: params.dehaze,
|
||||
};
|
||||
// Without the file the raw-stage settings stay RGB adjustments, like the filter's.
|
||||
let source = match (&raw.bytes, &raw.path) {
|
||||
(Some(b), _) => Some(photocraft_engine::jobs::OpenSource::Bytes(b.clone())),
|
||||
|
|
@ -346,6 +376,11 @@ fn finish_redevelop(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen,
|
|||
crate::notices::io_warnings(app, &format!("Opened {}", raw.name), &without_develop_note(&warnings));
|
||||
let mut rest = params.clone();
|
||||
rest.exposure = 0.0;
|
||||
rest.shadows = 0.0;
|
||||
rest.highlights = 0.0;
|
||||
rest.clarity = 0.0;
|
||||
rest.texture = 0.0;
|
||||
rest.dehaze = 0.0;
|
||||
if v.get("wb").and_then(Value::as_bool) == Some(true) {
|
||||
rest.temperature = 0.0;
|
||||
rest.tint = 0.0;
|
||||
|
|
@ -482,6 +517,10 @@ fn open_pixels(
|
|||
let st = app.session.active().ok_or("no document")?;
|
||||
let canvas = st.doc.bounds();
|
||||
let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default();
|
||||
let mut params = params;
|
||||
if params.encoding_gamma == 0.0 {
|
||||
params.encoding_gamma = photocraft_engine::lens_cmds::encoding_gamma(&st.doc);
|
||||
}
|
||||
// Use the engine filter domain, including pixels outside the canvas: spatial Camera Raw
|
||||
// stages (vignette, grain, local contrast) must agree with the committed result.
|
||||
let area = canvas.union(&surf.content_bounds());
|
||||
|
|
@ -591,6 +630,7 @@ fn open_pixels(
|
|||
curve_rect: None,
|
||||
render_ms: 0.0,
|
||||
raw_open: None,
|
||||
raw_curve: None,
|
||||
};
|
||||
d.render(ctx);
|
||||
app.camera_raw = Some(d);
|
||||
|
|
@ -937,7 +977,8 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) {
|
|||
// Never tile a checker across the offscreen extent of a highly zoomed image.
|
||||
widgets::checker(preview.painter(), r.intersect(view), 8.0);
|
||||
preview.painter().image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let detail_needed = r.width() * ctx.pixels_per_point() > d.pw as f32 || r.height() * ctx.pixels_per_point() > d.ph as f32;
|
||||
let raw_tone = d.raw_curve.is_some() && !raw_stage(&d.params).is_neutral();
|
||||
let detail_needed = !raw_tone && (r.width() * ctx.pixels_per_point() > d.pw as f32 || r.height() * ctx.pixels_per_point() > d.ph as f32);
|
||||
d.detail.paint(&preview, r, d.preview_revision, d.show_before, &d.params, detail_needed, &d.display);
|
||||
if d.detail.pending() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
|
|
@ -1336,6 +1377,46 @@ mod tests {
|
|||
assert!(mean_red(&app) > before + 0.05, "{before} → {}", mean_red(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_of_a_raw_open_act_on_the_raw_data_and_the_preview_shows_it() {
|
||||
let (mut app, dng) = raw_app();
|
||||
let ctx = egui::Context::default();
|
||||
app.open_bytes("IMG_0002.dng", &dng).unwrap();
|
||||
let raw = app.pending_raw_open.take().unwrap();
|
||||
open_raw(&mut app, &ctx, raw).unwrap();
|
||||
assert!(app.camera_raw.as_ref().unwrap().raw_curve.is_some(), "the preview knows how the raw was developed");
|
||||
let mean = |px: &[[f32; 4]]| px.iter().map(|p| p[0] + p[1] + p[2]).sum::<f32>() / px.len() as f32;
|
||||
let preview_before = mean(&app.camera_raw.as_ref().unwrap().processed);
|
||||
let before = mean_red(&app);
|
||||
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"shadows": 100}}})).unwrap().unwrap();
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
d.render(&ctx);
|
||||
assert!(mean(&d.processed) > preview_before + 0.01, "{preview_before} → {}", mean(&d.processed));
|
||||
let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap();
|
||||
assert_eq!(r["redeveloped"], true, "{r}");
|
||||
assert!(r.get("filter").is_none(), "Shadows acts while developing, not as a later filter step: {r}");
|
||||
assert!(mean_red(&app) > before + 0.01, "{before} → {}", mean_red(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clarity_texture_and_dehaze_of_a_raw_open_act_on_the_raw_data() {
|
||||
let (mut app, dng) = raw_app();
|
||||
let ctx = egui::Context::default();
|
||||
app.open_bytes("IMG_0003.dng", &dng).unwrap();
|
||||
let raw = app.pending_raw_open.take().unwrap();
|
||||
open_raw(&mut app, &ctx, raw).unwrap();
|
||||
let before = mean_red(&app);
|
||||
let preview_before = app.camera_raw.as_ref().unwrap().processed.clone();
|
||||
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"clarity": 60, "texture": 40, "dehaze": 50}}})).unwrap().unwrap();
|
||||
let d = app.camera_raw.as_mut().unwrap();
|
||||
d.render(&ctx);
|
||||
assert_ne!(d.processed, preview_before, "the preview shows the raw-stage change");
|
||||
let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap();
|
||||
assert_eq!(r["redeveloped"], true, "{r}");
|
||||
assert!(r.get("filter").is_none(), "they act while developing, not as a later filter step: {r}");
|
||||
assert!(mean_red(&app) < before, "Dehaze darkens: {before} → {}", mean_red(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_redevelop_keeps_the_tab_position_and_view() {
|
||||
let (mut app, dng) = raw_app();
|
||||
|
|
|
|||
|
|
@ -429,8 +429,9 @@ keeps the dialog open for correction. Nothing else writes document history.
|
|||
command line) of a camera raw developed from its sensor data shows this dialog first, titled
|
||||
"Camera Raw (name)", with **Open** and **Cancel**, as Photoshop opens raws in Adobe Camera Raw.
|
||||
`ui.inspect.cameraRaw.openingRaw` is then `{name, path}`. Commit (Open) re-develops the raw when
|
||||
Temperature, Tint or Exposure changed (as-shot white-balance gains and develop exposure on the
|
||||
sensor data; without an as-shot white balance, or without the file's bytes, they stay RGB
|
||||
Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture or Dehaze changed (as-shot
|
||||
white-balance gains, develop exposure, and the other five in linear light before the camera's
|
||||
tone curve, as Camera Raw does for raw files; without an as-shot white balance, or without the file's bytes, they stay RGB
|
||||
adjustments), applies the remaining settings as one `filter.cameraRaw` step and leaves the
|
||||
document unmodified; its result is `{document, redeveloped, filter?}`. Cancel closes the
|
||||
document. `app.open` and other automation opens never show the dialog. The preference
|
||||
|
|
|
|||
Loading…
Reference in New Issue