Skip to main content

av1_grain/
create.rs

1// Copyright (c) 2022-2022, The rav1e contributors. All rights reserved
2//
3// This source code is subject to the terms of the BSD 2 Clause License and
4// the Alliance for Open Media Patent License 1.0. If the BSD 2 Clause License
5// was not distributed with this source code in the LICENSE file, you can
6// obtain it at www.aomedia.org/license/software. If the Alliance for Open
7// Media Patent License 1.0 was not distributed with this source code in the
8// PATENTS file, you can obtain it at www.aomedia.org/license/patent.
9
10// The original work for this formula was implmented in aomenc, and this is
11// an adaptation of that work:
12// https://aomedia.googlesource.com/aom/+/refs/heads/main/examples/photon_noise_table.c
13
14// This implementation creates a film grain table, for use in stills and videos,
15// representing the noise that one would get by shooting with a digital camera
16// at a given light level. Much of the noise in digital images is photon shot
17// noise, which is due to the characteristics of photon arrival and grows in
18// standard deviation as the square root of the expected number of photons
19// captured.
20// https://www.photonstophotos.net/Emil%20Martinec/noise.html#shotnoise
21//
22// The proxy used by this implementation for the amount of light captured
23// is the ISO value such that the focal plane exposure at the time of capture
24// would have been mapped by a 35mm camera to the output lightness observed
25// in the image. That is, if one were to shoot on a 35mm camera (36×24mm sensor)
26// at the nominal exposure for that ISO setting, the resulting image should
27// contain noise of the same order of magnitude as generated by this
28// implementation.
29//
30// The (mostly) square-root relationship between light intensity and noise
31// amplitude holds in linear light, but AV1 streams are most often encoded
32// non-linearly, and the film grain is applied to those non-linear values.
33// Therefore, this implementation must account for the non-linearity, and this
34// is controlled by the transfer function parameter, which specifies the tone
35// response curve that will be used when encoding the actual image. The default
36// for this implementation is BT.1886, which is approximately similar to an
37// encoding gamma of 1/2.8 (i.e. a decoding gamma of 2.8) though not quite
38// identical.
39//
40// As alluded to above, the implementation assumes that the image is taken from
41// the entirety of a 36×24mm (“35mm format”) sensor. If that assumption does not
42// hold, then a “35mm-equivalent ISO value” that can be passed to the
43// implementation can be obtained by multiplying the true ISO value by the ratio
44// of 36×24mm to the area that was actually used. For formats that approximately
45// share the same aspect ratio, this is often expressed as the square of the
46// “equivalence ratio” which is the ratio of their diagonals. For example, APS-C
47// (often ~24×16mm) is said to have an equivalence ratio of 1.5 relative to the
48// 35mm format, and therefore ISO 1000 on APS-C and ISO 1000×1.5² = 2250 on 35mm
49// produce an image of the same lightness from the same amount of light spread
50// onto their respective surface areas (resulting in different focal plane
51// exposures), and those images will thus have similar amounts of noise if the
52// cameras are of similar technology. https://doi.org/10.1117/1.OE.57.11.110801
53//
54// The implementation needs to know the resolution of the images to which its
55// grain tables will be applied so that it can know how the light on the sensor
56// was shared between its pixels. As a general rule, while a higher pixel count
57// will lead to more noise per pixel, when the final image is viewed at the same
58// physical size, that noise will tend to “average out” to the same amount over
59// a given area, since there will be more pixels in it which, in aggregate, will
60// have received essentially as much light. Put differently, the amount of noise
61// depends on the scale at which it is measured, and the decision for this
62// implementation was to make that scale relative to the image instead of its
63// constituent samples. For more on this, see:
64//
65// https://www.photonstophotos.net/Emil%20Martinec/noise-p3.html#pixelsize
66// https://www.dpreview.com/articles/5365920428/the-effect-of-pixel-and-sensor-sizes-on-noise/2
67// https://www.dpreview.com/videos/7940373140/dpreview-tv-why-lower-resolution-sensors-are-not-better-in-low-light
68
69use std::{
70    fs::File,
71    io::{BufWriter, Write},
72    path::Path,
73};
74
75use arrayvec::ArrayVec;
76
77use crate::{DEFAULT_GRAIN_SEED, GrainTableSegment, NUM_Y_POINTS, ScalingPoints};
78
79const PQ_M1: f32 = 2610. / 16384.;
80const PQ_M2: f32 = 128. * 2523. / 4096.;
81const PQ_C1: f32 = 3424. / 4096.;
82const PQ_C2: f32 = 32. * 2413. / 4096.;
83const PQ_C3: f32 = 32. * 2392. / 4096.;
84
85const BT1886_WHITEPOINT: f32 = 203.;
86const BT1886_BLACKPOINT: f32 = 0.1;
87const BT1886_GAMMA: f32 = 2.4;
88
89// BT.1886 formula from https://en.wikipedia.org/wiki/ITU-R_BT.1886.
90//
91// TODO: the inverses, alpha, and beta should all be constants
92// once floats in const fns are stabilized and `powf` is const.
93// Until then, `inline(always)` gets us close enough.
94
95fn bt1886_inv_whitepoint() -> f32 {
96    BT1886_WHITEPOINT.powf(1.0 / BT1886_GAMMA)
97}
98
99fn bt1886_inv_blackpoint() -> f32 {
100    BT1886_BLACKPOINT.powf(1.0 / BT1886_GAMMA)
101}
102
103/// The variable for user gain:
104/// `α = (Lw^(1/λ) - Lb^(1/λ)) ^ λ`
105fn bt1886_alpha() -> f32 {
106    (bt1886_inv_whitepoint() - bt1886_inv_blackpoint()).powf(BT1886_GAMMA)
107}
108
109/// The variable for user black level lift:
110/// `β = Lb^(1/λ) / (Lw^(1/λ) - Lb^(1/λ))`
111fn bt1886_beta() -> f32 {
112    bt1886_inv_blackpoint() / (bt1886_inv_whitepoint() - bt1886_inv_blackpoint())
113}
114
115/// Settings and video data defining how to generate the film grain params.
116#[derive(Debug, Clone, Copy)]
117pub struct NoiseGenArgs {
118    pub iso_setting: u32,
119    pub width: u32,
120    pub height: u32,
121    pub transfer_function: TransferFunction,
122    /// Whether the input is full range or limited range
123    pub full_range: bool,
124    pub chroma_grain: bool,
125    pub random_seed: Option<u16>,
126}
127
128/// Generates a set of photon noise parameters for a segment of video
129/// given a set of `args`.
130#[must_use]
131#[inline]
132pub fn generate_photon_noise_params(
133    start_time: u64,
134    end_time: u64,
135    args: NoiseGenArgs,
136) -> GrainTableSegment {
137    GrainTableSegment {
138        start_time,
139        end_time,
140        scaling_points_y: generate_luma_noise_points(args),
141        scaling_points_cb: ArrayVec::new(),
142        scaling_points_cr: ArrayVec::new(),
143        scaling_shift: 8,
144        ar_coeff_lag: 0,
145        ar_coeffs_y: ArrayVec::new(),
146        ar_coeffs_cb: ArrayVec::try_from([0].as_slice())
147            .expect("Cannot fail creation from const array"),
148        ar_coeffs_cr: ArrayVec::try_from([0].as_slice())
149            .expect("Cannot fail creation from const array"),
150        ar_coeff_shift: 6,
151        cb_mult: 0,
152        cb_luma_mult: 0,
153        cb_offset: 0,
154        cr_mult: 0,
155        cr_luma_mult: 0,
156        cr_offset: 0,
157        overlap_flag: true,
158        chroma_scaling_from_luma: args.chroma_grain,
159        grain_scale_shift: 0,
160        random_seed: args.random_seed.unwrap_or(DEFAULT_GRAIN_SEED),
161    }
162}
163
164/// Generates a set of film grain parameters for a segment of video
165/// given a set of `args`.
166///
167/// # Panics
168/// - This is not yet implemented, so it will always panic
169#[must_use]
170#[inline]
171#[cfg(feature = "unstable")]
172pub fn generate_film_grain_params(
173    start_time: u64,
174    end_time: u64,
175    args: NoiseGenArgs,
176) -> GrainTableSegment {
177    todo!("SCIENCE");
178    // GrainTableSegment {
179    //     start_time,
180    //     end_time,
181    //     scaling_points_y: generate_luma_noise_points(args),
182    //     scaling_points_cb: ArrayVec::new(),
183    //     scaling_points_cr: ArrayVec::new(),
184    //     scaling_shift: 8,
185    //     ar_coeff_lag: 0,
186    //     ar_coeffs_y: ArrayVec::new(),
187    //     ar_coeffs_cb: ArrayVec::try_from([0].as_slice())
188    //         .expect("Cannot fail creation from const array"),
189    //     ar_coeffs_cr: ArrayVec::try_from([0].as_slice())
190    //         .expect("Cannot fail creation from const array"),
191    //     ar_coeff_shift: 6,
192    //     cb_mult: 0,
193    //     cb_luma_mult: 0,
194    //     cb_offset: 0,
195    //     cr_mult: 0,
196    //     cr_luma_mult: 0,
197    //     cr_offset: 0,
198    //     overlap_flag: true,
199    //     chroma_scaling_from_luma: args.chroma_grain,
200    //     grain_scale_shift: 0,
201    //     random_seed: args.random_seed.unwrap_or(DEFAULT_GRAIN_SEED),
202    // }
203}
204
205/// Write a set of generated film grain params to a table file,
206/// using the standard film grain table format supported by
207/// aomenc, rav1e, and svt-av1.
208///
209/// # Errors
210///
211/// - If the output file cannot be written to
212#[inline]
213pub fn write_grain_table<P: AsRef<Path>>(
214    filename: P,
215    params: &[GrainTableSegment],
216) -> anyhow::Result<()> {
217    let mut file = BufWriter::new(File::create(filename)?);
218    writeln!(&mut file, "filmgrn1")?;
219    for segment in params {
220        write_film_grain_segment(segment, &mut file)?;
221    }
222    file.flush()?;
223
224    Ok(())
225}
226
227fn write_film_grain_segment(
228    params: &GrainTableSegment,
229    output: &mut BufWriter<File>,
230) -> anyhow::Result<()> {
231    writeln!(
232        output,
233        "E {} {} 1 {} 1",
234        params.start_time, params.end_time, params.random_seed,
235    )?;
236    writeln!(
237        output,
238        "\tp {} {} {} {} {} {} {} {} {} {} {} {}",
239        params.ar_coeff_lag,
240        params.ar_coeff_shift,
241        params.grain_scale_shift,
242        params.scaling_shift,
243        u8::from(params.chroma_scaling_from_luma),
244        u8::from(params.overlap_flag),
245        params.cb_mult,
246        params.cb_luma_mult,
247        params.cb_offset,
248        params.cr_mult,
249        params.cr_luma_mult,
250        params.cr_offset
251    )?;
252
253    write!(output, "\tsY {} ", params.scaling_points_y.len())?;
254    for point in &params.scaling_points_y {
255        write!(output, " {} {}", point[0], point[1])?;
256    }
257    writeln!(output)?;
258
259    write!(output, "\tsCb {}", params.scaling_points_cb.len())?;
260    for point in &params.scaling_points_cb {
261        write!(output, " {} {}", point[0], point[1])?;
262    }
263    writeln!(output)?;
264
265    write!(output, "\tsCr {}", params.scaling_points_cr.len())?;
266    for point in &params.scaling_points_cr {
267        write!(output, " {} {}", point[0], point[1])?;
268    }
269    writeln!(output)?;
270
271    write!(output, "\tcY")?;
272    for coeff in &params.ar_coeffs_y {
273        write!(output, " {}", *coeff)?;
274    }
275    writeln!(output)?;
276
277    write!(output, "\tcCb")?;
278    for coeff in &params.ar_coeffs_cb {
279        write!(output, " {}", *coeff)?;
280    }
281    writeln!(output)?;
282
283    write!(output, "\tcCr")?;
284    for coeff in &params.ar_coeffs_cr {
285        write!(output, " {}", *coeff)?;
286    }
287    writeln!(output)?;
288
289    Ok(())
290}
291
292#[allow(clippy::upper_case_acronyms)]
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum TransferFunction {
295    /// For SDR content
296    BT1886,
297    /// For HDR content
298    SMPTE2084,
299}
300
301impl TransferFunction {
302    #[must_use]
303    #[inline]
304    pub fn to_linear(self, x: f32) -> f32 {
305        match self {
306            TransferFunction::BT1886 => {
307                // The screen luminance in cd/m^2:
308                // L = α * (x + β)^λ
309                let luma = bt1886_alpha() * (x + bt1886_beta()).powf(BT1886_GAMMA);
310
311                // Normalize to between 0.0 and 1.0
312                luma / BT1886_WHITEPOINT
313            }
314            TransferFunction::SMPTE2084 => {
315                let pq_pow_inv_m2 = x.powf(1. / PQ_M2);
316                (0_f32.max(pq_pow_inv_m2 - PQ_C1) / PQ_C3.mul_add(-pq_pow_inv_m2, PQ_C2))
317                    .powf(1. / PQ_M1)
318            }
319        }
320    }
321
322    #[allow(clippy::wrong_self_convention)]
323    #[must_use]
324    #[inline]
325    pub fn from_linear(self, x: f32) -> f32 {
326        match self {
327            TransferFunction::BT1886 => {
328                // Scale to a raw cd/m^2 value
329                let luma = x * BT1886_WHITEPOINT;
330
331                // The inverse of the `to_linear` formula:
332                // `(L / α)^(1 / λ) - β = x`
333                (luma / bt1886_alpha()).powf(1.0 / BT1886_GAMMA) - bt1886_beta()
334            }
335            TransferFunction::SMPTE2084 => {
336                if x < f32::EPSILON {
337                    return 0.0;
338                }
339                let linear_pow_m1 = x.powf(PQ_M1);
340                (PQ_C2.mul_add(linear_pow_m1, PQ_C1) / PQ_C3.mul_add(linear_pow_m1, 1.)).powf(PQ_M2)
341            }
342        }
343    }
344
345    #[inline]
346    #[must_use]
347    pub fn mid_tone(self) -> f32 {
348        self.to_linear(0.5)
349    }
350}
351
352fn generate_luma_noise_points(args: NoiseGenArgs) -> ScalingPoints {
353    // Assumes a daylight-like spectrum.
354    // https://www.strollswithmydog.com/effective-quantum-efficiency-of-sensor/#:~:text=11%2C260%20photons/um%5E2/lx-s
355    const PHOTONS_PER_SQ_MICRON_PER_LUX_SECOND: f32 = 11260.;
356
357    // Order of magnitude for cameras in the 2010-2020 decade, taking the CFA into
358    // account.
359    const EFFECTIVE_QUANTUM_EFFICIENCY: f32 = 0.2;
360
361    // Also reasonable values for current cameras. The read noise is typically
362    // higher than this at low ISO settings but it matters less there.
363    const PHOTO_RESPONSE_NON_UNIFORMITY: f32 = 0.005;
364    const INPUT_REFERRED_READ_NOISE: f32 = 1.5;
365
366    // Assumes a 35mm sensor (36mm × 24mm).
367    const SENSOR_AREA: f32 = 36_000. * 24_000.;
368
369    // Focal plane exposure for a mid-tone (typically a 18% reflectance card), in
370    // lx·s.
371    let mid_tone_exposure = 10. / args.iso_setting as f32;
372
373    let pixel_area_microns = SENSOR_AREA / (args.width * args.height) as f32;
374
375    let mid_tone_electrons_per_pixel = EFFECTIVE_QUANTUM_EFFICIENCY
376        * PHOTONS_PER_SQ_MICRON_PER_LUX_SECOND
377        * mid_tone_exposure
378        * pixel_area_microns;
379    let max_electrons_per_pixel = mid_tone_electrons_per_pixel / args.transfer_function.mid_tone();
380
381    let max_value = if args.full_range { 255 } else { 235 };
382    let min_value = if args.full_range { 0 } else { 16 };
383    let range = max_value - min_value;
384    const RAMP_OFFSET: usize = 3;
385    const MIN_EDGE: usize = 0;
386    const MAX_EDGE: usize = NUM_Y_POINTS - 1;
387
388    let mut scaling_points = ScalingPoints::default();
389    for i in 0..NUM_Y_POINTS {
390        // Applying photon noise "as is" results in unwanted brightening of darkest and darkening of brightest luma values;
391        // clamping scaling points to a maximum of 1 at those min and max values prevents that.
392        let x = if i == MIN_EDGE {
393            0.0
394        } else if i == MAX_EDGE {
395            1.0
396        } else {
397            // (ramp_offset + (range - 2 * ramp_offset) * ((i - 1) / (film_grain->num_y_points - 3.0))) / range
398            ((range - 2 * RAMP_OFFSET) as f32).mul_add(
399                (i - 1) as f32 / (NUM_Y_POINTS - 3) as f32,
400                RAMP_OFFSET as f32,
401            ) / range as f32
402        };
403
404        let linear = args.transfer_function.to_linear(x);
405        let electrons_per_pixel = max_electrons_per_pixel * linear;
406
407        // Quadrature sum of the relevant sources of noise, in electrons rms. Photon
408        // shot noise is sqrt(electrons) so we can skip the square root and the
409        // squaring.
410        // https://en.wikipedia.org/wiki/Addition_in_quadrature
411        // https://doi.org/10.1117/3.725073
412        let noise_in_electrons = (PHOTO_RESPONSE_NON_UNIFORMITY
413            * PHOTO_RESPONSE_NON_UNIFORMITY
414            * electrons_per_pixel)
415            .mul_add(
416                electrons_per_pixel,
417                INPUT_REFERRED_READ_NOISE.mul_add(INPUT_REFERRED_READ_NOISE, electrons_per_pixel),
418            )
419            .sqrt();
420        let linear_noise = noise_in_electrons / max_electrons_per_pixel;
421        let linear_range_start = 0_f32.max(2.0f32.mul_add(-linear_noise, linear));
422        let linear_range_end = 1_f32.min(2_f32.mul_add(linear_noise, linear));
423        let tf_slope = (args.transfer_function.from_linear(linear_range_end)
424            - args.transfer_function.from_linear(linear_range_start))
425            / (linear_range_end - linear_range_start);
426        let encoded_noise = linear_noise * tf_slope;
427
428        // min_value as f32 + range as f32 * x
429        let x = (range as f32).mul_add(x, min_value as f32).round() as u8;
430        let mut encoded_noise =
431            (range as f32).min((range as f32 * 7.88 * encoded_noise).round()) as u8;
432        if i == MIN_EDGE || i == MAX_EDGE {
433            encoded_noise = if encoded_noise >= 1 { 1 } else { 0 };
434        }
435
436        scaling_points.push([x, encoded_noise]);
437    }
438
439    scaling_points
440}
441
442#[cfg(test)]
443mod tests {
444    use quickcheck::TestResult;
445    use quickcheck_macros::quickcheck;
446
447    use super::*;
448
449    #[quickcheck]
450    fn bt1886_to_linear_within_range(x: f32) -> TestResult {
451        if !(0.0..=1.0).contains(&x) || x.is_nan() {
452            return TestResult::discard();
453        }
454
455        let tx = TransferFunction::BT1886;
456        let res = tx.to_linear(x);
457        TestResult::from_bool((0.0..=1.0).contains(&res))
458    }
459
460    #[quickcheck]
461    fn bt1886_to_linear_reverts_correctly(x: f32) -> TestResult {
462        if !(0.0..=1.0).contains(&x) || x.is_nan() {
463            return TestResult::discard();
464        }
465
466        let tx = TransferFunction::BT1886;
467        let res = tx.to_linear(x);
468        let res = tx.from_linear(res);
469        TestResult::from_bool((x - res).abs() < f32::EPSILON)
470    }
471
472    #[quickcheck]
473    fn smpte2084_to_linear_within_range(x: f32) -> TestResult {
474        if !(0.0..=1.0).contains(&x) || x.is_nan() {
475            return TestResult::discard();
476        }
477
478        let tx = TransferFunction::SMPTE2084;
479        let res = tx.to_linear(x);
480        TestResult::from_bool((0.0..=1.0).contains(&res))
481    }
482
483    #[quickcheck]
484    fn smpte2084_to_linear_reverts_correctly(x: f32) -> TestResult {
485        if !(0.0..=1.0).contains(&x) || x.is_nan() {
486            return TestResult::discard();
487        }
488
489        let tx = TransferFunction::SMPTE2084;
490        let res = tx.to_linear(x);
491        let res = tx.from_linear(res);
492        TestResult::from_bool((x - res).abs() < f32::EPSILON)
493    }
494}