Skip to content

Commit ec93312

Browse files
authored
Improvements to grain synth gamma handling (#622)
- Fixes BT.1886 to use the more correct formula and gamma - Simplify the `mid_tone` function - Add quickcheck tests around `to_linear`/`from_linear`
1 parent 270b3ba commit ec93312

File tree

3 files changed

+136
-6
lines changed

3 files changed

+136
-6
lines changed

Cargo.lock

+22
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

av1an-core/Cargo.toml

+4
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ features = ["rt", "process", "io-util"]
8080
version = "5.0.0"
8181
features = ["serde"]
8282

83+
[dev-dependencies]
84+
quickcheck = { version = "1.0.3", default-features = false }
85+
quickcheck_macros = "1"
86+
8387
[features]
8488
ffmpeg_static = ["ffmpeg/static", "ffmpeg/build"]
8589
vapoursynth_new_api = [

av1an-core/src/grain.rs

+110-6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#![allow(clippy::inline_always)]
2+
13
use std::fs::File;
24
use std::io::{BufWriter, Write};
35
use std::path::Path;
@@ -28,10 +30,51 @@ const PQ_C1: f32 = 3424. / 4096.;
2830
const PQ_C2: f32 = 32. * 2413. / 4096.;
2931
const PQ_C3: f32 = 32. * 2392. / 4096.;
3032

33+
const BT1886_WHITEPOINT: f32 = 203.;
34+
const BT1886_BLACKPOINT: f32 = 0.1;
35+
const BT1886_GAMMA: f32 = 2.4;
36+
37+
// BT.1886 formula from https://en.wikipedia.org/wiki/ITU-R_BT.1886.
38+
//
39+
// TODO: the inverses, alpha, and beta should all be constants
40+
// once floats in const fns are stabilized and `powf` is const.
41+
// Until then, `inline(always)` gets us close enough.
42+
43+
#[inline(always)]
44+
fn bt1886_inv_whitepoint() -> f32 {
45+
BT1886_WHITEPOINT.powf(1.0 / BT1886_GAMMA)
46+
}
47+
48+
#[inline(always)]
49+
fn bt1886_inv_blackpoint() -> f32 {
50+
BT1886_BLACKPOINT.powf(1.0 / BT1886_GAMMA)
51+
}
52+
53+
/// The variable for user gain:
54+
/// `α = (Lw^(1/λ) - Lb^(1/λ)) ^ λ`
55+
#[inline(always)]
56+
fn bt1886_alpha() -> f32 {
57+
(bt1886_inv_whitepoint() - bt1886_inv_blackpoint()).powf(BT1886_GAMMA)
58+
}
59+
60+
/// The variable for user black level lift:
61+
/// `β = Lb^(1/λ) / (Lw^(1/λ) - Lb^(1/λ))`
62+
#[inline(always)]
63+
fn bt1886_beta() -> f32 {
64+
bt1886_inv_blackpoint() / (bt1886_inv_whitepoint() - bt1886_inv_blackpoint())
65+
}
66+
3167
impl TransferFunction {
3268
pub fn to_linear(self, x: f32) -> f32 {
3369
match self {
34-
TransferFunction::BT1886 => x.powf(2.8),
70+
TransferFunction::BT1886 => {
71+
// The screen luminance in cd/m^2:
72+
// L = α * max((x + β, 0))^λ
73+
let luma = bt1886_alpha() * 0f32.max(x + bt1886_beta()).powf(BT1886_GAMMA);
74+
75+
// Normalize to between 0.0 and 1.0
76+
luma / BT1886_WHITEPOINT
77+
}
3578
TransferFunction::SMPTE2084 => {
3679
let pq_pow_inv_m2 = x.powf(1. / PQ_M2);
3780
(0_f32.max(pq_pow_inv_m2 - PQ_C1) / (PQ_C2 - PQ_C3 * pq_pow_inv_m2)).powf(1. / PQ_M1)
@@ -42,19 +85,27 @@ impl TransferFunction {
4285
#[allow(clippy::wrong_self_convention)]
4386
pub fn from_linear(self, x: f32) -> f32 {
4487
match self {
45-
TransferFunction::BT1886 => x.powf(1. / 2.8),
88+
TransferFunction::BT1886 => {
89+
// Scale to a raw cd/m^2 value
90+
let luma = x * BT1886_WHITEPOINT;
91+
92+
// The inverse of the `to_linear` formula:
93+
// `(L / α)^(1 / λ) - β = x`
94+
(luma / bt1886_alpha()).powf(1.0 / BT1886_GAMMA) - bt1886_beta()
95+
}
4696
TransferFunction::SMPTE2084 => {
97+
if x < f32::EPSILON {
98+
return 0.0;
99+
}
47100
let linear_pow_m1 = x.powf(PQ_M1);
48101
(PQ_C2.mul_add(linear_pow_m1, PQ_C1) / PQ_C3.mul_add(linear_pow_m1, 1.)).powf(PQ_M2)
49102
}
50103
}
51104
}
52105

106+
#[inline(always)]
53107
pub fn mid_tone(self) -> f32 {
54-
match self {
55-
TransferFunction::BT1886 => 0.18,
56-
TransferFunction::SMPTE2084 => 26. / 10000.,
57-
}
108+
self.to_linear(0.5)
58109
}
59110
}
60111

@@ -157,3 +208,56 @@ fn write_film_grain_table(
157208
file.flush()?;
158209
Ok(())
159210
}
211+
212+
#[cfg(test)]
213+
mod tests {
214+
use super::*;
215+
use quickcheck::TestResult;
216+
use quickcheck_macros::quickcheck;
217+
218+
#[quickcheck]
219+
fn bt1886_to_linear_within_range(x: f32) -> TestResult {
220+
if x < 0.0 || x > 1.0 || x.is_nan() {
221+
return TestResult::discard();
222+
}
223+
224+
let tx = TransferFunction::BT1886;
225+
let res = tx.to_linear(x);
226+
TestResult::from_bool(res >= 0.0 && res <= 1.0)
227+
}
228+
229+
#[quickcheck]
230+
fn bt1886_to_linear_reverts_correctly(x: f32) -> TestResult {
231+
if x < 0.0 || x > 1.0 || x.is_nan() {
232+
return TestResult::discard();
233+
}
234+
235+
let tx = TransferFunction::BT1886;
236+
let res = tx.to_linear(x);
237+
let res = tx.from_linear(res);
238+
TestResult::from_bool((x - res).abs() < f32::EPSILON)
239+
}
240+
241+
#[quickcheck]
242+
fn smpte2084_to_linear_within_range(x: f32) -> TestResult {
243+
if x < 0.0 || x > 1.0 || x.is_nan() {
244+
return TestResult::discard();
245+
}
246+
247+
let tx = TransferFunction::SMPTE2084;
248+
let res = tx.to_linear(x);
249+
TestResult::from_bool(res >= 0.0 && res <= 1.0)
250+
}
251+
252+
#[quickcheck]
253+
fn smpte2084_to_linear_reverts_correctly(x: f32) -> TestResult {
254+
if x < 0.0 || x > 1.0 || x.is_nan() {
255+
return TestResult::discard();
256+
}
257+
258+
let tx = TransferFunction::SMPTE2084;
259+
let res = tx.to_linear(x);
260+
let res = tx.from_linear(res);
261+
TestResult::from_bool((x - res).abs() < f32::EPSILON)
262+
}
263+
}

0 commit comments

Comments
 (0)