zenpixels_convert/hdr/gamut_compress.rs
1//! Soft gamut compression in OKLch — precomputed gamut boundary LUT
2//! plus a rational knee function that preserves hue and lightness.
3//!
4//! For a given color primaries set, [`GamutBoundaryLut`] tabulates the
5//! maximum in-gamut OKLch chroma at each `(L, hue)` pair. [`SoftCompress`]
6//! wraps a LUT with a configurable knee threshold and smoothly reduces
7//! out-of-gamut chroma using a rational compression curve.
8//!
9//! # Provenance
10//!
11//! Extracted from `zenfilters::gamut_lut` (the previous home of these
12//! types — the implementation is byte-identical to that crate's
13//! `GamutBoundaryLut`, plus a thin [`SoftCompress`] wrapper that exposes
14//! the same compression behavior under an explicit type. Tests carried
15//! over verbatim from `zenfilters/src/gamut_lut.rs`).
16
17extern crate alloc;
18
19use crate::gamut::GamutMatrix;
20use crate::oklab;
21
22/// Precomputed sRGB (or P3 / BT.2020) gamut boundary in OKLch space.
23///
24/// Stores the maximum in-gamut chroma for a grid of `(L, hue)` values.
25/// Constructed once per primaries set and reused across frames.
26///
27/// The grid is `64` lightness steps × `256` hue steps (16 384 floats =
28/// 64 KiB). Construction takes ~30 ms on a single core for sRGB — amortize
29/// by sharing one LUT across the lifetime of a converter.
30#[derive(Debug, Clone)]
31pub struct GamutBoundaryLut {
32 /// Flattened `[L_STEPS][H_STEPS]` array of max chroma values.
33 data: alloc::vec::Vec<f32>,
34}
35
36/// Number of lightness steps in the LUT (0..=1).
37const L_STEPS: usize = 64;
38/// Number of hue angle steps in the LUT (0..2π).
39const H_STEPS: usize = 256;
40/// Maximum chroma to search during LUT construction.
41/// OKLch chroma rarely exceeds 0.4 for sRGB, but P3/BT.2020 can go higher.
42const MAX_SEARCH_CHROMA: f32 = 0.5;
43/// Binary search iterations for gamut boundary (2^-20 ≈ 1e-6 precision).
44const BISECT_ITERS: u32 = 20;
45
46impl GamutBoundaryLut {
47 /// Build the gamut boundary LUT for a given primaries set.
48 ///
49 /// `m1_inv` is the combined LMS → linear RGB matrix for the target
50 /// primaries, from [`crate::oklab::lms_to_rgb_matrix`].
51 pub fn new(m1_inv: &GamutMatrix) -> Self {
52 let mut data = alloc::vec![0.0f32; L_STEPS * H_STEPS];
53
54 for li in 0..L_STEPS {
55 let l = li as f32 / (L_STEPS - 1) as f32;
56 for hi in 0..H_STEPS {
57 let h = hi as f32 / H_STEPS as f32 * core::f32::consts::TAU;
58 data[li * H_STEPS + hi] = find_max_chroma(l, h, m1_inv);
59 }
60 }
61
62 Self { data }
63 }
64
65 /// Look up the maximum in-gamut chroma for a given `(L, hue)` with
66 /// bilinear interpolation. `h` is in radians; out-of-range `L` is
67 /// clamped to `[0, 1]` and `h` is wrapped modulo `2π`.
68 #[inline]
69 pub fn max_chroma(&self, l: f32, h: f32) -> f32 {
70 let l_clamped = l.clamp(0.0, 1.0);
71 let h_norm = h.rem_euclid(core::f32::consts::TAU);
72
73 let l_f = l_clamped * (L_STEPS - 1) as f32;
74 let h_f = h_norm / core::f32::consts::TAU * H_STEPS as f32;
75
76 let l0 = (l_f as usize).min(L_STEPS - 2);
77 let l1 = l0 + 1;
78 let h0 = h_f as usize % H_STEPS;
79 let h1 = (h0 + 1) % H_STEPS;
80
81 let lt = l_f - l0 as f32;
82 let ht = h_f - h0 as f32;
83
84 let v00 = self.data[l0 * H_STEPS + h0];
85 let v01 = self.data[l0 * H_STEPS + h1];
86 let v10 = self.data[l1 * H_STEPS + h0];
87 let v11 = self.data[l1 * H_STEPS + h1];
88
89 let top = v00 + (v01 - v00) * ht;
90 let bot = v10 + (v11 - v10) * ht;
91 top + (bot - top) * lt
92 }
93
94 /// Apply soft chroma compression to OKLab planes in-place.
95 ///
96 /// For each pixel, if chroma exceeds `knee * max_chroma`, smoothly
97 /// compresses it toward the gamut boundary using a rational function
98 /// that preserves hue and lightness.
99 ///
100 /// `knee` is the fraction of max chroma where compression starts
101 /// (`0.0`–`1.0`). Production default: `0.96` (start compressing at
102 /// 96 % of gamut boundary), empirically calibrated against the
103 /// imazen-26 gain-mapped HDR corpus on 2026-06-23 — the largest knee
104 /// where the corpus-p90 fraction of pre-clamp out-of-gamut pixels
105 /// stays under 0.1 %. Smaller values bring the rolloff in earlier
106 /// (more desaturation, lower clipping); larger values let more
107 /// clipping leak through.
108 ///
109 /// Chroma-compresses `NaN`/`inf` OKLab inputs to `NaN`/`inf` outputs
110 /// (no scrubbing — the caller owns input sanitisation; the pipeline
111 /// path feeds this finite values).
112 ///
113 /// # Panics
114 ///
115 /// Panics if `a.len()` or `b.len()` differs from `l.len()`. The three
116 /// planes describe one image and must be the same length; a mismatch
117 /// is a caller bug. (Promoted from a `debug_assert` so release builds
118 /// get this message instead of a raw index-out-of-bounds panic.)
119 pub fn compress_planes(&self, l: &[f32], a: &mut [f32], b: &mut [f32], knee: f32) {
120 let n = l.len();
121 assert!(
122 a.len() == n && b.len() == n,
123 "compress_planes: L/a/b plane lengths must match (l={n}, a={}, b={})",
124 a.len(),
125 b.len(),
126 );
127
128 for i in 0..n {
129 let av = a[i];
130 let bv = b[i];
131
132 // libm, not the std `f32::sqrt`/`atan2` inherents: this crate is
133 // `#![cfg_attr(not(feature = "std"), no_std)]` and `hdr-experimental`
134 // does not pull `std`, so a no_std consumer enabling it must not hit
135 // std-only float methods.
136 let c = libm::sqrtf(av * av + bv * bv);
137 if c < 1e-10 {
138 continue; // achromatic, nothing to compress
139 }
140
141 let h = libm::atan2f(bv, av);
142
143 let max_c = self.max_chroma(l[i], h);
144 if max_c < 1e-10 {
145 // At L=0 or L=1, max chroma is 0 — force achromatic
146 a[i] = 0.0;
147 b[i] = 0.0;
148 continue;
149 }
150
151 let knee_c = knee * max_c;
152 if c <= knee_c {
153 continue; // within knee threshold, pass through
154 }
155
156 // Rational compression: maps [knee_c, ∞) → [knee_c, max_c)
157 //
158 // f(C) = knee_c + range * excess / (excess + range)
159 //
160 // Properties:
161 // f(knee_c) = knee_c (C0 continuous)
162 // f'(knee_c) = 1 (C1 continuous — slope matches passthrough)
163 // f(∞) → max_c (asymptotic limit)
164 let range = max_c - knee_c;
165 let excess = c - knee_c;
166 let compressed_c = knee_c + range * excess / (excess + range);
167
168 let scale = compressed_c / c;
169 a[i] = av * scale;
170 b[i] = bv * scale;
171 }
172 }
173}
174
175/// Binary search for the maximum in-gamut chroma at a given `(L, hue)`.
176fn find_max_chroma(l: f32, h: f32, m1_inv: &GamutMatrix) -> f32 {
177 let cos_h = libm::cosf(h);
178 let sin_h = libm::sinf(h);
179
180 let mut lo = 0.0f32;
181 let mut hi = MAX_SEARCH_CHROMA;
182
183 for _ in 0..BISECT_ITERS {
184 let mid = (lo + hi) * 0.5;
185 let a = mid * cos_h;
186 let b = mid * sin_h;
187
188 if is_in_gamut(l, a, b, m1_inv) {
189 lo = mid;
190 } else {
191 hi = mid;
192 }
193 }
194
195 lo
196}
197
198/// Check if an OKLab color is within the RGB gamut for the given primaries.
199#[inline]
200fn is_in_gamut(l: f32, a: f32, b: f32, m1_inv: &GamutMatrix) -> bool {
201 let rgb = oklab::oklab_to_rgb(l, a, b, m1_inv);
202 rgb[0] >= 0.0
203 && rgb[0] <= 1.0
204 && rgb[1] >= 0.0
205 && rgb[1] <= 1.0
206 && rgb[2] >= 0.0
207 && rgb[2] <= 1.0
208}
209
210/// Soft chroma compression on linear-light RGB strips.
211///
212/// Wraps a [`GamutBoundaryLut`] with a `knee` threshold and exposes the
213/// compression as an explicit per-strip API. Construction performs the
214/// LUT build once; subsequent [`apply_strip`](Self::apply_strip) calls
215/// reuse it.
216///
217/// # Pipeline
218///
219/// For each pixel:
220/// 1. Convert linear RGB → OKLab (via [`crate::oklab::rgb_to_oklab`]).
221/// 2. Compute chroma `c = √(a² + b²)` and hue `h = atan2(b, a)`.
222/// 3. Look up max in-gamut chroma `c_max` at `(L, h)`.
223/// 4. If `c > knee · c_max`, compress: `c' = knee·c_max + range · excess / (excess + range)`.
224/// 5. Convert OKLab → linear RGB.
225///
226/// Hue and lightness are preserved within float precision; only chroma is
227/// modified. The rational compression curve is C¹-continuous at the knee
228/// (slope `1.0` on the inside, asymptote at the gamut boundary).
229///
230/// # Examples
231///
232/// ```
233/// # #[cfg(feature = "hdr-experimental")]
234/// # {
235/// use zenpixels_convert::hdr::SoftCompress;
236/// use zenpixels_convert::oklab;
237/// use zenpixels::ColorPrimaries;
238///
239/// let m1_inv = oklab::lms_to_rgb_matrix(ColorPrimaries::Bt709).unwrap();
240/// let compress = SoftCompress::new(&m1_inv, 0.96);
241///
242/// let mut pixels = vec![[1.2_f32, 0.05, 0.05]]; // out-of-gamut red
243/// compress.apply_strip(&mut pixels);
244/// for px in &pixels {
245/// for &c in px {
246/// assert!(c <= 1.0 + 1e-2, "expected in-gamut output");
247/// }
248/// }
249/// # }
250/// ```
251#[derive(Debug, Clone)]
252pub struct SoftCompress {
253 lut: GamutBoundaryLut,
254 knee: f32,
255 m1: GamutMatrix,
256 m1_inv: GamutMatrix,
257}
258
259impl SoftCompress {
260 /// Production default knee — the fraction of max chroma where the soft
261 /// rolloff begins. Empirically calibrated against the 76-sample
262 /// imazen-26 gain-mapped HDR corpus on 2026-06-23: `0.96` is the
263 /// largest knee value (i.e. the LEAST chroma compression / desaturation)
264 /// where the corpus-p90 fraction of pre-clamp out-of-gamut pixels stays
265 /// under 0.1 %. Surfaced as a `pub const` so test fixtures and external
266 /// callers can refer to the same anchor as
267 /// [`crate::HdrConfig::default`]'s `gamut_knee` field. Matches
268 /// `HdrConfig::default().gamut_knee` byte-for-byte.
269 pub const DEFAULT_KNEE: f32 = 0.96;
270
271 /// Construct a [`SoftCompress`] for the given primaries (via `m1_inv`,
272 /// the LMS → RGB matrix from
273 /// [`crate::oklab::lms_to_rgb_matrix`]) and
274 /// `knee` threshold (`0.0`–`1.0`; production default `0.96`,
275 /// corpus-validated 2026-06-23).
276 ///
277 /// The matching forward matrix is derived by inverting `m1_inv`. If you
278 /// already have the forward matrix on hand (the `rgb_to_lms_matrix`
279 /// output), prefer [`SoftCompress::from_matrices`] (which takes both
280 /// and cannot panic).
281 ///
282 /// # Panics
283 ///
284 /// Panics if `m1_inv` is singular (non-invertible). Every matrix from
285 /// [`crate::oklab::lms_to_rgb_matrix`] — the intended input — is
286 /// invertible, so the pipeline path never triggers this; it only
287 /// fires for a hand-constructed degenerate matrix. Pass matrices from
288 /// `oklab::lms_to_rgb_matrix` / `rgb_to_lms_matrix`, or use
289 /// [`from_matrices`](Self::from_matrices) to supply the inverse
290 /// directly.
291 #[must_use]
292 pub fn new(m1_inv: &GamutMatrix, knee: f32) -> Self {
293 let m1 = invert_3x3(m1_inv).expect("LMS→RGB matrix must be invertible");
294 Self {
295 lut: GamutBoundaryLut::new(m1_inv),
296 knee,
297 m1,
298 m1_inv: *m1_inv,
299 }
300 }
301
302 /// Construct a [`SoftCompress`] from both forward and inverse matrices.
303 /// `m1` is the linear-RGB → LMS matrix (from `oklab::rgb_to_lms_matrix`);
304 /// `m1_inv` is the LMS → linear-RGB matrix.
305 ///
306 /// The two matrices must be a matched inverse pair for the same
307 /// primaries — this constructor does not verify that (it's the cheap,
308 /// no-panic path). A mismatched pair yields a wrong (but non-panicking)
309 /// color transform. Prefer [`new`](Self::new) when you only have the
310 /// inverse and want the forward derived consistently.
311 #[must_use]
312 pub fn from_matrices(m1: &GamutMatrix, m1_inv: &GamutMatrix, knee: f32) -> Self {
313 Self {
314 lut: GamutBoundaryLut::new(m1_inv),
315 knee,
316 m1: *m1,
317 m1_inv: *m1_inv,
318 }
319 }
320
321 /// Apply soft gamut compression to a strip of linear RGB pixels in place.
322 ///
323 /// Expects finite linear-RGB input; `NaN`/`inf` channels pass through
324 /// to `NaN`/`inf` output (this is an inner strip primitive — the
325 /// pipeline scrubs non-finite values before the tone-map chain, so
326 /// direct callers own that themselves).
327 pub fn apply_strip(&self, rgb: &mut [[f32; 3]]) {
328 // Convert to OKLab in planar form for the LUT — small temporaries
329 // per pixel keep the API allocation-free even for short strips.
330 for px in rgb.iter_mut() {
331 let lab = oklab::rgb_to_oklab(px[0], px[1], px[2], &self.m1);
332 let l = lab[0];
333 let mut a = lab[1];
334 let mut b = lab[2];
335
336 // libm float math — no_std discipline (see `compress_planes`).
337 let c = libm::sqrtf(a * a + b * b);
338 if c < 1e-10 {
339 continue;
340 }
341 let h = libm::atan2f(b, a);
342 let max_c = self.lut.max_chroma(l, h);
343 if max_c < 1e-10 {
344 a = 0.0;
345 b = 0.0;
346 } else {
347 let knee_c = self.knee * max_c;
348 if c > knee_c {
349 let range = max_c - knee_c;
350 let excess = c - knee_c;
351 let compressed_c = knee_c + range * excess / (excess + range);
352 let scale = compressed_c / c;
353 a *= scale;
354 b *= scale;
355 }
356 }
357 let out = oklab::oklab_to_rgb(l, a, b, &self.m1_inv);
358 *px = out;
359 }
360 }
361
362 /// Borrow the inner [`GamutBoundaryLut`] for direct planar use (used by
363 /// `zenfilters::Pipeline`, which keeps its own planar OKLab buffer).
364 #[inline]
365 #[must_use]
366 pub fn lut(&self) -> &GamutBoundaryLut {
367 &self.lut
368 }
369
370 /// Knee threshold (fraction of max chroma where compression starts).
371 #[inline]
372 #[must_use]
373 pub fn knee(&self) -> f32 {
374 self.knee
375 }
376}
377
378/// Invert a 3×3 matrix via cofactor expansion. Returns `None` if singular.
379fn invert_3x3(m: &GamutMatrix) -> Option<GamutMatrix> {
380 let a = m[0][0];
381 let b = m[0][1];
382 let c = m[0][2];
383 let d = m[1][0];
384 let e = m[1][1];
385 let f = m[1][2];
386 let g = m[2][0];
387 let h = m[2][1];
388 let i = m[2][2];
389
390 let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
391 if det.abs() < 1e-30 {
392 return None;
393 }
394 let inv_det = 1.0 / det;
395 Some([
396 [
397 (e * i - f * h) * inv_det,
398 -(b * i - c * h) * inv_det,
399 (b * f - c * e) * inv_det,
400 ],
401 [
402 -(d * i - f * g) * inv_det,
403 (a * i - c * g) * inv_det,
404 -(a * f - c * d) * inv_det,
405 ],
406 [
407 (d * h - e * g) * inv_det,
408 -(a * h - b * g) * inv_det,
409 (a * e - b * d) * inv_det,
410 ],
411 ])
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use crate::oklab;
418 use zenpixels::ColorPrimaries;
419
420 fn bt709_lut() -> GamutBoundaryLut {
421 let m1_inv = oklab::lms_to_rgb_matrix(ColorPrimaries::Bt709).unwrap();
422 GamutBoundaryLut::new(&m1_inv)
423 }
424
425 fn bt709_soft_compress(knee: f32) -> SoftCompress {
426 let m1 = oklab::rgb_to_lms_matrix(ColorPrimaries::Bt709).unwrap();
427 let m1_inv = oklab::lms_to_rgb_matrix(ColorPrimaries::Bt709).unwrap();
428 SoftCompress::from_matrices(&m1, &m1_inv, knee)
429 }
430
431 // ---- Tests carried from zenfilters/src/gamut_lut.rs ----
432
433 #[test]
434 fn lut_boundary_at_extremes() {
435 let lut = bt709_lut();
436
437 // At L=0 (black) and L=1 (white), max chroma should be ~0
438 for hi in 0..H_STEPS {
439 let h = hi as f32 / H_STEPS as f32 * core::f32::consts::TAU;
440 assert!(
441 lut.max_chroma(0.0, h) < 0.01,
442 "L=0 max chroma should be ~0, got {}",
443 lut.max_chroma(0.0, h)
444 );
445 assert!(
446 lut.max_chroma(1.0, h) < 0.01,
447 "L=1 max chroma should be ~0, got {}",
448 lut.max_chroma(1.0, h)
449 );
450 }
451 }
452
453 #[test]
454 fn lut_boundary_has_positive_chroma_at_mid_l() {
455 let lut = bt709_lut();
456
457 let mut max_found = 0.0f32;
458 for hi in 0..H_STEPS {
459 let h = hi as f32 / H_STEPS as f32 * core::f32::consts::TAU;
460 let mc = lut.max_chroma(0.5, h);
461 max_found = max_found.max(mc);
462 }
463 assert!(
464 max_found > 0.1,
465 "mid-L should have substantial gamut, max chroma = {max_found}"
466 );
467 }
468
469 #[test]
470 fn lut_boundary_is_monotonic_toward_extremes() {
471 let lut = bt709_lut();
472
473 // For a fixed hue, chroma boundary should increase from L=0 to
474 // some peak, then decrease to L=1 (spindle shape).
475 let h = 0.5;
476 let mut found_peak = false;
477 let mut prev = 0.0f32;
478 for li in 0..L_STEPS {
479 let l = li as f32 / (L_STEPS - 1) as f32;
480 let mc = lut.max_chroma(l, h);
481 if mc < prev {
482 found_peak = true;
483 }
484 if found_peak {
485 assert!(
486 mc <= prev + 0.01,
487 "chroma should decrease after peak at L={l}"
488 );
489 }
490 prev = mc;
491 }
492 }
493
494 #[test]
495 fn compress_preserves_in_gamut_colors() {
496 let lut = bt709_lut();
497 let m1_inv = oklab::lms_to_rgb_matrix(ColorPrimaries::Bt709).unwrap();
498
499 let l = alloc::vec![0.5, 0.3, 0.7, 0.9];
500 let mut a = alloc::vec![0.01, -0.02, 0.005, 0.0];
501 let mut b = alloc::vec![0.01, 0.01, -0.01, 0.0];
502 let a_orig = a.clone();
503 let b_orig = b.clone();
504
505 for i in 0..l.len() {
506 assert!(
507 is_in_gamut(l[i], a[i], b[i], &m1_inv),
508 "test color {i} should be in gamut"
509 );
510 }
511
512 lut.compress_planes(&l, &mut a, &mut b, 0.9);
513
514 for i in 0..l.len() {
515 assert!(
516 (a[i] - a_orig[i]).abs() < 1e-6,
517 "in-gamut color {i} a should be unchanged"
518 );
519 assert!(
520 (b[i] - b_orig[i]).abs() < 1e-6,
521 "in-gamut color {i} b should be unchanged"
522 );
523 }
524 }
525
526 #[test]
527 fn compress_reduces_out_of_gamut_chroma() {
528 let lut = bt709_lut();
529
530 let l = alloc::vec![0.5, 0.5, 0.5];
531 let mut a = alloc::vec![0.3, -0.3, 0.0];
532 let mut b = alloc::vec![0.0, 0.0, 0.3];
533
534 let orig_chroma: alloc::vec::Vec<f32> = a
535 .iter()
536 .zip(b.iter())
537 .map(|(&av, &bv): (&f32, &f32)| (av * av + bv * bv).sqrt())
538 .collect();
539
540 lut.compress_planes(&l, &mut a, &mut b, 0.9);
541
542 for i in 0..l.len() {
543 let new_chroma = (a[i] * a[i] + b[i] * b[i]).sqrt();
544 assert!(
545 new_chroma < orig_chroma[i],
546 "color {i} chroma should decrease: {:.4} -> {:.4}",
547 orig_chroma[i],
548 new_chroma
549 );
550 }
551 }
552
553 #[test]
554 fn compress_preserves_hue() {
555 let lut = bt709_lut();
556
557 let l = alloc::vec![0.5, 0.5];
558 let mut a = alloc::vec![0.3, -0.2];
559 let mut b = alloc::vec![0.1, 0.25];
560
561 let orig_hue: alloc::vec::Vec<f32> = a
562 .iter()
563 .zip(b.iter())
564 .map(|(&av, &bv): (&f32, &f32)| bv.atan2(av))
565 .collect();
566
567 lut.compress_planes(&l, &mut a, &mut b, 0.9);
568
569 for i in 0..l.len() {
570 let new_hue = b[i].atan2(a[i]);
571 let hue_diff = (new_hue - orig_hue[i]).abs();
572 assert!(
573 hue_diff < 1e-4,
574 "hue should be preserved: {:.6} -> {:.6}",
575 orig_hue[i],
576 new_hue
577 );
578 }
579 }
580
581 #[test]
582 fn compress_output_is_in_gamut() {
583 let m1_inv = oklab::lms_to_rgb_matrix(ColorPrimaries::Bt709).unwrap();
584 let lut = bt709_lut();
585
586 let l = alloc::vec![0.5, 0.3, 0.8, 0.5, 0.1, 0.95];
587 let mut a = alloc::vec![0.4, -0.3, 0.2, -0.4, 0.15, 0.05];
588 let mut b = alloc::vec![0.3, 0.4, -0.3, -0.2, 0.2, -0.03];
589
590 lut.compress_planes(&l, &mut a, &mut b, 0.9);
591
592 for i in 0..l.len() {
593 let rgb = oklab::oklab_to_rgb(l[i], a[i], b[i], &m1_inv);
594 assert!(
595 rgb[0] >= -0.01 && rgb[0] <= 1.01,
596 "R out of gamut after compress: color {i} R={:.4}",
597 rgb[0]
598 );
599 assert!(
600 rgb[1] >= -0.01 && rgb[1] <= 1.01,
601 "G out of gamut after compress: color {i} G={:.4}",
602 rgb[1]
603 );
604 assert!(
605 rgb[2] >= -0.01 && rgb[2] <= 1.01,
606 "B out of gamut after compress: color {i} B={:.4}",
607 rgb[2]
608 );
609 }
610 }
611
612 // ---- New tests for SoftCompress wrapper ----
613
614 #[test]
615 fn interior_pixels_pass_through_unchanged() {
616 let compress = bt709_soft_compress(0.9);
617 // Pixels well inside the gamut should be bit-identical (within
618 // RGB→OKLab→RGB float roundtrip noise) after compress.
619 let mut pixels = alloc::vec![
620 [0.5_f32, 0.5, 0.5],
621 [0.3, 0.2, 0.1],
622 [0.6, 0.3, 0.2],
623 [0.2, 0.4, 0.5],
624 ];
625 let originals = pixels.clone();
626 compress.apply_strip(&mut pixels);
627 for (i, (p, o)) in pixels.iter().zip(originals.iter()).enumerate() {
628 for k in 0..3 {
629 let diff = (p[k] - o[k]).abs();
630 // 5e-4 tolerates the OKLab roundtrip f32 noise (cbrt + 3×3 matrices).
631 assert!(
632 diff < 5e-4,
633 "interior pixel {i} ch{k}: input {} output {} diff {}",
634 o[k],
635 p[k],
636 diff
637 );
638 }
639 }
640 }
641
642 #[test]
643 fn out_of_gamut_pixels_land_in_gamut() {
644 // Out-of-gamut **chroma** at sub-peak luminance — the realistic case
645 // post-tone-mapping. Inputs were chosen so OKLab L stays in [0, 1]
646 // (the LUT's defined domain); at L > 1, max_chroma collapses to 0
647 // and the compressor correctly snaps to achromatic, but the OKLab →
648 // RGB roundtrip on the L > 1 column emits RGB > 1 by construction.
649 let compress = bt709_soft_compress(0.9);
650 let mut pixels = alloc::vec![
651 [0.85_f32, -0.05, -0.05], // out-of-gamut red (negative G/B from BT.2020 mapping)
652 [-0.05, 0.85, -0.05], // out-of-gamut green
653 [-0.05, -0.05, 0.85], // out-of-gamut blue
654 [0.9, 0.9, -0.05], // out-of-gamut yellow
655 ];
656 compress.apply_strip(&mut pixels);
657 for (i, px) in pixels.iter().enumerate() {
658 for (k, &v) in px.iter().enumerate() {
659 assert!(
660 v.is_finite() && (-0.02..=1.02).contains(&v),
661 "out-of-gamut pixel {i} ch{k} = {v} did not land in `[0,1]`"
662 );
663 }
664 }
665 }
666
667 #[test]
668 fn hue_preservation_under_compression() {
669 // Use sub-peak primaries so OKLab L stays well inside [0, 1]; at
670 // L → 1 (or L → 0) the LUT's max_chroma collapses to 0 and the
671 // compressor *correctly* forces a → b → 0, which by definition
672 // throws hue away. The contract is "hue preserved when the LUT has
673 // headroom"; we test that, not the degenerate L = boundary case.
674 let m1 = oklab::rgb_to_lms_matrix(ColorPrimaries::Bt709).unwrap();
675 let compress = bt709_soft_compress(0.9);
676 let inputs = [
677 [0.85_f32, 0.05, 0.05], // saturated red
678 [0.05, 0.85, 0.05], // saturated green
679 [0.05, 0.05, 0.85], // saturated blue
680 [0.7, 0.7, 0.05], // saturated yellow
681 [0.85, 0.05, 0.85], // saturated magenta
682 ];
683 for rgb in inputs {
684 let lab_before = oklab::rgb_to_oklab(rgb[0], rgb[1], rgb[2], &m1);
685 let h_before = lab_before[2].atan2(lab_before[1]);
686
687 let mut strip = alloc::vec![rgb];
688 compress.apply_strip(&mut strip);
689
690 let out = strip[0];
691 let lab_after = oklab::rgb_to_oklab(out[0], out[1], out[2], &m1);
692 let h_after = lab_after[2].atan2(lab_after[1]);
693
694 let hue_diff = (h_after - h_before).abs();
695 assert!(
696 hue_diff < 0.001,
697 "hue drift for {rgb:?}: before {h_before:.6} after {h_after:.6} diff {hue_diff}"
698 );
699 }
700 }
701
702 #[test]
703 fn lightness_preservation_under_compression() {
704 // Same input shape as hue test — sub-peak primaries with OKLab L in
705 // [0, 1] so the compressor never has to snap chroma to zero.
706 let m1 = oklab::rgb_to_lms_matrix(ColorPrimaries::Bt709).unwrap();
707 let compress = bt709_soft_compress(0.9);
708 let inputs = [
709 [0.85_f32, 0.05, 0.05],
710 [0.05, 0.85, 0.05],
711 [0.05, 0.05, 0.85],
712 [0.7, 0.7, 0.05],
713 [0.85, 0.05, 0.85],
714 ];
715 for rgb in inputs {
716 let l_before = oklab::rgb_to_oklab(rgb[0], rgb[1], rgb[2], &m1)[0];
717 let mut strip = alloc::vec![rgb];
718 compress.apply_strip(&mut strip);
719 let out = strip[0];
720 let l_after = oklab::rgb_to_oklab(out[0], out[1], out[2], &m1)[0];
721 let l_diff = (l_after - l_before).abs();
722 assert!(
723 l_diff < 0.005,
724 "lightness drift for {rgb:?}: before {l_before} after {l_after} diff {l_diff}"
725 );
726 }
727 }
728
729 #[test]
730 fn matrix_invert_round_trip() {
731 let m_in = oklab::lms_to_rgb_matrix(ColorPrimaries::Bt709).unwrap();
732 let m_out = invert_3x3(&m_in).unwrap();
733 let m_back = invert_3x3(&m_out).unwrap();
734 for i in 0..3 {
735 for j in 0..3 {
736 assert!(
737 (m_in[i][j] - m_back[i][j]).abs() < 1e-4,
738 "matrix invert roundtrip drift at [{i}][{j}]: in {} back {}",
739 m_in[i][j],
740 m_back[i][j]
741 );
742 }
743 }
744 }
745
746 #[test]
747 fn empty_strip_is_noop() {
748 let compress = bt709_soft_compress(0.9);
749 let mut empty: alloc::vec::Vec<[f32; 3]> = alloc::vec::Vec::new();
750 compress.apply_strip(&mut empty);
751 assert!(empty.is_empty());
752 }
753}