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 color 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 behavior.
86#[must_use]
87pub fn bt1886_oetf(linear: f64) -> f64 {
88    // Not `f64::max`, which turns NaN into 0.0: the reference's `std::max`
89    // keeps it, and so does this.
90    let clamped = if linear < 0.0 { 0.0 } else { linear };
91    clamped.powf(1.0 / 2.4)
92}
93
94// ── Kernel blob shapes ──────────────────────────────────────────────
95//
96// Declared here rather than pulled from `drm-ffi`, which this crate does not
97// otherwise need. Both are fixed kernel ABI: `drm_color_lut` and
98// `drm_color_ctm` in `include/uapi/drm/drm_mode.h`.
99
100/// One LUT entry, as the kernel reads it.
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
102#[repr(C)]
103#[allow(
104    clippy::exhaustive_structs,
105    reason = "a value defined by its fields; there is nothing to add to it"
106)]
107pub struct ColorLut {
108    /// Red output for this input step.
109    pub red: u16,
110    /// Green output.
111    pub green: u16,
112    /// Blue output.
113    pub blue: u16,
114    /// Kernel padding. Must be zero.
115    pub reserved: u16,
116}
117
118/// A 3x3 color transform, as the kernel reads it.
119///
120/// Row-major, each entry S31.32 sign-magnitude -- see [`encode_s31_32`].
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122#[repr(C)]
123#[allow(
124    clippy::exhaustive_structs,
125    reason = "a value defined by its fields; there is nothing to add to it"
126)]
127pub struct ColorCtm {
128    /// The nine coefficients, row-major.
129    pub matrix: [u64; 9],
130}
131
132/// `1.0` in S31.32.
133const S31_32_ONE: u64 = 1 << 32;
134/// The same, as the scale factor the arithmetic uses. Exact in `f64`.
135const S31_32_SCALE: f64 = 4_294_967_296.0;
136/// The sign bit. Sign-magnitude, not two's complement.
137const S31_32_SIGN: u64 = 1 << 63;
138/// The largest magnitude the format holds.
139const S31_32_MAX: u64 = (1 << 63) - 1;
140/// The saturation bound in floating point. `S31_32_MAX` has no exact `f64`,
141/// so this is the next value up, which is what a comparison would round to
142/// anyway.
143const S31_32_MAX_AS_F64: f64 = 9_223_372_036_854_775_808.0;
144
145/// Encode a coefficient the way `CTM` wants it.
146///
147/// S31.32 **sign-magnitude**: the top bit is the sign and the remaining
148/// sixty-three are the absolute value scaled by 2^32. Not two's complement,
149/// which is the mistake this exists to make hard -- a negative coefficient
150/// encoded as two's complement reads to the kernel as an enormous positive
151/// one, and the picture comes out with a channel blown to white.
152#[must_use]
153#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
154pub fn encode_s31_32(value: f64) -> u64 {
155    if value.is_nan() {
156        return 0;
157    }
158    let scaled = (value.abs() * S31_32_SCALE).round();
159    // Saturating rather than wrapping. No color matrix has a coefficient
160    // anywhere near 2^31, so this is unreachable in practice; wrapping would
161    // turn an overflow into a small number and hide it. The comparison is
162    // against 2^63 rather than 2^63 - 1 because the latter is not
163    // representable in an `f64` -- it rounds up to the former, so the two
164    // bounds are the same number here.
165    let magnitude = if scaled >= S31_32_MAX_AS_F64 {
166        S31_32_MAX
167    } else {
168        // Non-negative and below 2^63 by the branch above.
169        scaled as u64
170    };
171    magnitude | if value < 0.0 { S31_32_SIGN } else { 0 }
172}
173
174/// Read back what [`encode_s31_32`] wrote.
175#[must_use]
176pub fn decode_s31_32(encoded: u64) -> f64 {
177    #[allow(clippy::cast_precision_loss)] // The magnitude is what it is.
178    let magnitude = (encoded & !S31_32_SIGN) as f64 / S31_32_SCALE;
179    if encoded & S31_32_SIGN == 0 {
180        magnitude
181    } else {
182        -magnitude
183    }
184}
185
186/// Quantize a normalized curve output into a LUT entry.
187///
188/// Clamped rather than wrapped: a curve that overshoots slightly at the top
189/// end -- which floating point does -- would otherwise wrap to black at the
190/// brightest step.
191#[must_use]
192pub fn quantize_lut_value(normalized: f64) -> u16 {
193    if normalized.is_nan() || normalized <= 0.0 {
194        return 0;
195    }
196    if normalized >= 1.0 {
197        return u16::MAX;
198    }
199    // In `(0, 1)` by the guards above, so the product is in range and
200    // non-negative.
201    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
202    let quantized = (normalized * f64::from(u16::MAX)).round() as u16;
203    quantized
204}
205
206/// Fill `out` with `curve(i / (n - 1))`, the same value on all three channels.
207///
208/// The length of `out` is the quantization granularity, and has to be exactly
209/// what the driver asks for -- see
210/// [`CrtcCapabilities`](crate::CrtcCapabilities).
211pub fn fill_lut(out: &mut [ColorLut], curve: impl Fn(f64) -> f64) {
212    let last = out.len().saturating_sub(1);
213    for (index, entry) in out.iter_mut().enumerate() {
214        // A one-entry LUT has no range to walk, and dividing by zero would
215        // make its single step NaN.
216        #[allow(clippy::cast_precision_loss)] // A LUT is thousands of entries.
217        let normalized = if last == 0 {
218            0.0
219        } else {
220            index as f64 / last as f64
221        };
222        let value = quantize_lut_value(curve(normalized));
223        *entry = ColorLut {
224            red: value,
225            green: value,
226            blue: value,
227            reserved: 0,
228        };
229    }
230}
231
232/// A LUT that changes nothing.
233///
234/// For a stage the kernel insists on a blob for while the pipeline is only
235/// really using another one.
236pub fn build_identity_lut(out: &mut [ColorLut]) {
237    fill_lut(out, |value| value);
238}
239
240/// PQ encoded to linear, for `DEGAMMA_LUT` on HDR10 content.
241pub fn build_pq_eotf_lut(out: &mut [ColorLut]) {
242    fill_lut(out, pq_eotf);
243}
244
245/// Linear to PQ encoded, for `GAMMA_LUT` when the output is HDR10.
246pub fn build_pq_oetf_lut(out: &mut [ColorLut]) {
247    fill_lut(out, pq_oetf);
248}
249
250/// HLG encoded to scene-linear, for `DEGAMMA_LUT` on HLG content.
251pub fn build_hlg_oetf_inverse_lut(out: &mut [ColorLut]) {
252    fill_lut(out, hlg_oetf_inverse);
253}
254
255/// A matrix that changes nothing.
256#[must_use]
257pub fn build_identity_ctm() -> ColorCtm {
258    let mut matrix = [0_u64; 9];
259    matrix[0] = S31_32_ONE;
260    matrix[4] = S31_32_ONE;
261    matrix[8] = S31_32_ONE;
262    ColorCtm { matrix }
263}
264
265/// BT.2020 to BT.709 in linear light, from ITU-R BT.2087.
266///
267/// Only valid after a degamma stage that produces linear values. Applied to
268/// gamma-encoded ones it is simply wrong, and wrong in a way that looks like
269/// a plausible picture with the colors shifted.
270#[must_use]
271pub fn build_bt2020_to_bt709_ctm() -> ColorCtm {
272    encode_matrix([
273        1.6605, -0.5876, -0.0728, //
274        -0.1246, 1.1329, -0.0083, //
275        -0.0182, -0.1006, 1.1187,
276    ])
277}
278
279/// BT.709 to BT.2020 in linear light, the inverse of the above.
280#[must_use]
281pub fn build_bt709_to_bt2020_ctm() -> ColorCtm {
282    encode_matrix([
283        0.6274, 0.3293, 0.0433, //
284        0.0691, 0.9195, 0.0114, //
285        0.0164, 0.0880, 0.8956,
286    ])
287}
288
289fn encode_matrix(coefficients: [f64; 9]) -> ColorCtm {
290    ColorCtm {
291        matrix: coefficients.map(encode_s31_32),
292    }
293}