//! GPU colour management vs the CPU effects (the oracle): Apply Color LUT, the OCIO effects,
//! Color Profile Converter and Lumetri's Input LUT / Look, direct on a buffer (full and half
//! resolution, as adjustment) and composited at 8 and 32 bpc.
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, check, compare_at, effect_case, effect_direct, n, opts, set};
/// [`effect_case`] for nearest-neighbour lattice lookups: an 8 bpc input that lands exactly on a
/// rounding boundary between two lattice points may pick the other one (the GPU's division is
/// not correctly rounded), so up to `allow` of the composited pixels may differ.
fn case_allow(id: &str, vals: &[(&str, Value)], allow: f64) {
effect_direct(id, vals, false);
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut l = s.footage(70, 44);
s.effect(&mut l, id, vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
check(&format!("{id} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), allow);
check(&format!("{id} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), allow);
}
}
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn s(v: &str) -> Value {
Value::Str(v.to_string())
}
/// A graded lattice: `f` over an `n`³ grid (red fastest).
fn lattice(n: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> String {
let mut out = String::new();
for b in 0..n {
for g in 0..n {
for r in 0..n {
let c = f([r, g, b].map(|i| i as f32 / (n - 1) as f32));
out += &format!("{} {} {}\n", c[0], c[1], c[2]);
}
}
}
out
}
fn grade(c: [f32; 3]) -> [f32; 3] {
[c[0].powf(0.8) * 1.05, c[1] * 0.9 + 0.08 * (c[0] * 5.0).sin(), c[2] * 0.7 + c[0] * 0.2 + 0.03]
}
fn cube_3d() -> String {
format!("TITLE \"grade\"\nLUT_3D_SIZE 9\n{}", lattice(9, grade))
}
fn cube_1d_3d() -> String {
let mut s = String::from("LUT_1D_SIZE 16\nLUT_3D_SIZE 7\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1.2 1.2 1.2\n");
for i in 0..16 {
let v = (i as f32 / 15.0).powf(0.6);
s += &format!("{} {} {}\n", v, v * 0.95, v.powf(1.2));
}
s + &lattice(7, grade)
}
fn cube_1d() -> String {
let mut s = String::from("LUT_1D_SIZE 32\n");
for i in 0..32 {
let v = i as f32 / 31.0;
s += &format!("{} {} {}\n", v.powf(0.7), v.powf(1.3), 0.05 + v * 0.9);
}
s
}
fn csp() -> String {
let mut s = String::from("CSPLUTV100\n3D\n\n3\n0 0.5 1\n0 0.3 1\n2\n0 1\n0 1\n4\n0 0.2 0.6 1\n0 0.4 0.7 1\n\n5 5 5\n");
for b in 0..5 {
for g in 0..5 {
for r in 0..5 {
let c = grade([r, g, b].map(|i| i as f32 / 4.0));
s += &format!("{} {} {}\n", c[0], c[1], c[2]);
}
}
}
s
}
#[test]
fn apply_color_lut() {
effect_case("ec.utility.applylut", &[("lut", s(&cube_3d()))]);
effect_case("ec.utility.applylut", &[("lut", s(&cube_1d_3d()))]);
effect_case("ec.utility.applylut", &[("lut", s(&cube_1d()))]);
effect_case("ec.utility.applylut", &[("lut", s("/nonexistent/grade.cube"))]);
}
#[test]
fn ocio_file_transform() {
for interp in 1..4 {
effect_case("ec.color.ociofile", &[("file", s(&cube_3d())), ("interpolation", e(interp))]);
}
case_allow("ec.color.ociofile", &[("file", s(&cube_3d())), ("interpolation", e(0))], 0.01);
case_allow("ec.color.ociofile", &[("file", s(&cube_1d_3d())), ("interpolation", e(0))], 0.01);
effect_case("ec.color.ociofile", &[("file", s(&cube_1d())), ("direction", e(1))]);
effect_case("ec.color.ociofile", &[("file", s(&cube_3d())), ("direction", e(1))]);
effect_case("ec.color.ociofile", &[("file", s(&csp())), ("interpolation", e(1))]);
effect_case("ec.color.ociofile", &[("file", s(&csp())), ("direction", e(1))]);
let ccc = r#"1.2 1 0.90.02 0 -0.011.1 0.9 1
0.8 0.8 0.81.4"#;
effect_case("ec.color.ociofile", &[("file", s(ccc))]);
effect_case("ec.color.ociofile", &[("file", s(ccc)), ("cccId", s("b")), ("direction", e(1))]);
}
#[test]
fn ocio_cdl_space_display_look() {
let cdl = [("slopeRed", n(1.3)), ("offsetGreen", n(0.05)), ("powerBlue", n(1.4)), ("saturation", n(1.3))];
effect_case("ec.color.ociocdl", &cdl);
effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("style", e(1)), ("offsetRed", n(-0.1))]].concat());
effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("direction", e(1))]].concat());
effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("direction", e(1)), ("style", e(1))]].concat());
let spaces = effectcraft_effects::find("ec.color.ociocolorspace").unwrap();
let names = match &spaces.params.iter().find(|p| p.id == "source").unwrap().ui {
effectcraft_project::ParamUi::Popup { options } => options.clone(),
_ => vec![],
};
for (a, z) in [("sRGB", "ACEScg"), ("ACEScct", "Display P3"), ("Gamma 2.4 Rec.2020", "ACES2065-1"), ("CIE-XYZ-D65", "sRGB"), ("Raw", "sRGB")] {
let i = |n: &str| e(names.iter().position(|x| x == n).unwrap() as u32);
effect_case("ec.color.ociocolorspace", &[("source", i(a)), ("destination", i(z))]);
effect_case("ec.color.ociocolorspace", &[("source", i(a)), ("destination", i(z)), ("direction", e(1))]);
}
for display in 0..4 {
for view in 0..2 {
effect_case("ec.color.ociodisplay", &[("display", e(display)), ("view", e(view))]);
}
}
// (Inverting PQ and the roll-off together reaches values near 1000 whose matrix mix cancels
// in the small channels: f32 agrees to ~1e-5 of the pixel's largest channel there.)
effect_case("ec.color.ociodisplay", &[("display", e(3)), ("direction", e(1))]);
effect_case("ec.color.ociodisplay", &[("display", e(2)), ("view", e(1)), ("direction", e(1))]);
effect_case("ec.color.ociodisplay", &[("display", e(1)), ("direction", e(1))]);
effect_case("ec.color.ociolook", &[("look", e(3))]);
effect_case("ec.color.ociolook", &[("look", e(1)), ("looks", s("Vivid, -Cool"))]);
effect_case("ec.color.ociolook", &[("looks", s("High Contrast")), ("direction", e(1))]);
}
#[test]
fn color_profile_converter() {
for (inp, out, intent) in [(0, 1, 1), (1, 3, 0), (4, 5, 2), (6, 2, 3), (5, 1, 0), (3, 4, 1)] {
effect_case("ec.utility.colorprofileconverter", &[("inputProfile", e(inp)), ("outputProfile", e(out)), ("intent", e(intent))]);
}
effect_case(
"ec.utility.colorprofileconverter",
&[("inputProfile", e(1)), ("outputProfile", e(3)), ("linearizeInputProfile", Value::Bool(true)), ("linearizeOutputProfile", Value::Bool(true))],
);
}
#[test]
fn lumetri_luts_and_looks() {
for look in 2..8 {
effect_case("ec.color.lumetri", &[("creative/look", e(look))]);
}
effect_case("ec.color.lumetri", &[("creative/look", e(4)), ("creative/lookIntensity", n(60.0)), ("basicCorrection/tone/exposure", n(0.3))]);
effect_case("ec.color.lumetri", &[("creative/look", e(1)), ("creative/lookFile", s(&cube_3d()))]);
effect_case(
"ec.color.lumetri",
&[("basicCorrection/inputLut", e(1)), ("basicCorrection/inputLutFile", s(&cube_1d_3d())), ("basicCorrection/saturation", n(120.0))],
);
effect_case(
"ec.color.lumetri",
&[("basicCorrection/inputLut", e(1)), ("basicCorrection/inputLutFile", s(&cube_1d())), ("creative/look", e(1)), ("creative/lookFile", s(&cube_3d()))],
);
}
/// A custom config: exponent, matrix + offset, group (matrix, log), log-affine (inverse),
/// range, CDL and data spaces, and a display.
const CONFIG: &str = r#"ocio_profile_version: 2
roles:
scene_linear: lin
displays:
Monitor:
- ! {name: Video, colorspace: gamma22}
- ! {name: Log, colorspace: log2}
colorspaces:
- !
name: lin
- !
name: gamma22
to_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]}
- !
name: half
from_reference: ! {matrix: [0.5, 0.1, 0, 0, 0, 0.5, 0, 0, 0.05, 0, 0.5, 0, 0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]}
- !
name: log2
from_reference: !
children:
- ! {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]}
- ! {base: 2}
- !
name: affine
to_reference: ! {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse}
- !
name: range
from_reference: ! {min_in_value: 0.1, max_in_value: 0.9, min_out_value: 0, max_out_value: 1}
- !
name: graded
to_reference: ! {slope: [1.2, 1, 0.9], offset: [0.02, 0, -0.01], power: [1.1, 0.9, 1], sat: 1.2}
- !
name: raw
isdata: true
"#;
#[test]
fn ocio_custom_config() {
let cfg = [("config", e(1)), ("configFile", s(CONFIG))];
for (a, z) in [
("gamma22", "lin"),
("lin", "gamma22"),
("gamma22", "half"),
("half", "lin"),
("lin", "log2"),
("log2", "graded"),
("affine", "lin"),
("lin", "affine"),
("range", "gamma22"),
("lin", "range"),
("graded", "lin"),
("raw", "half"),
("lin", "missing"),
] {
for dir in 0..2 {
let vals = [cfg.as_slice(), &[("sourceName", s(a)), ("destinationName", s(z)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociocolorspace", &vals);
}
}
for (view, dir) in [("Video", 0), ("Log", 0), ("Log", 1)] {
let vals = [cfg.as_slice(), &[("sourceName", s("half")), ("displayName", s("Monitor")), ("viewName", s(view)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociodisplay", &vals);
}
effect_case("ec.color.ociocolorspace", &[("config", e(1)), ("configFile", s("/missing.ocio"))]);
}
/// A config with the OCIO v2 transforms of Blender's (#410): an E-white XYZ reference,
/// ColorSpaceTransform, ExponentWithLinearTransform, display-referred spaces through the
/// default view transform, AllocationTransform, BuiltinTransform (a matrix, the PQ and ACEScc
/// curves), a minimum-only RangeTransform and a look in a view.
const CONFIG_V2: &str = r#"ocio_profile_version: 2
roles:
reference: XYZ E
scene_linear: Linear Rec.709
default_view_transform: Standard
view_transforms:
- !
name: Standard
from_scene_reference: ! {matrix: [0.9531, -0.0266, 0.0239, 0, -0.0382, 1.0288, 0.0094, 0, 0.0026, -0.0030, 1.0893, 0, 0, 0, 0, 1]}
displays:
sRGB:
- ! {name: Standard, view_transform: Standard, display_colorspace: sRGB Display}
- ! {name: Log, colorspace: Log, looks: +Lift}
- ! {name: PQ, view_transform: Standard, display_colorspace: PQ Display}
display_colorspaces:
- !
name: XYZ D65
- !
name: sRGB Display
from_display_reference: !
children:
- ! {src: XYZ D65, dst: Linear Rec.709}
- ! {gamma: 2.4, offset: 0.055, direction: inverse}
- !
name: PQ Display
from_display_reference: ! {style: DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ}
colorspaces:
- !
name: XYZ E
- !
name: Linear Rec.709
from_scene_reference: !
children:
- ! {matrix: [0.9531, -0.0266, 0.0239, 0, -0.0382, 1.0288, 0.0094, 0, 0.0026, -0.0030, 1.0893, 0, 0, 0, 0, 1]}
- ! {matrix: [3.2410, -1.5374, -0.4986, 0, -0.9692, 1.8760, 0.0416, 0, 0.0556, -0.2040, 1.0570, 0, 0, 0, 0, 1]}
- !
name: sRGB
from_scene_reference: !
children:
- ! {src: XYZ E, dst: Linear Rec.709}
- ! {gamma: 2.4, offset: 0.055, direction: inverse}
- !
name: Log
from_scene_reference: !
children:
- ! {src: XYZ E, dst: Linear Rec.709}
- ! {allocation: lg2, vars: [-10, 6.5]}
- !
name: ACEScg
from_scene_reference: !
children:
- ! {src: XYZ E, dst: XYZ D65}
- ! {style: "UTILITY - ACES-AP1_to_CIE-XYZ-D65_BFD", direction: inverse}
- !
name: ACEScc
to_scene_reference: !
children:
- ! {style: ACEScc_to_ACES2065-1}
- ! {matrix: [0.9525, 0, 0.0001, 0, 0.3439, 0.7281, -0.0721, 0, 0, 0, 1.0088, 0, 0, 0, 0, 1]}
- !
name: Floor
from_scene_reference: !
children:
- ! {src: XYZ E, dst: sRGB}
- ! {min_in_value: 0.1, min_out_value: 0.1}
- !
name: Uniform
from_scene_reference: ! {allocation: uniform, vars: [-0.5, 2]}
looks:
- !
name: Lift
process_space: Log
transform: ! {offset: [0.05, 0.02, 0], sat: 0.8}
"#;
#[test]
fn ocio_custom_config_v2() {
let cfg = [("config", e(1)), ("configFile", s(CONFIG_V2))];
for (a, z) in [
("Linear Rec.709", "sRGB"),
("Linear Rec.709", "sRGB Display"),
("sRGB", "Log"),
("ACEScg", "sRGB"),
("ACEScc", "Linear Rec.709"),
("sRGB", "Floor"),
("Linear Rec.709", "Uniform"),
] {
for dir in 0..2 {
let vals = [cfg.as_slice(), &[("sourceName", s(a)), ("destinationName", s(z)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociocolorspace", &vals);
}
}
for (view, dir) in [("Standard", 0), ("Standard", 1), ("Log", 0), ("Log", 1), ("PQ", 0)] {
let vals = [cfg.as_slice(), &[("sourceName", s("Linear Rec.709")), ("displayName", s("sRGB")), ("viewName", s(view)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociodisplay", &vals);
}
}