Render raws through Camera Raw's tone by default, after baseline exposure
The rendering half of camera-profiles.md §11-§12 (D21). The view transform gains a curve choice — Camera Raw (the default) or D19's sigmoid. Camera Raw converts to linear ProPhoto, clips to [0, 1], runs the curve on the largest and smallest channel and places the middle one at its old fraction between them (RefBaselineRGBTone), and converts back: hue kept, saturation raised where the curve is steep, which is where Adobe Standard's look desaturated. White sets the input scale (1 at its default, so sensor white is display white) and contrast bends the input about grey (1 at its default). The curve rides in the profile buffer after the tables: the profile's own, else the ACR3 default, which the placeholder every profile-less source binds also carries — so a CR2 with no .dcp still gets Camera Raw's tone. Baseline exposure is a gain folded into the rendering matrix at upload; RawImage::color_matrix stays the file's for the merge's linear DNG. camera_raw::apply_reference is the CPU statement; GPU tests hold the shader to it on 256 colours and on greys against the ACR3 table. The sigmoid's own tests now choose it explicitly.
This commit is contained in:
@@ -3420,8 +3420,10 @@ mod tests {
|
||||
read_centre(&ctx, t)
|
||||
};
|
||||
|
||||
// The default rendering of a raw is Camera Raw's tone (D21): a grey
|
||||
// goes through the ACR3 curve unchanged in hue.
|
||||
let scene = 3537.0 / 16383.0;
|
||||
let viewed = dr_pipeline::view::Sigmoid::default_curve().channel(scene);
|
||||
let viewed = dr_types::tone::evaluate(&dr_types::tone::ACR3_DEFAULT, scene);
|
||||
let expected = (dr_types::Transfer::Srgb.encode(viewed) * 255.0).round() as i32;
|
||||
let delta = (i32::from(from_sensor[0]) - expected).abs();
|
||||
assert!(
|
||||
|
||||
@@ -578,7 +578,7 @@ impl DemosaicedImage {
|
||||
view,
|
||||
width,
|
||||
height,
|
||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
||||
color_matrix: rendering_matrix(raw),
|
||||
profile_tables: raw.profile_tables.clone(),
|
||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||
non_linear: false,
|
||||
@@ -589,6 +589,23 @@ impl DemosaicedImage {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The camera matrix a raw renders through: the file's — identity where the
|
||||
/// body is uncalibrated, so the image renders with no colour transform
|
||||
/// rather than not at all — times the baseline exposure as a gain
|
||||
/// (camera-profiles.md §11).
|
||||
///
|
||||
/// A uniform gain commutes with every scene operation before the view
|
||||
/// transform, so folding it in here is the same as an exposure step at the
|
||||
/// head of the chain, at no cost. The camera-space tap and the white-balance
|
||||
/// probe read camera RGB before this matrix and are unaffected.
|
||||
/// `RawImage::color_matrix` stays the file's: a merge writes a linear DNG
|
||||
/// from it and must not bake a gain into its pixels.
|
||||
fn rendering_matrix(raw: &RawImage) -> [f32; 9] {
|
||||
let gain = raw.baseline_exposure.exp2();
|
||||
raw.color_matrix.unwrap_or(IDENTITY_3X3).map(|v| v * gain)
|
||||
}
|
||||
|
||||
/// Convert an f32 to half-precision bits, the general case: sign,
|
||||
/// subnormals, round-to-nearest-even, saturation at the largest finite.
|
||||
///
|
||||
@@ -937,7 +954,7 @@ impl Demosaicer {
|
||||
height,
|
||||
// Identity where the body is uncalibrated: the image renders with
|
||||
// no colour transform rather than not at all.
|
||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
||||
color_matrix: rendering_matrix(raw),
|
||||
profile_tables: raw.profile_tables.clone(),
|
||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||
// Whatever the profile database had for this body (FR-DEV-3e),
|
||||
|
||||
@@ -192,6 +192,41 @@ fn the_shader_agrees_with_the_cpu_reference() {
|
||||
assert_agrees(&got, |c| apply_reference(&tables, c, 2.0), "look at 200%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_raw_tone_agrees_with_its_cpu_reference() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// D21's rendering on 256 colours: the ProPhoto round trip, the clip, the
|
||||
// curve from the profile buffer's placeholder, and RGBTone's placement
|
||||
// of the middle channel, against `camera_raw::apply_reference`.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let source = Demosaicer::new(&ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&frame(None))
|
||||
.expect("upload");
|
||||
let ops: Vec<Box<dyn Operation>> = vec![Box::new(dr_pipeline::ops::ViewTransform::new())];
|
||||
let shader = dr_pipeline::compose(&ops);
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let got: Vec<[u8; 3]> = pixels.chunks_exact(4).map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let curve = &dr_types::tone::ACR3_DEFAULT;
|
||||
assert_agrees(
|
||||
&got,
|
||||
|c| {
|
||||
dr_pipeline::camera_raw::apply_reference(
|
||||
curve,
|
||||
c,
|
||||
dr_pipeline::view::DEFAULT_CONTRAST,
|
||||
dr_pipeline::view::DEFAULT_WHITE,
|
||||
)
|
||||
},
|
||||
"Camera Raw tone",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switched_off_or_absent_the_render_is_unchanged() {
|
||||
let Some(ctx) = ctx() else {
|
||||
|
||||
@@ -46,6 +46,40 @@ fn flat_raw(level: u16) -> RawImage {
|
||||
}
|
||||
}
|
||||
|
||||
/// The default chain with D19's sigmoid chosen: Camera Raw's tone is the
|
||||
/// default since D21, and these tests are about the sigmoid.
|
||||
fn sigmoid_chain() -> EditGraph {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(
|
||||
dr_pipeline::ops::view_transform::ID,
|
||||
dr_pipeline::ops::view_transform::CURVE,
|
||||
dr_pipeline::ops::view_transform::SIGMOID,
|
||||
);
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_raw_tone_is_the_default_and_agrees_with_the_acr3_curve() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// D21: a raw with no profile renders a grey through Camera Raw's default
|
||||
// curve, which the profile buffer's placeholder carries.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let graph = EditGraph::default_chain();
|
||||
for level in [500u16, 4_000, 8_520, 20_000, 40_000] {
|
||||
let scene = f32::from(level) / f32::from(u16::MAX);
|
||||
let display = dr_types::tone::evaluate(&dr_types::tone::ACR3_DEFAULT, scene);
|
||||
let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32;
|
||||
let got = i32::from(rendered(&ctx, level, &graph));
|
||||
assert!(
|
||||
(got - expected).abs() <= 2,
|
||||
"raw {level} rendered as {got}, the ACR3 curve says {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `graph` over a flat frame and return the centre pixel's red.
|
||||
///
|
||||
/// The centre rather than a corner: a demosaic has to invent its edges.
|
||||
@@ -70,7 +104,7 @@ fn the_shader_agrees_with_the_cpu_reference() {
|
||||
return;
|
||||
};
|
||||
let curve = Sigmoid::default_curve();
|
||||
let graph = EditGraph::default_chain();
|
||||
let graph = sigmoid_chain();
|
||||
for level in [0u16, 500, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
|
||||
let scene = f32::from(level) / f32::from(u16::MAX);
|
||||
let display = curve.channel(scene).min(1.0);
|
||||
@@ -96,7 +130,7 @@ fn highlights_above_one_stay_distinct() {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let mut graph = sigmoid_chain();
|
||||
graph.set_param(
|
||||
dr_pipeline::ops::exposure::ID,
|
||||
dr_pipeline::ops::exposure::EXPOSURE,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! TRACES: FR-DEV-3j | FR-DEV-3e
|
||||
//! Camera Raw's tone, as a rendering the view transform can choose (D21).
|
||||
//!
|
||||
//! Lightroom's colour is mostly its tone curve (camera-profiles.md §1): the
|
||||
//! profile's `ProfileToneCurve`, or the ACR3 default for a profile with none.
|
||||
//! Half of what the curve does is *how* it is applied. Camera Raw's
|
||||
//! `RefBaselineRGBTone` runs it on the largest and the smallest channel, and
|
||||
//! places the middle channel at the fraction between them it had before. Hue
|
||||
//! is kept; saturation rises wherever the curve is steeper than the
|
||||
//! diagonal, which for the ACR3 curve is the shadows and the midtones —
|
||||
//! where Adobe Standard's look table desaturated to compensate.
|
||||
//!
|
||||
//! It runs in linear ProPhoto, as Camera Raw does, on values clipped to
|
||||
//! `[0, 1]`; its output is linear and goes to the output transform as the
|
||||
//! sigmoid's does. The curve is read from the profile buffer
|
||||
//! (`ops::camera_profile::profile_buffer`), which always carries one.
|
||||
//!
|
||||
//! [`apply_reference`] is the arithmetic on the CPU; the GPU test holds the
|
||||
//! shader to it.
|
||||
|
||||
use crate::ops::camera_profile::{mul, working_prophoto};
|
||||
use crate::view::{DEFAULT_CONTRAST, DEFAULT_WHITE, SCENE_GREY};
|
||||
|
||||
/// The input scale for a white point: 1 at the default, so sensor white is
|
||||
/// display white as in Camera Raw; each stop of `white` above it halves the
|
||||
/// input.
|
||||
pub fn input_scale(white: f32) -> f32 {
|
||||
(DEFAULT_WHITE - white).exp2()
|
||||
}
|
||||
|
||||
/// The power the input is bent by about middle grey: 1 at the default
|
||||
/// contrast, so the curve is Camera Raw's untouched.
|
||||
pub fn contrast_power(contrast: f32) -> f32 {
|
||||
contrast / DEFAULT_CONTRAST
|
||||
}
|
||||
|
||||
/// The curve, its scale and its contrast applied to one ProPhoto colour.
|
||||
fn rgb_tone(curve: &[f32], p: [f32; 3]) -> [f32; 3] {
|
||||
let p = p.map(|v| v.clamp(0.0, 1.0));
|
||||
let hi = p[0].max(p[1]).max(p[2]);
|
||||
let lo = p[0].min(p[1]).min(p[2]);
|
||||
let (c_hi, c_lo) = (
|
||||
dr_types::tone::evaluate(curve, hi),
|
||||
dr_types::tone::evaluate(curve, lo),
|
||||
);
|
||||
if hi - lo <= 1e-7 {
|
||||
return [c_hi; 3];
|
||||
}
|
||||
p.map(|v| c_lo + (c_hi - c_lo) * (v - lo) / (hi - lo))
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3j
|
||||
/// The view transform's Camera Raw rendering of one working-space colour.
|
||||
pub fn apply_reference(curve: &[f32], c: [f32; 3], contrast: f32, white: f32) -> [f32; 3] {
|
||||
let (to, back) = working_prophoto();
|
||||
let scale = input_scale(white);
|
||||
let power = contrast_power(contrast);
|
||||
let mut p = mul(to, c).map(|v| v * scale);
|
||||
if power != 1.0 {
|
||||
p = p.map(|v| SCENE_GREY * (v.max(0.0) / SCENE_GREY).powf(power));
|
||||
}
|
||||
mul(back, rgb_tone(curve, p))
|
||||
}
|
||||
|
||||
/// The WGSL, a helper the view transform asks for after
|
||||
/// `ops::camera_profile`'s ProPhoto constants. Mirrors [`apply_reference`].
|
||||
pub const CAMERA_RAW_WGSL: &str = "
|
||||
fn camera_raw_curve(x: f32) -> f32 {
|
||||
let base = profile_curve_base();
|
||||
let n = u32(profile_table[2].x);
|
||||
let s = clamp(x, 0.0, 1.0) * f32(n - 1u);
|
||||
let i = min(u32(s), n - 2u);
|
||||
return mix(profile_table[base + i].x, profile_table[base + i + 1u].x, s - f32(i));
|
||||
}
|
||||
|
||||
// Camera Raw's RGBTone: the curve on the largest and smallest channel, the
|
||||
// middle one kept at its fraction between them, so hue survives.
|
||||
fn camera_raw_tone(c: vec3<f32>, scale: f32, power: f32, grey: f32) -> vec3<f32> {
|
||||
var p = PROFILE_FROM_WORKING * c * scale;
|
||||
if (power != 1.0) {
|
||||
p = grey * pow(max(p, vec3<f32>(0.0)) / grey, vec3<f32>(power));
|
||||
}
|
||||
p = clamp(p, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
let hi = max(p.r, max(p.g, p.b));
|
||||
let lo = min(p.r, min(p.g, p.b));
|
||||
let c_hi = camera_raw_curve(hi);
|
||||
let c_lo = camera_raw_curve(lo);
|
||||
var out = vec3<f32>(c_hi);
|
||||
if (hi - lo > 1e-7) {
|
||||
out = vec3<f32>(c_lo) + (c_hi - c_lo) * (p - vec3<f32>(lo)) / (hi - lo);
|
||||
}
|
||||
return PROFILE_TO_WORKING * out;
|
||||
}
|
||||
";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_types::tone::{evaluate, ACR3_DEFAULT};
|
||||
|
||||
fn identity() -> Vec<f32> {
|
||||
(0..1025).map(|i| i as f32 / 1024.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_its_defaults_the_input_is_untouched() {
|
||||
assert_eq!(input_scale(DEFAULT_WHITE), 1.0);
|
||||
assert_eq!(contrast_power(DEFAULT_CONTRAST), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grey_goes_through_the_curve_and_stays_grey() {
|
||||
for v in [0.02, 0.13, 0.5] {
|
||||
let out = apply_reference(&ACR3_DEFAULT, [v; 3], DEFAULT_CONTRAST, DEFAULT_WHITE);
|
||||
let want = evaluate(&ACR3_DEFAULT, v);
|
||||
assert!(
|
||||
out.iter().all(|o| (o - want).abs() < 1e-4),
|
||||
"{v}: {out:?} vs {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_identity_curve_changes_nothing_inside_the_range() {
|
||||
let c = [0.4, 0.2, 0.1];
|
||||
let out = apply_reference(&identity(), c, DEFAULT_CONTRAST, DEFAULT_WHITE);
|
||||
assert!(
|
||||
out.iter().zip(c).all(|(o, c)| (o - c).abs() < 1e-4),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_middle_channel_keeps_its_place_between_the_other_two() {
|
||||
let p = [0.3, 0.12, 0.05];
|
||||
let out = rgb_tone(&ACR3_DEFAULT, p);
|
||||
let before = (p[1] - p[2]) / (p[0] - p[2]);
|
||||
let after = (out[1] - out[2]) / (out[0] - out[2]);
|
||||
assert!((before - after).abs() < 1e-5, "{before} {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_acr_curve_raises_saturation_in_the_midtones() {
|
||||
let p = [0.15, 0.08, 0.05];
|
||||
let out = rgb_tone(&ACR3_DEFAULT, p);
|
||||
let sat = |c: [f32; 3]| (c[0] - c[2]) / c[0];
|
||||
assert!(sat(out) > sat(p), "{p:?} -> {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_halves_the_input_per_stop() {
|
||||
assert_eq!(input_scale(DEFAULT_WHITE + 1.0), 0.5);
|
||||
assert!(contrast_power(2.8) > 1.0);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@
|
||||
//! data neither would be physically meaningful (ARCH §5.2).
|
||||
|
||||
pub mod bundled;
|
||||
pub mod camera_raw;
|
||||
pub mod coverage;
|
||||
pub mod declared;
|
||||
pub mod descriptor;
|
||||
|
||||
@@ -51,10 +51,11 @@ pub const DEFAULT_LOOK: f32 = 100.0;
|
||||
/// Twice the profile's look: Lightroom's *Amount* reaches the same.
|
||||
pub const MAX_LOOK: f32 = 200.0;
|
||||
|
||||
/// Entries of the buffer's header: one `vec4` describing each table —
|
||||
/// `(hue divisions, saturation divisions, value divisions, sRGB-encoded)`,
|
||||
/// zero hue divisions meaning absent — before the entries themselves.
|
||||
pub const HEADER_ENTRIES: usize = 2;
|
||||
/// Entries of the buffer's header, before the entries themselves: one
|
||||
/// `vec4` describing each table — `(hue divisions, saturation divisions,
|
||||
/// value divisions, sRGB-encoded)`, zero hue divisions meaning absent — and
|
||||
/// a third whose `.x` is the tone curve's length (camera-profiles.md §12).
|
||||
pub const HEADER_ENTRIES: usize = 3;
|
||||
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
@@ -80,7 +81,7 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
/// Linear sRGB (the working space) to linear ProPhoto, and back, row-major,
|
||||
/// each row scaled to sum to one so that working white is ProPhoto white
|
||||
/// exactly and a neutral reaches the tables with zero saturation.
|
||||
fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) {
|
||||
pub(crate) fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) {
|
||||
static M: LazyLock<([f32; 9], [f32; 9])> = LazyLock::new(|| {
|
||||
let to = normalise_rows(dr_types::ColourSpace::ProPhoto.from_linear_srgb());
|
||||
let back = normalise_rows(invert(&to).expect("ProPhoto's matrix is invertible"));
|
||||
@@ -117,7 +118,7 @@ fn invert(m: &[f32; 9]) -> Option<[f32; 9]> {
|
||||
Some(inv.map(|v| v as f32))
|
||||
}
|
||||
|
||||
fn mul(m: &[f32; 9], c: [f32; 3]) -> [f32; 3] {
|
||||
pub(crate) fn mul(m: &[f32; 9], c: [f32; 3]) -> [f32; 3] {
|
||||
std::array::from_fn(|r| m[r * 3] * c[0] + m[r * 3 + 1] * c[1] + m[r * 3 + 2] * c[2])
|
||||
}
|
||||
|
||||
@@ -127,19 +128,48 @@ fn wgsl_mat(m: &[f32; 9]) -> String {
|
||||
format!("mat3x3<f32>({}, {}, {})", col(0), col(1), col(2))
|
||||
}
|
||||
|
||||
static HELPERS: LazyLock<[Helper; 1]> = LazyLock::new(|| {
|
||||
/// The working space to ProPhoto and back, as WGSL constants, and where
|
||||
/// the profile buffer's sections begin. A helper of its own because the
|
||||
/// view transform's Camera Raw curve needs it too, and helpers are emitted
|
||||
/// once each, in the order first asked for.
|
||||
pub(crate) static PROPHOTO_HELPER: LazyLock<Helper> = LazyLock::new(|| {
|
||||
let (to, back) = working_prophoto();
|
||||
let source = format!(
|
||||
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{LOOKUP_WGSL}",
|
||||
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{SECTIONS_WGSL}",
|
||||
wgsl_mat(to),
|
||||
wgsl_mat(back)
|
||||
);
|
||||
[Helper {
|
||||
name: "profile_apply",
|
||||
Helper {
|
||||
name: "profile_curve_base",
|
||||
source: Box::leak(source.into_boxed_str()),
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
static HELPERS: LazyLock<[Helper; 2]> = LazyLock::new(|| {
|
||||
[
|
||||
*PROPHOTO_HELPER,
|
||||
Helper {
|
||||
name: "profile_apply",
|
||||
source: LOOKUP_WGSL,
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
/// Where each section of the profile buffer starts, from its header.
|
||||
const SECTIONS_WGSL: &str = "
|
||||
fn profile_entries(dims: vec4<f32>) -> u32 {
|
||||
return u32(dims.x * dims.y * dims.z);
|
||||
}
|
||||
|
||||
fn profile_look_base() -> u32 {
|
||||
return 3u + profile_entries(profile_table[0]);
|
||||
}
|
||||
|
||||
fn profile_curve_base() -> u32 {
|
||||
return profile_look_base() + profile_entries(profile_table[1]);
|
||||
}
|
||||
";
|
||||
|
||||
/// The lookup, in WGSL. Mirrors [`apply_reference`] line for line.
|
||||
const LOOKUP_WGSL: &str = r#"
|
||||
fn profile_srgb_encode(v: f32) -> f32 {
|
||||
@@ -327,12 +357,11 @@ if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) {
|
||||
// A colour outside ProPhoto has no HSV the tables were made for; it
|
||||
// passes through rather than being floored, which would clip it (D19).
|
||||
if (min(p.r, min(p.g, p.b)) >= 0.0) {
|
||||
let look_base = 2u + u32(hue_sat_dims.x * hue_sat_dims.y * hue_sat_dims.z);
|
||||
if (hue_sat_dims.x > 0.0) {
|
||||
p = profile_apply(hue_sat_dims, 2u, p, 1.0);
|
||||
p = profile_apply(hue_sat_dims, 3u, p, 1.0);
|
||||
}
|
||||
if (look_dims.x > 0.0 && look > 0.0) {
|
||||
p = profile_apply(look_dims, look_base, p, look);
|
||||
p = profile_apply(look_dims, profile_look_base(), p, look);
|
||||
}
|
||||
c = PROFILE_TO_WORKING * p;
|
||||
}
|
||||
@@ -352,13 +381,16 @@ if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The storage buffer a source's tables are uploaded as: the two header
|
||||
/// `vec4`s, the HueSatMap's entries, then the LookTable's, each entry
|
||||
/// `(hue shift, saturation scale, value scale, 0)`.
|
||||
/// TRACES: FR-DEV-3e | FR-DEV-3j
|
||||
/// The storage buffer a source's profile is uploaded as: the three header
|
||||
/// `vec4`s, the HueSatMap's entries, the LookTable's, each entry
|
||||
/// `(hue shift, saturation scale, value scale, 0)`, then the tone curve's
|
||||
/// samples in `.x`.
|
||||
///
|
||||
/// Two zero `vec4`s where there are no tables — the placeholder every source
|
||||
/// without a profile binds, and what makes the fragment pass through.
|
||||
/// The curve is always there: the profile's own where it has one, Camera
|
||||
/// Raw's ACR3 default otherwise — including in the placeholder every source
|
||||
/// without a profile binds, whose tables are absent, so a raw with no
|
||||
/// profile still renders through Camera Raw's tone (D21).
|
||||
pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> {
|
||||
let header = |t: Option<&HueSatTable>| match t {
|
||||
Some(t) => [
|
||||
@@ -371,10 +403,18 @@ pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> {
|
||||
};
|
||||
let hue_sat = tables.and_then(|t| t.hue_sat.as_ref());
|
||||
let look = tables.and_then(|t| t.look.as_ref());
|
||||
let mut out = vec![header(hue_sat), header(look)];
|
||||
let curve: &[f32] = tables
|
||||
.and_then(|t| t.tone_curve.as_deref())
|
||||
.unwrap_or(&dr_types::tone::ACR3_DEFAULT);
|
||||
let mut out = vec![
|
||||
header(hue_sat),
|
||||
header(look),
|
||||
[curve.len() as f32, 0.0, 0.0, 0.0],
|
||||
];
|
||||
for t in [hue_sat, look].into_iter().flatten() {
|
||||
out.extend(t.entries.iter().map(|e| [e[0], e[1], e[2], 0.0]));
|
||||
}
|
||||
out.extend(curve.iter().map(|&v| [v, 0.0, 0.0, 0.0]));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -664,7 +704,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_buffer_puts_the_header_first_and_the_look_after_the_hue_sat_map() {
|
||||
assert_eq!(profile_buffer(None), vec![[0.0; 4]; 2]);
|
||||
let bare = profile_buffer(None);
|
||||
assert_eq!(bare[..2], [[0.0; 4]; 2], "no tables");
|
||||
assert_eq!(bare[2][0], 1025.0, "and Camera Raw's default curve");
|
||||
assert_eq!(bare.len(), HEADER_ENTRIES + 1025);
|
||||
let t = tables(
|
||||
Some(uniform(2, 2, 1, [1.0, 2.0, 3.0])),
|
||||
Some(uniform(3, 2, 2, [4.0, 5.0, 6.0])),
|
||||
@@ -672,8 +715,9 @@ mod tests {
|
||||
let b = profile_buffer(Some(&t));
|
||||
assert_eq!(b[0], [2.0, 2.0, 1.0, 0.0]);
|
||||
assert_eq!(b[1], [3.0, 2.0, 2.0, 0.0]);
|
||||
assert_eq!(b.len(), HEADER_ENTRIES + 4 + 12);
|
||||
assert_eq!(b[2], [1.0, 2.0, 3.0, 0.0]);
|
||||
assert_eq!(b.len(), HEADER_ENTRIES + 4 + 12 + 1025);
|
||||
assert_eq!(b[HEADER_ENTRIES], [1.0, 2.0, 3.0, 0.0]);
|
||||
assert_eq!(b[HEADER_ENTRIES + 4], [4.0, 5.0, 6.0, 0.0]);
|
||||
assert_eq!(b[HEADER_ENTRIES + 16][0], dr_types::tone::ACR3_DEFAULT[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,28 @@ use crate::view::{Sigmoid, CONTRAST_RANGE, DEFAULT_CONTRAST, DEFAULT_WHITE, WHIT
|
||||
pub const ID: OpId = OpId("view_transform");
|
||||
pub const CONTRAST: ParamId = ParamId("contrast");
|
||||
pub const WHITE: ParamId = ParamId("white");
|
||||
/// TRACES: FR-DEV-3j
|
||||
/// Which curve renders: Camera Raw's (index 0, the default — D21) or D19's
|
||||
/// sigmoid.
|
||||
pub const CURVE: ParamId = ParamId("curve");
|
||||
|
||||
static HELPERS: [Helper; 1] = [Helper {
|
||||
name: "view_sigmoid",
|
||||
source: crate::view::VIEW_SIGMOID_WGSL,
|
||||
}];
|
||||
/// [`CURVE`]'s values, in the order of its variants.
|
||||
pub const CAMERA_RAW: f32 = 0.0;
|
||||
pub const SIGMOID: f32 = 1.0;
|
||||
|
||||
static HELPERS: LazyLock<[Helper; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
Helper {
|
||||
name: "view_sigmoid",
|
||||
source: crate::view::VIEW_SIGMOID_WGSL,
|
||||
},
|
||||
*crate::ops::camera_profile::PROPHOTO_HELPER,
|
||||
Helper {
|
||||
name: "camera_raw_tone",
|
||||
source: crate::camera_raw::CAMERA_RAW_WGSL,
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
@@ -67,6 +84,14 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Scale::Linear,
|
||||
1,
|
||||
),
|
||||
ParamDescriptor::choice(
|
||||
"curve",
|
||||
"param.view_transform.curve",
|
||||
vec![
|
||||
LocalizedKey("param.view_transform.curve.camera_raw"),
|
||||
LocalizedKey("param.view_transform.curve.sigmoid"),
|
||||
],
|
||||
),
|
||||
],
|
||||
})
|
||||
});
|
||||
@@ -75,6 +100,7 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
pub struct ViewTransform {
|
||||
contrast: f32,
|
||||
white: f32,
|
||||
curve: f32,
|
||||
}
|
||||
|
||||
impl Default for ViewTransform {
|
||||
@@ -82,6 +108,7 @@ impl Default for ViewTransform {
|
||||
Self {
|
||||
contrast: DEFAULT_CONTRAST,
|
||||
white: DEFAULT_WHITE,
|
||||
curve: CAMERA_RAW,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +133,7 @@ impl Operation for ViewTransform {
|
||||
match id {
|
||||
CONTRAST => self.contrast = value,
|
||||
WHITE => self.white = value,
|
||||
CURVE => self.curve = value.round(),
|
||||
_ => log::warn!("view_transform: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
@@ -114,12 +142,13 @@ impl Operation for ViewTransform {
|
||||
match id {
|
||||
CONTRAST => self.contrast,
|
||||
WHITE => self.white,
|
||||
CURVE => self.curve,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE
|
||||
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE || self.curve != CAMERA_RAW
|
||||
}
|
||||
|
||||
fn stage(&self) -> Stage {
|
||||
@@ -130,8 +159,13 @@ impl Operation for ViewTransform {
|
||||
"\
|
||||
// Skipped for an already-rendered source: a JPEG is a display rendering
|
||||
// already, and rendering it again would compress it twice.
|
||||
// Camera Raw's tone by default (D21), D19's sigmoid by choice.
|
||||
if (!non_linear) {
|
||||
c = view_sigmoid(c, slope, inv_k, peak);
|
||||
if (mode < 0.5) {
|
||||
c = camera_raw_tone(c, cr_scale, cr_power, cr_grey);
|
||||
} else {
|
||||
c = view_sigmoid(c, slope, inv_k, peak);
|
||||
}
|
||||
}"
|
||||
.into()
|
||||
}
|
||||
@@ -151,11 +185,27 @@ if (!non_linear) {
|
||||
name: "peak",
|
||||
value: s.w,
|
||||
},
|
||||
Uniform {
|
||||
name: "mode",
|
||||
value: self.curve,
|
||||
},
|
||||
Uniform {
|
||||
name: "cr_scale",
|
||||
value: crate::camera_raw::input_scale(self.white),
|
||||
},
|
||||
Uniform {
|
||||
name: "cr_power",
|
||||
value: crate::camera_raw::contrast_power(self.contrast),
|
||||
},
|
||||
Uniform {
|
||||
name: "cr_grey",
|
||||
value: crate::view::SCENE_GREY,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &[Helper] {
|
||||
&HELPERS
|
||||
HELPERS.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +241,7 @@ mod tests {
|
||||
let s = Sigmoid::new(2.0, 6.0);
|
||||
let u = op.uniforms();
|
||||
assert_eq!(
|
||||
u.iter().map(|u| u.value).collect::<Vec<_>>(),
|
||||
u.iter().take(3).map(|u| u.value).collect::<Vec<_>>(),
|
||||
vec![s.n, s.inv_k, s.w]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user