Coverage Report

Created: 2025-10-10 07:21

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/rust/registry/src/index.crates.io-1949cf8c6b5b557f/av1-grain-0.2.4/src/create.rs
Line
Count
Source
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
69
use std::{
70
    fs::File,
71
    io::{BufWriter, Write},
72
    path::Path,
73
};
74
75
use arrayvec::ArrayVec;
76
77
use crate::{GrainTableSegment, ScalingPoints, DEFAULT_GRAIN_SEED, NUM_Y_POINTS};
78
79
const PQ_M1: f32 = 2610. / 16384.;
80
const PQ_M2: f32 = 128. * 2523. / 4096.;
81
const PQ_C1: f32 = 3424. / 4096.;
82
const PQ_C2: f32 = 32. * 2413. / 4096.;
83
const PQ_C3: f32 = 32. * 2392. / 4096.;
84
85
const BT1886_WHITEPOINT: f32 = 203.;
86
const BT1886_BLACKPOINT: f32 = 0.1;
87
const 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
95
#[inline(always)]
96
0
fn bt1886_inv_whitepoint() -> f32 {
97
0
    BT1886_WHITEPOINT.powf(1.0 / BT1886_GAMMA)
98
0
}
99
100
#[inline(always)]
101
0
fn bt1886_inv_blackpoint() -> f32 {
102
0
    BT1886_BLACKPOINT.powf(1.0 / BT1886_GAMMA)
103
0
}
104
105
/// The variable for user gain:
106
/// `α = (Lw^(1/λ) - Lb^(1/λ)) ^ λ`
107
#[inline(always)]
108
0
fn bt1886_alpha() -> f32 {
109
0
    (bt1886_inv_whitepoint() - bt1886_inv_blackpoint()).powf(BT1886_GAMMA)
110
0
}
111
112
/// The variable for user black level lift:
113
/// `β = Lb^(1/λ) / (Lw^(1/λ) - Lb^(1/λ))`
114
#[inline(always)]
115
0
fn bt1886_beta() -> f32 {
116
0
    bt1886_inv_blackpoint() / (bt1886_inv_whitepoint() - bt1886_inv_blackpoint())
117
0
}
118
119
/// Settings and video data defining how to generate the film grain params.
120
#[derive(Debug, Clone, Copy)]
121
pub struct NoiseGenArgs {
122
    pub iso_setting: u32,
123
    pub width: u32,
124
    pub height: u32,
125
    pub transfer_function: TransferFunction,
126
    pub chroma_grain: bool,
127
    pub random_seed: Option<u16>,
128
}
129
130
/// Generates a set of photon noise parameters for a segment of video
131
/// given a set of `args`.
132
#[must_use]
133
0
pub fn generate_photon_noise_params(
134
0
    start_time: u64,
135
0
    end_time: u64,
136
0
    args: NoiseGenArgs,
137
0
) -> GrainTableSegment {
138
0
    GrainTableSegment {
139
0
        start_time,
140
0
        end_time,
141
0
        scaling_points_y: generate_luma_noise_points(args),
142
0
        scaling_points_cb: ArrayVec::new(),
143
0
        scaling_points_cr: ArrayVec::new(),
144
0
        scaling_shift: 8,
145
0
        ar_coeff_lag: 0,
146
0
        ar_coeffs_y: ArrayVec::new(),
147
0
        ar_coeffs_cb: ArrayVec::try_from([0].as_slice())
148
0
            .expect("Cannot fail creation from const array"),
149
0
        ar_coeffs_cr: ArrayVec::try_from([0].as_slice())
150
0
            .expect("Cannot fail creation from const array"),
151
0
        ar_coeff_shift: 6,
152
0
        cb_mult: 0,
153
0
        cb_luma_mult: 0,
154
0
        cb_offset: 0,
155
0
        cr_mult: 0,
156
0
        cr_luma_mult: 0,
157
0
        cr_offset: 0,
158
0
        overlap_flag: true,
159
0
        chroma_scaling_from_luma: args.chroma_grain,
160
0
        grain_scale_shift: 0,
161
0
        random_seed: args.random_seed.unwrap_or(DEFAULT_GRAIN_SEED),
162
0
    }
163
0
}
164
165
/// Generates a set of film grain parameters for a segment of video
166
/// given a set of `args`.
167
///
168
/// # Panics
169
/// - This is not yet implemented, so it will always panic
170
#[must_use]
171
#[cfg(feature = "unstable")]
172
pub 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
0
pub fn write_grain_table<P: AsRef<Path>>(
213
0
    filename: P,
214
0
    params: &[GrainTableSegment],
215
0
) -> anyhow::Result<()> {
216
0
    let mut file = BufWriter::new(File::create(filename)?);
217
0
    writeln!(&mut file, "filmgrn1")?;
218
0
    for segment in params {
219
0
        write_film_grain_segment(segment, &mut file)?;
220
    }
221
0
    file.flush()?;
222
223
0
    Ok(())
224
0
}
225
226
0
fn write_film_grain_segment(
227
0
    params: &GrainTableSegment,
228
0
    output: &mut BufWriter<File>,
229
0
) -> anyhow::Result<()> {
230
0
    writeln!(
231
0
        output,
232
0
        "E {} {} 1 {} 1",
233
        params.start_time, params.end_time, params.random_seed,
234
0
    )?;
235
0
    writeln!(
236
0
        output,
237
0
        "\tp {} {} {} {} {} {} {} {} {} {} {} {}",
238
        params.ar_coeff_lag,
239
        params.ar_coeff_shift,
240
        params.grain_scale_shift,
241
        params.scaling_shift,
242
0
        u8::from(params.chroma_scaling_from_luma),
243
0
        u8::from(params.overlap_flag),
244
        params.cb_mult,
245
        params.cb_luma_mult,
246
        params.cb_offset,
247
        params.cr_mult,
248
        params.cr_luma_mult,
249
        params.cr_offset
250
0
    )?;
251
252
0
    write!(output, "\tsY {} ", params.scaling_points_y.len())?;
253
0
    for point in &params.scaling_points_y {
254
0
        write!(output, " {} {}", point[0], point[1])?;
255
    }
256
0
    writeln!(output)?;
257
258
0
    write!(output, "\tsCb {}", params.scaling_points_cb.len())?;
259
0
    for point in &params.scaling_points_cb {
260
0
        write!(output, " {} {}", point[0], point[1])?;
261
    }
262
0
    writeln!(output)?;
263
264
0
    write!(output, "\tsCr {}", params.scaling_points_cr.len())?;
265
0
    for point in &params.scaling_points_cr {
266
0
        write!(output, " {} {}", point[0], point[1])?;
267
    }
268
0
    writeln!(output)?;
269
270
0
    write!(output, "\tcY")?;
271
0
    for coeff in &params.ar_coeffs_y {
272
0
        write!(output, " {}", *coeff)?;
273
    }
274
0
    writeln!(output)?;
275
276
0
    write!(output, "\tcCb")?;
277
0
    for coeff in &params.ar_coeffs_cb {
278
0
        write!(output, " {}", *coeff)?;
279
    }
280
0
    writeln!(output)?;
281
282
0
    write!(output, "\tcCr")?;
283
0
    for coeff in &params.ar_coeffs_cr {
284
0
        write!(output, " {}", *coeff)?;
285
    }
286
0
    writeln!(output)?;
287
288
0
    Ok(())
289
0
}
290
291
#[allow(clippy::upper_case_acronyms)]
292
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293
pub enum TransferFunction {
294
    /// For SDR content
295
    BT1886,
296
    /// For HDR content
297
    SMPTE2084,
298
}
299
300
impl TransferFunction {
301
    #[must_use]
302
0
    pub fn to_linear(self, x: f32) -> f32 {
303
0
        match self {
304
            TransferFunction::BT1886 => {
305
                // The screen luminance in cd/m^2:
306
                // L = α * (x + β)^λ
307
0
                let luma = bt1886_alpha() * (x + bt1886_beta()).powf(BT1886_GAMMA);
308
309
                // Normalize to between 0.0 and 1.0
310
0
                luma / BT1886_WHITEPOINT
311
            }
312
            TransferFunction::SMPTE2084 => {
313
0
                let pq_pow_inv_m2 = x.powf(1. / PQ_M2);
314
0
                (0_f32.max(pq_pow_inv_m2 - PQ_C1) / PQ_C3.mul_add(-pq_pow_inv_m2, PQ_C2))
315
0
                    .powf(1. / PQ_M1)
316
            }
317
        }
318
0
    }
319
320
    #[allow(clippy::wrong_self_convention)]
321
    #[must_use]
322
0
    pub fn from_linear(self, x: f32) -> f32 {
323
0
        match self {
324
            TransferFunction::BT1886 => {
325
                // Scale to a raw cd/m^2 value
326
0
                let luma = x * BT1886_WHITEPOINT;
327
328
                // The inverse of the `to_linear` formula:
329
                // `(L / α)^(1 / λ) - β = x`
330
0
                (luma / bt1886_alpha()).powf(1.0 / BT1886_GAMMA) - bt1886_beta()
331
            }
332
            TransferFunction::SMPTE2084 => {
333
0
                if x < f32::EPSILON {
334
0
                    return 0.0;
335
0
                }
336
0
                let linear_pow_m1 = x.powf(PQ_M1);
337
0
                (PQ_C2.mul_add(linear_pow_m1, PQ_C1) / PQ_C3.mul_add(linear_pow_m1, 1.)).powf(PQ_M2)
338
            }
339
        }
340
0
    }
341
342
    #[inline(always)]
343
    #[must_use]
344
0
    pub fn mid_tone(self) -> f32 {
345
0
        self.to_linear(0.5)
346
0
    }
347
}
348
349
0
fn generate_luma_noise_points(args: NoiseGenArgs) -> ScalingPoints {
350
    // Assumes a daylight-like spectrum.
351
    // https://www.strollswithmydog.com/effective-quantum-efficiency-of-sensor/#:~:text=11%2C260%20photons/um%5E2/lx-s
352
    const PHOTONS_PER_SQ_MICRON_PER_LUX_SECOND: f32 = 11260.;
353
354
    // Order of magnitude for cameras in the 2010-2020 decade, taking the CFA into
355
    // account.
356
    const EFFECTIVE_QUANTUM_EFFICIENCY: f32 = 0.2;
357
358
    // Also reasonable values for current cameras. The read noise is typically
359
    // higher than this at low ISO settings but it matters less there.
360
    const PHOTO_RESPONSE_NON_UNIFORMITY: f32 = 0.005;
361
    const INPUT_REFERRED_READ_NOISE: f32 = 1.5;
362
363
    // Assumes a 35mm sensor (36mm × 24mm).
364
    const SENSOR_AREA: f32 = 36_000. * 24_000.;
365
366
    // Focal plane exposure for a mid-tone (typically a 18% reflectance card), in
367
    // lx·s.
368
0
    let mid_tone_exposure = 10. / args.iso_setting as f32;
369
370
0
    let pixel_area_microns = SENSOR_AREA / (args.width * args.height) as f32;
371
372
0
    let mid_tone_electrons_per_pixel = EFFECTIVE_QUANTUM_EFFICIENCY
373
0
        * PHOTONS_PER_SQ_MICRON_PER_LUX_SECOND
374
0
        * mid_tone_exposure
375
0
        * pixel_area_microns;
376
0
    let max_electrons_per_pixel = mid_tone_electrons_per_pixel / args.transfer_function.mid_tone();
377
378
0
    let mut scaling_points = ScalingPoints::default();
379
0
    for i in 0..NUM_Y_POINTS {
380
0
        let x = i as f32 / (NUM_Y_POINTS as f32 - 1.);
381
0
        let linear = args.transfer_function.to_linear(x);
382
0
        let electrons_per_pixel = max_electrons_per_pixel * linear;
383
0
384
0
        // Quadrature sum of the relevant sources of noise, in electrons rms. Photon
385
0
        // shot noise is sqrt(electrons) so we can skip the square root and the
386
0
        // squaring.
387
0
        // https://en.wikipedia.org/wiki/Addition_in_quadrature
388
0
        // https://doi.org/10.1117/3.725073
389
0
        let noise_in_electrons = (PHOTO_RESPONSE_NON_UNIFORMITY
390
0
            * PHOTO_RESPONSE_NON_UNIFORMITY
391
0
            * electrons_per_pixel)
392
0
            .mul_add(
393
0
                electrons_per_pixel,
394
0
                INPUT_REFERRED_READ_NOISE.mul_add(INPUT_REFERRED_READ_NOISE, electrons_per_pixel),
395
0
            )
396
0
            .sqrt();
397
0
        let linear_noise = noise_in_electrons / max_electrons_per_pixel;
398
0
        let linear_range_start = 0_f32.max(2.0f32.mul_add(-linear_noise, linear));
399
0
        let linear_range_end = 1_f32.min(2_f32.mul_add(linear_noise, linear));
400
0
        let tf_slope = (args.transfer_function.from_linear(linear_range_end)
401
0
            - args.transfer_function.from_linear(linear_range_start))
402
0
            / (linear_range_end - linear_range_start);
403
0
        let encoded_noise = linear_noise * tf_slope;
404
0
405
0
        let x = (255. * x).round() as u8;
406
0
        let encoded_noise = 255_f32.min((255. * 7.88 * encoded_noise).round()) as u8;
407
0
408
0
        scaling_points.push([x, encoded_noise]);
409
0
    }
410
411
0
    scaling_points
412
0
}
413
414
#[cfg(test)]
415
mod tests {
416
    use quickcheck::TestResult;
417
    use quickcheck_macros::quickcheck;
418
419
    use super::*;
420
421
    #[quickcheck]
422
    fn bt1886_to_linear_within_range(x: f32) -> TestResult {
423
        if !(0.0..=1.0).contains(&x) || x.is_nan() {
424
            return TestResult::discard();
425
        }
426
427
        let tx = TransferFunction::BT1886;
428
        let res = tx.to_linear(x);
429
        TestResult::from_bool((0.0..=1.0).contains(&res))
430
    }
431
432
    #[quickcheck]
433
    fn bt1886_to_linear_reverts_correctly(x: f32) -> TestResult {
434
        if !(0.0..=1.0).contains(&x) || x.is_nan() {
435
            return TestResult::discard();
436
        }
437
438
        let tx = TransferFunction::BT1886;
439
        let res = tx.to_linear(x);
440
        let res = tx.from_linear(res);
441
        TestResult::from_bool((x - res).abs() < f32::EPSILON)
442
    }
443
444
    #[quickcheck]
445
    fn smpte2084_to_linear_within_range(x: f32) -> TestResult {
446
        if !(0.0..=1.0).contains(&x) || x.is_nan() {
447
            return TestResult::discard();
448
        }
449
450
        let tx = TransferFunction::SMPTE2084;
451
        let res = tx.to_linear(x);
452
        TestResult::from_bool((0.0..=1.0).contains(&res))
453
    }
454
455
    #[quickcheck]
456
    fn smpte2084_to_linear_reverts_correctly(x: f32) -> TestResult {
457
        if !(0.0..=1.0).contains(&x) || x.is_nan() {
458
            return TestResult::discard();
459
        }
460
461
        let tx = TransferFunction::SMPTE2084;
462
        let res = tx.to_linear(x);
463
        let res = tx.from_linear(res);
464
        TestResult::from_bool((x - res).abs() < f32::EPSILON)
465
    }
466
}