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 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 color 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 color 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 colors 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}