Skip to main content

drmkit_display/
curves.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Transfer functions.
5//!
6//! An *EOTF* turns an encoded signal into linear light; an *OETF* goes the
7//! other way. Every one of these is defined by a standard, so the constants are
8//! written as the fractions the specification gives rather than as decimals —
9//! `2610 / 16384`, not `0.15930175`. A transposed digit in a hand-rounded
10//! constant is invisible in review and shows up as a colour cast.
11//!
12//! All of it is `f64`. These run once per LUT entry at construction, not per
13//! pixel, so the precision is free.
14
15/// SMPTE ST 2084 (PQ) constants.
16const PQ_M1: f64 = 2610.0 / 16384.0;
17const PQ_M2: f64 = 2523.0 / 4096.0 * 128.0;
18const PQ_C1: f64 = 3424.0 / 4096.0;
19const PQ_C2: f64 = 2413.0 / 4096.0 * 32.0;
20const PQ_C3: f64 = 2392.0 / 4096.0 * 32.0;
21
22/// ARIB STD-B67 (HLG) constants.
23const HLG_A: f64 = 0.178_832_77;
24const HLG_B: f64 = 0.284_668_92;
25const HLG_C: f64 = 0.559_910_73;
26
27/// PQ encoded signal to linear light.
28#[must_use]
29pub fn pq_eotf(encoded: f64) -> f64 {
30    let encoded = encoded.clamp(0.0, 1.0);
31    let n = encoded.powf(1.0 / PQ_M2);
32    let num = (n - PQ_C1).max(0.0);
33    let den = PQ_C2 - (PQ_C3 * n);
34    if den <= 0.0 {
35        return 0.0;
36    }
37    (num / den).powf(1.0 / PQ_M1)
38}
39
40/// Linear light to PQ encoded signal.
41#[must_use]
42pub fn pq_oetf(linear: f64) -> f64 {
43    let linear = linear.clamp(0.0, 1.0);
44    let n = linear.powf(PQ_M1);
45    ((PQ_C1 + (PQ_C2 * n)) / (1.0 + (PQ_C3 * n))).powf(PQ_M2)
46}
47
48/// HLG encoded signal to scene-linear light.
49#[must_use]
50pub fn hlg_oetf_inverse(encoded: f64) -> f64 {
51    let encoded = encoded.clamp(0.0, 1.0);
52    if encoded <= 0.5 {
53        (encoded * encoded) / 3.0
54    } else {
55        (((encoded - HLG_C) / HLG_A).exp() + HLG_B) / 12.0
56    }
57}
58
59/// sRGB encoded signal to linear light.
60#[must_use]
61pub fn srgb_eotf(encoded: f64) -> f64 {
62    let encoded = encoded.clamp(0.0, 1.0);
63    if encoded <= 0.040_45 {
64        encoded / 12.92
65    } else {
66        ((encoded + 0.055) / 1.055).powf(2.4)
67    }
68}
69
70/// Linear light to sRGB encoded signal.
71#[must_use]
72pub fn srgb_oetf(linear: f64) -> f64 {
73    let linear = linear.clamp(0.0, 1.0);
74    if linear <= 0.003_130_8 {
75        12.92 * linear
76    } else {
77        (1.055 * linear.powf(1.0 / 2.4)) - 0.055
78    }
79}
80
81/// Linear light to BT.1886 encoded signal.
82///
83/// A pure 2.4 gamma. Used as the SDR output curve rather than sRGB's piecewise
84/// one: BT.1886 is what a display actually does, and the near-black linear
85/// segment sRGB adds is an encoding convenience, not a display behaviour.
86#[must_use]
87pub fn bt1886_oetf(linear: f64) -> f64 {
88    linear.max(0.0).powf(1.0 / 2.4)
89}
90
91// ── Kernel blob shapes ──────────────────────────────────────────────
92//
93// Declared here rather than pulled from `drm-ffi`, which this crate does not
94// otherwise need. Both are fixed kernel ABI: `drm_color_lut` and
95// `drm_color_ctm` in `include/uapi/drm/drm_mode.h`.
96
97/// One LUT entry, as the kernel reads it.
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
99#[repr(C)]
100#[allow(
101    clippy::exhaustive_structs,
102    reason = "a value defined by its fields; there is nothing to add to it"
103)]
104pub struct ColorLut {
105    /// Red output for this input step.
106    pub red: u16,
107    /// Green output.
108    pub green: u16,
109    /// Blue output.
110    pub blue: u16,
111    /// Kernel padding. Must be zero.
112    pub reserved: u16,
113}
114
115/// A 3x3 colour transform, as the kernel reads it.
116///
117/// Row-major, each entry S31.32 sign-magnitude -- see [`encode_s31_32`].
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119#[repr(C)]
120#[allow(
121    clippy::exhaustive_structs,
122    reason = "a value defined by its fields; there is nothing to add to it"
123)]
124pub struct ColorCtm {
125    /// The nine coefficients, row-major.
126    pub matrix: [u64; 9],
127}
128
129/// `1.0` in S31.32.
130const S31_32_ONE: u64 = 1 << 32;
131/// The same, as the scale factor the arithmetic uses. Exact in `f64`.
132const S31_32_SCALE: f64 = 4_294_967_296.0;
133/// The sign bit. Sign-magnitude, not two's complement.
134const S31_32_SIGN: u64 = 1 << 63;
135/// The largest magnitude the format holds.
136const S31_32_MAX: u64 = (1 << 63) - 1;
137/// The saturation bound in floating point. `S31_32_MAX` has no exact `f64`,
138/// so this is the next value up, which is what a comparison would round to
139/// anyway.
140const S31_32_MAX_AS_F64: f64 = 9_223_372_036_854_775_808.0;
141
142/// Encode a coefficient the way `CTM` wants it.
143///
144/// S31.32 **sign-magnitude**: the top bit is the sign and the remaining
145/// sixty-three are the absolute value scaled by 2^32. Not two's complement,
146/// which is the mistake this exists to make hard -- a negative coefficient
147/// encoded as two's complement reads to the kernel as an enormous positive
148/// one, and the picture comes out with a channel blown to white.
149#[must_use]
150#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
151pub fn encode_s31_32(value: f64) -> u64 {
152    if value.is_nan() {
153        return 0;
154    }
155    let scaled = (value.abs() * S31_32_SCALE).round();
156    // Saturating rather than wrapping. No colour matrix has a coefficient
157    // anywhere near 2^31, so this is unreachable in practice; wrapping would
158    // turn an overflow into a small number and hide it. The comparison is
159    // against 2^63 rather than 2^63 - 1 because the latter is not
160    // representable in an `f64` -- it rounds up to the former, so the two
161    // bounds are the same number here.
162    let magnitude = if scaled >= S31_32_MAX_AS_F64 {
163        S31_32_MAX
164    } else {
165        // Non-negative and below 2^63 by the branch above.
166        scaled as u64
167    };
168    magnitude | if value < 0.0 { S31_32_SIGN } else { 0 }
169}
170
171/// Read back what [`encode_s31_32`] wrote.
172#[must_use]
173pub fn decode_s31_32(encoded: u64) -> f64 {
174    #[allow(clippy::cast_precision_loss)] // The magnitude is what it is.
175    let magnitude = (encoded & !S31_32_SIGN) as f64 / S31_32_SCALE;
176    if encoded & S31_32_SIGN == 0 {
177        magnitude
178    } else {
179        -magnitude
180    }
181}
182
183/// Quantize a normalized curve output into a LUT entry.
184///
185/// Clamped rather than wrapped: a curve that overshoots slightly at the top
186/// end -- which floating point does -- would otherwise wrap to black at the
187/// brightest step.
188#[must_use]
189pub fn quantize_lut_value(normalized: f64) -> u16 {
190    if normalized.is_nan() || normalized <= 0.0 {
191        return 0;
192    }
193    if normalized >= 1.0 {
194        return u16::MAX;
195    }
196    // In `(0, 1)` by the guards above, so the product is in range and
197    // non-negative.
198    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
199    let quantized = (normalized * f64::from(u16::MAX)).round() as u16;
200    quantized
201}
202
203/// Fill `out` with `curve(i / (n - 1))`, the same value on all three channels.
204///
205/// The length of `out` is the quantization granularity, and has to be exactly
206/// what the driver asks for -- see
207/// [`CrtcCapabilities`](crate::CrtcCapabilities).
208pub fn fill_lut(out: &mut [ColorLut], curve: impl Fn(f64) -> f64) {
209    let last = out.len().saturating_sub(1);
210    for (index, entry) in out.iter_mut().enumerate() {
211        // A one-entry LUT has no range to walk, and dividing by zero would
212        // make its single step NaN.
213        #[allow(clippy::cast_precision_loss)] // A LUT is thousands of entries.
214        let normalized = if last == 0 {
215            0.0
216        } else {
217            index as f64 / last as f64
218        };
219        let value = quantize_lut_value(curve(normalized));
220        *entry = ColorLut {
221            red: value,
222            green: value,
223            blue: value,
224            reserved: 0,
225        };
226    }
227}
228
229/// A LUT that changes nothing.
230///
231/// For a stage the kernel insists on a blob for while the pipeline is only
232/// really using another one.
233pub fn build_identity_lut(out: &mut [ColorLut]) {
234    fill_lut(out, |value| value);
235}
236
237/// PQ encoded to linear, for `DEGAMMA_LUT` on HDR10 content.
238pub fn build_pq_eotf_lut(out: &mut [ColorLut]) {
239    fill_lut(out, pq_eotf);
240}
241
242/// Linear to PQ encoded, for `GAMMA_LUT` when the output is HDR10.
243pub fn build_pq_oetf_lut(out: &mut [ColorLut]) {
244    fill_lut(out, pq_oetf);
245}
246
247/// HLG encoded to scene-linear, for `DEGAMMA_LUT` on HLG content.
248pub fn build_hlg_oetf_inverse_lut(out: &mut [ColorLut]) {
249    fill_lut(out, hlg_oetf_inverse);
250}
251
252/// A matrix that changes nothing.
253#[must_use]
254pub fn build_identity_ctm() -> ColorCtm {
255    let mut matrix = [0_u64; 9];
256    matrix[0] = S31_32_ONE;
257    matrix[4] = S31_32_ONE;
258    matrix[8] = S31_32_ONE;
259    ColorCtm { matrix }
260}
261
262/// BT.2020 to BT.709 in linear light, from ITU-R BT.2087.
263///
264/// Only valid after a degamma stage that produces linear values. Applied to
265/// gamma-encoded ones it is simply wrong, and wrong in a way that looks like
266/// a plausible picture with the colours shifted.
267#[must_use]
268pub fn build_bt2020_to_bt709_ctm() -> ColorCtm {
269    encode_matrix([
270        1.6605, -0.5876, -0.0728, //
271        -0.1246, 1.1329, -0.0083, //
272        -0.0182, -0.1006, 1.1187,
273    ])
274}
275
276/// BT.709 to BT.2020 in linear light, the inverse of the above.
277#[must_use]
278pub fn build_bt709_to_bt2020_ctm() -> ColorCtm {
279    encode_matrix([
280        0.6274, 0.3293, 0.0433, //
281        0.0691, 0.9195, 0.0114, //
282        0.0164, 0.0880, 0.8956,
283    ])
284}
285
286fn encode_matrix(coefficients: [f64; 9]) -> ColorCtm {
287    ColorCtm {
288        matrix: coefficients.map(encode_s31_32),
289    }
290}