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}