1use crate::curves::{bt1886_oetf, hlg_oetf_inverse, pq_eotf, pq_oetf, srgb_eotf};
27
28pub const LUT_SIZE: usize = 1025;
33
34const LUT_BUCKETS: usize = 1024;
35const LUT_BUCKETS_F: f64 = 1024.0;
39const INPUT_INDEX_SHIFT: u32 = 6;
41const INPUT_FRAC_DIV: f64 = 64.0;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45#[non_exhaustive]
46pub enum ToneMapCurve {
47 None,
52 #[default]
54 Reinhard,
55 Hable,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62#[allow(
63 clippy::exhaustive_enums,
64 reason = "a closed set: there is no further variant to add"
65)]
66pub enum Direction {
67 Bt709ToBt2020Pq,
69 Bt2020PqToBt709,
71 HlgToBt709,
73}
74
75const BT709_TO_BT2020: [f64; 9] = [
77 0.6274, 0.3293, 0.0433, 0.0691, 0.9195, 0.0114, 0.0164, 0.0880, 0.8956,
80];
81
82const BT2020_TO_BT709: [f64; 9] = [
88 1.6605, -0.5876, -0.0728, -0.1246, 1.1329, -0.0083, -0.0182, -0.1006, 1.1187,
91];
92
93const BT2020_Y_R: f64 = 0.2627;
95const BT2020_Y_G: f64 = 0.6780;
96const BT2020_Y_B: f64 = 0.0593;
97
98fn reinhard(x: f64) -> f64 {
99 x / (1.0 + x)
100}
101
102fn hable_curve(x: f64) -> f64 {
103 const A: f64 = 0.15;
104 const B: f64 = 0.50;
105 const C: f64 = 0.10;
106 const D: f64 = 0.20;
107 const E: f64 = 0.02;
108 const F: f64 = 0.30;
109 (((x * ((A * x) + (C * B))) + (D * E)) / ((x * ((A * x) + B)) + (D * F))) - (E / F)
110}
111
112fn hable(x: f64) -> f64 {
113 const WHITE: f64 = 11.2;
117 (hable_curve(x) / hable_curve(WHITE)).clamp(0.0, 1.0)
118}
119
120fn apply_curve(x: f64, curve: ToneMapCurve) -> f64 {
121 match curve {
122 ToneMapCurve::None => x.clamp(0.0, 1.0),
123 ToneMapCurve::Reinhard => reinhard(x.max(0.0)),
124 ToneMapCurve::Hable => hable(x.max(0.0)),
125 }
126}
127
128fn matrix_mul(m: &[f64; 9], v: [f64; 3]) -> [f64; 3] {
129 [
130 (m[0] * v[0]) + (m[1] * v[1]) + (m[2] * v[2]),
131 (m[3] * v[0]) + (m[4] * v[1]) + (m[5] * v[2]),
132 (m[6] * v[0]) + (m[7] * v[1]) + (m[8] * v[2]),
133 ]
134}
135
136fn sample_input(lut: &[f32; LUT_SIZE], encoded: u16) -> f64 {
138 let idx = (encoded >> INPUT_INDEX_SHIFT) as usize;
139 let frac = f64::from(encoded & 0x3F) / INPUT_FRAC_DIV;
140 let lo = f64::from(lut[idx]);
141 let hi = f64::from(lut[idx + 1]);
142 lo + (frac * (hi - lo))
143}
144
145#[expect(
147 clippy::cast_possible_truncation,
148 clippy::cast_sign_loss,
149 reason = "the clamp on the line above bounds `scaled` to 0.0..=1024.0, so \
150 the conversion is exact and non-negative by construction"
151)]
152fn sample_output(lut: &[f32; LUT_SIZE], linear: f64) -> f64 {
153 let linear = linear.clamp(0.0, 1.0);
154 let scaled = linear * LUT_BUCKETS_F;
155 let idx = scaled as usize;
156 if idx >= LUT_BUCKETS {
159 return f64::from(lut[LUT_BUCKETS]);
160 }
161 let frac = scaled - f64::from(u32::try_from(idx).unwrap_or(u32::MAX));
162 let lo = f64::from(lut[idx]);
163 let hi = f64::from(lut[idx + 1]);
164 lo + (frac * (hi - lo))
165}
166
167pub struct ToneMapper {
172 direction: Direction,
173 curve: ToneMapCurve,
174 sdr_white_nits: f32,
175 target_max_nits: f32,
176 input_lut: Box<[f32; LUT_SIZE]>,
177 output_lut: Box<[f32; LUT_SIZE]>,
178}
179
180impl std::fmt::Debug for ToneMapper {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct("ToneMapper")
183 .field("direction", &self.direction)
184 .field("curve", &self.curve)
185 .field("sdr_white_nits", &self.sdr_white_nits)
186 .field("target_max_nits", &self.target_max_nits)
187 .finish_non_exhaustive()
188 }
189}
190
191impl ToneMapper {
192 #[must_use]
199 pub fn bt709_to_bt2020_pq(sdr_white_nits: f32) -> Self {
200 Self::new(
201 Direction::Bt709ToBt2020Pq,
202 sdr_white_nits,
203 100.0,
204 ToneMapCurve::None,
205 )
206 }
207
208 #[must_use]
214 pub fn bt2020_pq_to_bt709(target_max_nits: f32, curve: ToneMapCurve) -> Self {
215 Self::new(Direction::Bt2020PqToBt709, 100.0, target_max_nits, curve)
216 }
217
218 #[must_use]
223 pub fn hlg_to_bt709(target_max_nits: f32) -> Self {
224 Self::new(
225 Direction::HlgToBt709,
226 100.0,
227 target_max_nits,
228 ToneMapCurve::Reinhard,
229 )
230 }
231
232 #[expect(
233 clippy::cast_possible_truncation,
234 reason = "the tables are f32 on purpose: two 4KB tables stay in cache \
235 where two 8KB ones do not, and the values are transfer-curve \
236 samples in 0.0..=1.0 where f32 has far more precision than \
237 the 16-bit channels being sampled"
238 )]
239 fn new(
240 direction: Direction,
241 sdr_white_nits: f32,
242 target_max_nits: f32,
243 curve: ToneMapCurve,
244 ) -> Self {
245 let mut input_lut = Box::new([0.0f32; LUT_SIZE]);
246 let mut output_lut = Box::new([0.0f32; LUT_SIZE]);
247 for i in 0..LUT_SIZE {
248 let t = f64::from(u32::try_from(i).unwrap_or(u32::MAX)) / LUT_BUCKETS_F;
249 input_lut[i] = match direction {
250 Direction::Bt709ToBt2020Pq => srgb_eotf(t),
251 Direction::Bt2020PqToBt709 => pq_eotf(t),
252 Direction::HlgToBt709 => hlg_oetf_inverse(t),
253 } as f32;
254 output_lut[i] = match direction {
255 Direction::Bt709ToBt2020Pq => pq_oetf(t),
256 Direction::Bt2020PqToBt709 | Direction::HlgToBt709 => bt1886_oetf(t),
257 } as f32;
258 }
259 Self {
260 direction,
261 curve,
262 sdr_white_nits,
263 target_max_nits,
264 input_lut,
265 output_lut,
266 }
267 }
268
269 #[must_use]
271 pub const fn direction(&self) -> Direction {
272 self.direction
273 }
274
275 #[must_use]
277 pub const fn curve(&self) -> ToneMapCurve {
278 self.curve
279 }
280
281 #[must_use]
283 pub const fn sdr_white_nits(&self) -> f32 {
284 self.sdr_white_nits
285 }
286
287 #[must_use]
289 pub const fn target_max_nits(&self) -> f32 {
290 self.target_max_nits
291 }
292
293 #[must_use]
295 #[expect(
296 clippy::cast_possible_truncation,
297 clippy::cast_sign_loss,
298 reason = "the quantiser clamps to 0.0..=1.0 before scaling by 65535, \
299 so the rounded result is a whole number in 0..=65535 and \
300 both the truncation and the sign are ruled out by the clamp"
301 )]
302 pub fn map(&self, pixel: u64) -> u64 {
303 let r_q = (pixel & 0xFFFF) as u16;
304 let g_q = ((pixel >> 16) & 0xFFFF) as u16;
305 let b_q = ((pixel >> 32) & 0xFFFF) as u16;
306 let a_q = (pixel >> 48) & 0xFFFF;
307
308 let mut r = sample_input(&self.input_lut, r_q);
309 let mut g = sample_input(&self.input_lut, g_q);
310 let mut b = sample_input(&self.input_lut, b_q);
311
312 match self.direction {
313 Direction::Bt709ToBt2020Pq => {
314 let rgb = matrix_mul(&BT709_TO_BT2020, [r, g, b]);
315 let scale = f64::from(self.sdr_white_nits) / 10_000.0;
319 r = (rgb[0].max(0.0) * scale).clamp(0.0, 1.0);
320 g = (rgb[1].max(0.0) * scale).clamp(0.0, 1.0);
321 b = (rgb[2].max(0.0) * scale).clamp(0.0, 1.0);
322 }
323 Direction::Bt2020PqToBt709 => {
324 let rgb = matrix_mul(&BT2020_TO_BT709, [r, g, b]);
325 let scale = 10_000.0 / f64::from(self.target_max_nits);
326 r = apply_curve(rgb[0].max(0.0) * scale, self.curve);
327 g = apply_curve(rgb[1].max(0.0) * scale, self.curve);
328 b = apply_curve(rgb[2].max(0.0) * scale, self.curve);
329 }
330 Direction::HlgToBt709 => {
331 let y = (BT2020_Y_R * r) + (BT2020_Y_G * g) + (BT2020_Y_B * b);
338 let ootf = y.max(0.0).powf(0.2);
339 r = (r * ootf).max(0.0);
340 g = (g * ootf).max(0.0);
341 b = (b * ootf).max(0.0);
342
343 let rgb = matrix_mul(&BT2020_TO_BT709, [r, g, b]);
344 let scale = 12.0 / (f64::from(self.target_max_nits) / 100.0);
348 r = reinhard(rgb[0].max(0.0) * scale);
349 g = reinhard(rgb[1].max(0.0) * scale);
350 b = reinhard(rgb[2].max(0.0) * scale);
351 }
352 }
353
354 let to_u16 = |v: f64| (v.clamp(0.0, 1.0) * 65535.0).round() as u64;
355 to_u16(sample_output(&self.output_lut, r))
356 | (to_u16(sample_output(&self.output_lut, g)) << 16)
357 | (to_u16(sample_output(&self.output_lut, b)) << 32)
358 | (a_q << 48)
359 }
360
361 pub fn apply(&self, src: &[u64], dst: &mut [u64]) {
367 for (out, pixel) in dst.iter_mut().zip(src) {
368 *out = self.map(*pixel);
369 }
370 }
371}