Skip to main content

zenpixels_convert/hdr/
measure.rs

1//! Content-light-level (CLL) measurement for HDR pixel data.
2//!
3//! Histogram-based and SOTA scalar/SIMD reductions over relative-linear
4//! `RgbF32` / `RgbaF32` buffers, scaled by a [`DiffuseWhite`] anchor into
5//! absolute cd/m². The primitives are exposed as an extension trait
6//! ([`CllMeasure`]) on [`ContentLightLevel`] so the call sites stay
7//! identical to the (pre-relocation) inherent-impl shape — `cargo add
8//! zenpixels-convert` + `use zenpixels_convert::CllMeasure` is the
9//! whole upgrade path.
10//!
11//! This module owns:
12//!
13//! - [`LightLevelMethod`] — per-pixel reduction (MaxRgb / BT.2020 luma).
14//! - [`LightLevelHistogram`] — log-scale histogram primitive with max,
15//!   mean, and linearly-interpolated percentile readouts.
16//! - [`CllMeasure`] — extension trait on `ContentLightLevel` carrying
17//!   `measure_max`, `measure_max_smoothed`, `measure_robust`,
18//!   `measure_percentile`, `measure_histogram`.
19//! - The scalar and tiered-SIMD kernels behind the trait methods.
20//!
21//! The bit-exact deprecated `ContentLightLevel::measure(px, white)` 2-arg
22//! method stays in `zenpixels::hdr` (frozen public surface for the
23//! 0.2.14 release line). This module is the post-0.2.14 home for
24//! everything richer.
25
26use alloc::boxed::Box;
27use alloc::vec;
28
29use zenpixels::hdr::{ContentLightLevel, DiffuseWhite};
30use zenpixels::{PixelFormat, PixelSlice, TransferFunction};
31
32/// Round non-negative nits to a CTA-861.3 `u16` code (saturating).
33///
34/// `nits` is a luminance — always `≥ 0` at the call sites. Round-half-up is
35/// then `(nits + 0.5)` truncated, and the float→int `as` cast saturates to
36/// `[0, u16::MAX]` (mapping negatives and NaN to 0). Done by hand because
37/// `f64::round` lives in `std` (libm) and this crate builds `no_std`.
38#[inline]
39fn nits_to_u16(nits: f64) -> u16 {
40    (nits + 0.5) as u16
41}
42
43/// Per-pixel reduction method for content-light-level measurement.
44///
45/// CTA-861-G Annex P pins MaxCLL as "the largest light level of any
46/// pixel" without normatively fixing the per-pixel reduction. Two
47/// readings are in production use:
48///
49/// - **`MaxRgb`** — `max(R, G, B)` in cd/m². The dominant industry
50///   convention (x265, DaVinci Resolve, Psychtoolbox, Dolby Vision L1,
51///   libultrahdr). Bounds what a panel must drive on its worst channel
52///   and is conservative on saturated colours.
53/// - **`LuminanceBt2020`** — BT.2020 NCL luma
54///   (`0.2627·R + 0.6780·G + 0.0593·B`). Used by some Netflix / Apple
55///   TV+ pipelines. Matches photometric luminance, so a saturated red
56///   reads at `0.2627 ×` peak instead of the full peak — closer to
57///   perceived brightness, further from panel-drive worst case.
58///
59/// Default is [`MaxRgb`](Self::MaxRgb) matching the dominant reading.
60#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
61#[non_exhaustive]
62pub enum LightLevelMethod {
63    /// `max(R, G, B)` per pixel — the CTA-861.3 industry default.
64    #[default]
65    MaxRgb,
66    /// `0.2627·R + 0.6780·G + 0.0593·B` per BT.2020 NCL luma weights.
67    LuminanceBt2020,
68}
69
70/// Log-scale histogram of per-pixel light levels in cd/m².
71///
72/// Built by [`CllMeasure::measure_histogram`]. Exposes the
73/// spec-literal max, arithmetic mean (the MaxFALL component), and
74/// arbitrary percentile via a CDF walk over the binned distribution.
75/// Bins are log2-spaced over `[BIN_MIN_NITS, BIN_MAX_NITS]` so the
76/// high-DR range is well-resolved at ~0.02 stops per bin.
77///
78/// **Why the histogram is the primitive.** Defect-driven outliers
79/// (stuck pixels, sensor noise spikes, specular blowouts) want a
80/// percentile readout; naturally-sparse-bright content
81/// (astrophotography, fireworks, candle in a dark room) wants the
82/// literal max. A fixed-percentile API silently miscalibrates one
83/// or the other; surfacing the histogram lets the caller commit to
84/// a content policy explicitly. See
85/// <https://github.com/imazen/zenpixels/issues/54> for the design.
86///
87/// The histogram is also the cheapest way to compute multiple
88/// readouts — the per-pixel scan is the expensive step, and CDF
89/// lookups are O(bins) after.
90#[derive(Clone, Debug)]
91#[non_exhaustive]
92#[doc(hidden)]
93pub struct LightLevelHistogram {
94    bins: Box<[u32]>,
95    total: u64,
96    sum_nits: f64,
97    literal_max_nits: f32,
98    method: LightLevelMethod,
99}
100
101impl LightLevelHistogram {
102    /// Lower edge of bin 0, in cd/m². Anything ≤ this (incl. 0 and
103    /// negatives that survived clamping) lands in bin 0.
104    pub const BIN_MIN_NITS: f32 = 0.005;
105    /// Upper edge of the last bin, in cd/m². Anything ≥ this saturates
106    /// into the last bin. The PQ container peak is 10 000 cd/m².
107    pub const BIN_MAX_NITS: f32 = 10_000.0;
108    /// Number of bins. 1024 covers `[0.005, 10000]` at ~0.0204 stops
109    /// per bin (well below the cone JND), fits in L1 at 4 KiB.
110    pub const NUM_BINS: usize = 1024;
111
112    // log2 of the range endpoints (constants, not from libm at runtime).
113    // log2(0.005) = -log2(200) = -(log2(128) + log2(1.5625)) ≈ -7.6438561
114    // log2(10000) = log2(2^13 · 1.220703125) ≈ 13.287712
115    // (computed in f64 at design time; pinned here so no_std builds need
116    // no libm dep at runtime to know the bin geometry.)
117    const LOG2_MIN: f32 = -7.643_856;
118    const LOG2_MAX: f32 = 13.287_712;
119    #[inline(always)]
120    const fn log2_step() -> f32 {
121        (Self::LOG2_MAX - Self::LOG2_MIN) / (Self::NUM_BINS as f32)
122    }
123    #[inline(always)]
124    const fn inv_log2_step() -> f32 {
125        1.0 / Self::log2_step()
126    }
127
128    /// Spec-literal MaxCLL — the largest per-pixel light level observed
129    /// (CTA-861.3 strict reading). Exact, not bin-quantised.
130    pub fn max(&self) -> f32 {
131        self.literal_max_nits
132    }
133
134    /// Arithmetic mean of per-pixel light levels — the MaxFALL component
135    /// for a single frame. `0.0` for an empty histogram.
136    pub fn mean(&self) -> f32 {
137        if self.total == 0 {
138            return 0.0;
139        }
140        (self.sum_nits / self.total as f64) as f32
141    }
142
143    /// Percentile of the light-level distribution, in cd/m².
144    /// `percentile` is in `[0.0, 1.0]`; out-of-range inputs clamp, NaN
145    /// maps to 0. `1.0` returns [`max`](Self::max) exactly (no bin
146    /// quantisation at the spec-literal value).
147    ///
148    /// Intermediate percentiles walk the binned CDF, identify the bin
149    /// where the cumulative count first crosses `percentile · total`,
150    /// and **linearly interpolate within that bin** (in log2 space, to
151    /// match the log2-uniform bin spacing). Returned values land
152    /// strictly between the bin edges and the literal max is preserved
153    /// when the threshold falls in the bin holding the maximum sample.
154    /// Resolution is ~0.0006 stops at typical pixel counts (≥ 4 MP) and
155    /// degrades smoothly as content fills fewer pixels per bin —
156    /// always finer than the bin floor (~0.02 stops) returned by a
157    /// naïve walk.
158    pub fn percentile(&self, percentile: f32) -> f32 {
159        if self.total == 0 {
160            return 0.0;
161        }
162        // NaN check first — `clamp` panics on NaN bounds and propagates NaN
163        // through the input; we've already documented NaN → 0 above.
164        let p = if percentile.is_nan() {
165            0.0
166        } else {
167            percentile.clamp(0.0, 1.0)
168        };
169        if p >= 1.0 {
170            return self.literal_max_nits;
171        }
172        if p <= 0.0 {
173            // The 0-th percentile is the floor of the distribution. We
174            // don't track a literal-min, and reporting "the lower edge
175            // of bin 0" (`BIN_MIN_NITS` ≈ 0.005) would surprise callers
176            // who reasonably expect `p=0` → `0.0`. Pin to 0.
177            return 0.0;
178        }
179        let threshold = (p as f64 * self.total as f64) as u64;
180        let mut cum: u64 = 0;
181        let inv_step = Self::inv_log2_step();
182        for (i, &count) in self.bins.iter().enumerate() {
183            let count_u64 = count as u64;
184            cum += count_u64;
185            if cum >= threshold {
186                // Fraction of `count` pixels that fall ≤ threshold
187                // within this bin. `count_before = cum - count_u64`
188                // is the running total before this bin. Compute in f64
189                // to keep precision when `count` reaches into the
190                // millions on large frames (f32 mantissa is 23 bits).
191                let count_before = cum - count_u64;
192                let fraction = if count_u64 > 0 {
193                    let inside = threshold.saturating_sub(count_before) as f64;
194                    let f = (inside / count_u64 as f64).clamp(0.0, 1.0);
195                    f as f32
196                } else {
197                    0.0
198                };
199                let log2_interp = Self::LOG2_MIN + (i as f32 + fraction) / inv_step;
200                let interp = fast_exp2(log2_interp).max(0.0);
201                // The bin holding the literal max must NEVER report a
202                // value above it — `fast_exp2` rounding could otherwise
203                // overshoot by a u16-nit code on the last reachable bin.
204                return interp.min(self.literal_max_nits);
205            }
206        }
207        self.literal_max_nits
208    }
209
210    /// The per-pixel reduction used when this histogram was built.
211    pub fn method(&self) -> LightLevelMethod {
212        self.method
213    }
214
215    /// Total pixels accumulated (equals `width × height` for the
216    /// contiguous-RGB(A) measure path).
217    pub fn total_pixels(&self) -> u64 {
218        self.total
219    }
220
221    /// Raw bin counts; index `i` covers
222    /// `[BIN_MIN_NITS · 2^(i·log2_step), BIN_MIN_NITS · 2^((i+1)·log2_step))`.
223    /// Useful for plotting or composing custom readouts (multi-percentile,
224    /// mode, etc.).
225    pub fn bins(&self) -> &[u32] {
226        &self.bins
227    }
228}
229
230/// `no_std` `log2` for a positive `f32` — degree-2 minimax polynomial
231/// on the mantissa. Max error ~0.01 stops over the input domain, well
232/// below the 0.02-stop bin width of [`LightLevelHistogram`]. Inputs ≤ 0
233/// return `f32::NEG_INFINITY` so the caller clamps into bin 0.
234#[inline]
235fn fast_log2(x: f32) -> f32 {
236    use core::f32::consts::LOG2_E;
237    // !(x > 0.0) catches NaN and ≤ 0 in one branch; the partial_cmp
238    // rewrite clippy suggests doesn't read more clearly here.
239    if let Some(core::cmp::Ordering::Greater) = x.partial_cmp(&0.0) {
240        let bits = x.to_bits();
241        let exponent = ((bits >> 23) & 0xFF) as i32 - 127;
242        // Mantissa reconstructed as a float in `[1.0, 2.0)`.
243        let mantissa = f32::from_bits((bits & 0x7F_FFFF) | (127 << 23));
244        let f = mantissa - 1.0;
245        // log2(1+f) ≈ f · (log2(e) − (log2(e) − 1)·f), Horner-form
246        // minimax (log2(e) ≈ 1.4426950, the leading constant).
247        let log2_mantissa = f * (LOG2_E - (LOG2_E - 1.0) * f);
248        (exponent as f32) + log2_mantissa
249    } else {
250        f32::NEG_INFINITY
251    }
252}
253
254/// `no_std` `exp2` — degree-3 minimax polynomial on the fractional part
255/// plus a bit-fiddle for the integer power-of-2 component. Accuracy
256/// ample for the bin-edge → cd/m² conversion in
257/// [`LightLevelHistogram::percentile`] (a percentile result is
258/// quantised to a bin edge anyway). Inputs outside the f32 exponent
259/// range saturate to 0 or `INFINITY` instead of wrapping.
260#[inline]
261fn fast_exp2(x: f32) -> f32 {
262    if !x.is_finite() {
263        return if x > 0.0 { f32::INFINITY } else { 0.0 };
264    }
265    // `as i32` truncates toward 0; floor differs for negative x.
266    let mut i = x as i32;
267    if (i as f32) > x {
268        i -= 1;
269    }
270    let f = x - (i as f32);
271    // 2^f ≈ 1 + ln(2)·f + 0.2402264·f² + 0.0554976·f³ (deg-3 minimax on [0,1]).
272    // The leading coefficient is ln(2) by Taylor identity; the higher-order
273    // terms are minimax-fit constants that don't match a named `consts`.
274    let pf = 1.0 + f * (core::f32::consts::LN_2 + f * (0.240_226_4 + f * 0.055_497_6));
275    // 2^i via f32 exponent bits (bias 127, shift 23). Saturate outside
276    // the normal range; subnormals and overflow handled by the clamp.
277    let biased = i + 127;
278    if biased <= 0 {
279        return 0.0;
280    }
281    if biased >= 255 {
282        return f32::INFINITY;
283    }
284    let two_i = f32::from_bits((biased as u32) << 23);
285    two_i * pf
286}
287
288/// Compute the bin index for a cd/m² value via the log2 mapping
289/// pinned in [`LightLevelHistogram`]. Values ≤ `BIN_MIN_NITS` go to
290/// bin 0; values ≥ `BIN_MAX_NITS` saturate to the last bin.
291#[inline(always)]
292fn bin_for_nits(value_nits: f32) -> usize {
293    if value_nits <= LightLevelHistogram::BIN_MIN_NITS {
294        return 0;
295    }
296    if value_nits >= LightLevelHistogram::BIN_MAX_NITS {
297        return LightLevelHistogram::NUM_BINS - 1;
298    }
299    let log2 = fast_log2(value_nits);
300    let bin =
301        ((log2 - LightLevelHistogram::LOG2_MIN) * LightLevelHistogram::inv_log2_step()) as usize;
302    if bin >= LightLevelHistogram::NUM_BINS {
303        LightLevelHistogram::NUM_BINS - 1
304    } else {
305        bin
306    }
307}
308
309/// Single-pass 3×1 horizontal-box-filtered row scan for
310/// `measure_max_smoothed`: one row of `N`-channel f32 pixels →
311/// `(smoothed_max_relative, unsmoothed_sum_relative)`.
312///
313/// Per-pixel `m[i] = reduce(R, G, B)` (per `method`) is computed once;
314/// the smoothed running max tracks `max over i of mean(m[i-1], m[i], m[i+1])`
315/// with mirror-padding at the row edges. The sum is the *unsmoothed*
316/// arithmetic sum — `mean(mean(...))` is just the mean (linearity of
317/// expectation) and CTA-861.3 MaxFALL is the literal arithmetic mean, so
318/// the box filter only affects the max readout, not MaxFALL.
319///
320/// Why 3×1 over 3×3: a 3×3 mean needs an explicit row buffer (~16 KB for
321/// 4K-wide rows) and doubles memory traffic; 3×1 is one sliding window of
322/// three floats, single pass over the row, no allocation. 3×1 still
323/// suppresses the dominant defect modes — single stuck pixels, denormal /
324/// near-infinity values that escaped a poorly-clamped pipeline, specular
325/// single-pixel blowouts. Real bright features that span ≥2 horizontal
326/// pixels (small stars, sparks, candle flames) survive proportional to
327/// their width.
328///
329/// State is two scalars (`prev`, `curr`); LLVM keeps them in registers, the
330/// per-pixel cost is `reduce + 2 adds + 1 compare + 1 f64 add`. Memory
331/// traffic matches `scan_row_max_mean` (no scratch buffer, no second pass).
332///
333/// Returns relative-linear units; the caller scales by `white_nits` at
334/// end-of-image.
335#[inline]
336fn scan_row_max_mean_smoothed<const N: usize>(row: &[f32], method: LightLevelMethod) -> (f32, f64) {
337    const ONE_THIRD: f32 = 1.0 / 3.0;
338    let pixel_count = row.len() / N;
339    if pixel_count == 0 {
340        return (0.0, 0.0);
341    }
342
343    // Per-pixel reduce closure — `method` is loop-invariant Copy, LLVM
344    // hoists the match out of the inner loop.
345    let reduce_at = |i: usize| -> f32 {
346        let px: &[f32; N] = row[i * N..(i + 1) * N].try_into().unwrap();
347        match method {
348            LightLevelMethod::MaxRgb => 0.0_f32.max(px[0]).max(px[1]).max(px[2]),
349            LightLevelMethod::LuminanceBt2020 => {
350                let r = 0.0_f32.max(px[0]);
351                let g = 0.0_f32.max(px[1]);
352                let b = 0.0_f32.max(px[2]);
353                0.262_7 * r + 0.678_0 * g + 0.059_3 * b
354            }
355        }
356    };
357
358    // Degenerate widths: the 3-pixel window collapses, return the trivial
359    // reading. Box filter at width=1 mirror-pads to (m,m,m) → mean = m.
360    if pixel_count == 1 {
361        let m = reduce_at(0);
362        return (m, f64::from(m));
363    }
364    if pixel_count == 2 {
365        let m0 = reduce_at(0);
366        let m1 = reduce_at(1);
367        // Mirror pad: m_smooth[0] = (m0+m0+m1)/3 ; m_smooth[1] = (m0+m1+m1)/3.
368        let s0 = (2.0 * m0 + m1) * ONE_THIRD;
369        let s1 = (m0 + 2.0 * m1) * ONE_THIRD;
370        return (s0.max(s1), f64::from(m0) + f64::from(m1));
371    }
372
373    // pixel_count >= 3 — single-pass streaming with a 3-element sliding
374    // window. `max_x3` holds the un-divided 3-sum; we divide by 3 once at
375    // the end to keep the hot loop free of constant multiplies.
376    let m0 = reduce_at(0);
377    let m1 = reduce_at(1);
378    let mut prev = m0;
379    let mut curr = m1;
380    // i=0: mirror-pad left → m_smooth_x3 = m0 + m0 + m1
381    let mut max_x3 = 2.0 * m0 + m1;
382    let mut sum = f64::from(m0);
383
384    for i in 2..pixel_count {
385        let next = reduce_at(i);
386        // Smoothed value at pixel (i-1): (prev + curr + next) / 3.
387        let s = prev + curr + next;
388        if s > max_x3 {
389            max_x3 = s;
390        }
391        sum += f64::from(curr);
392        prev = curr;
393        curr = next;
394    }
395    // i=pixel_count-1: mirror-pad right → m_smooth_x3 = prev + curr + curr.
396    let s_last = prev + 2.0 * curr;
397    if s_last > max_x3 {
398        max_x3 = s_last;
399    }
400    sum += f64::from(curr);
401
402    (max_x3 * ONE_THIRD, sum)
403}
404
405/// CLL measurement extension trait on [`ContentLightLevel`].
406///
407/// Carries the histogram-based and SOTA scalar/SIMD measurement
408/// entrypoints. Implemented for `ContentLightLevel` only; users call
409/// these as associated functions just like the (pre-relocation)
410/// inherent impls — `ContentLightLevel::measure_max(px, white,
411/// method)` etc., once `use zenpixels_convert::CllMeasure;` is in
412/// scope.
413///
414/// MaxFALL is always the arithmetic mean (CTA-861.3 spec-literal),
415/// independent of which entrypoint produces the MaxCLL reading.
416pub trait CllMeasure {
417    /// MaxCLL + MaxFALL measurement for HDR content.
418    ///
419    /// Spec-conformant CTA-861.3 MaxCLL + MaxFALL — literal max + mean.
420    /// MaxCLL = the largest single per-pixel light level in the image,
421    /// MaxFALL = the arithmetic mean.
422    ///
423    /// `method` picks the per-pixel reduction; the same input contract
424    /// holds for all measurements: relative-linear `RgbF32` / `RgbaF32`
425    /// only, with `white` anchoring the relative scale to absolute
426    /// cd/m² (sample `1.0` = `white` nits; [`DiffuseWhite::BT2408`] =
427    /// 203 is the convention). Negative/NaN samples clamp to 0; an
428    /// alpha lane is ignored; strided rows are handled.
429    ///
430    /// Empirically the production-best peak-measurement method per
431    /// the 2026-06-22 audited HDR→SDR shootout (76 imazen-26 samples
432    /// × 20 curves × 4 peak methods, scored with mean + per-image-
433    /// percentile ΔE2000 and OKLab Euclidean ΔE). Won 3 of 6 ranking
434    /// criteria including the user-visible `pct_above_de5` by 11 %
435    /// over the closest alternative. See
436    /// `zen/zentone/benchmarks/shootout_2026-06-22_findings_v2.md`.
437    ///
438    /// **SOTA performance.** This is the hot path for spec-conformant
439    /// CLL metadata — the kind of measurement that runs on every frame
440    /// of every encode. Implementation skips the histogram entirely:
441    /// SIMD per-pixel `max + sum` only, scaled by the diffuse-white
442    /// anchor at end-of-image. The SIMD path is unconditional (runtime
443    /// dispatch — no cargo feature, no `-C target-cpu` flag needed): on
444    /// a Ryzen 9 7950X via the AVX2 tier this reaches ≥1 Gpix/s
445    /// sustained, several times the histogram path's throughput
446    /// (`examples/measure_histogram_throughput.rs` prints both).
447    fn measure_max(
448        px: PixelSlice<'_>,
449        white: DiffuseWhite,
450        method: LightLevelMethod,
451    ) -> Option<ContentLightLevel>;
452
453    /// **Internal / experimental.** 3×1 horizontal box-filtered max as
454    /// an alternative defect-rejection strategy to percentile-based
455    /// [`measure_robust`](Self::measure_robust).
456    ///
457    /// **Kept on the trait but doc-hidden** because the 2026-06-22
458    /// audited shootout did NOT crown this method under any of the 6
459    /// ranking criteria (`mean_de2000`, `de2000_p95`, `de2000_p99`,
460    /// `pct_above_de5`, `de_ok_mean`, `de_ok_p95`). On the 76-sample
461    /// imazen-26 corpus it was a near-tie with `measure_max` and
462    /// uniformly behind `measure_robust` on tail metrics. Production
463    /// callers should pick [`measure_max`](Self::measure_max) (spec
464    /// strict / sparse-bright) or [`measure_robust`](Self::measure_robust)
465    /// (defect-tolerant) instead. May be removed in 0.3.0 if no usage
466    /// case emerges.
467    ///
468    /// Each pixel's value contributes through the local 3-tap horizontal
469    /// mean (`(m[i-1] + m[i] + m[i+1]) / 3`, mirror-padded at row edges).
470    /// One stuck pixel at 10 000 cd/m² in a 0.005 cd/m² background reads
471    /// as ~3 333 instead of 10 000; real bright features spanning ≥2
472    /// horizontal pixels survive proportionally. MaxFALL is unchanged
473    /// (mean of a 3×1 box-filtered image equals mean of the original).
474    /// Same input contract as [`measure_max`](Self::measure_max).
475    #[doc(hidden)]
476    fn measure_max_smoothed(
477        px: PixelSlice<'_>,
478        white: DiffuseWhite,
479        method: LightLevelMethod,
480    ) -> Option<ContentLightLevel>;
481
482    /// **Internal / experimental.** Convenience wrapper around
483    /// [`measure_percentile`](Self::measure_percentile) at
484    /// [`DEFAULT_PERCENTILE`](ContentLightLevel::DEFAULT_PERCENTILE).
485    ///
486    /// **Kept on the trait but doc-hidden** because the 2026-06-22
487    /// audited shootout showed it splits 3-3 against
488    /// [`measure_max`](Self::measure_max) on the corpus (winning the
489    /// 3 tail-aware metrics by 1.4-1.8 % but losing
490    /// `mean_de2000` / `pct_above_de5` / `de_ok_mean`). On the
491    /// user-visible "clearly-different fraction" (`pct_above_de5`)
492    /// it loses by 11 % relative. Production callers should use
493    /// [`measure_max`](Self::measure_max) (default) or
494    /// [`measure_percentile`](Self::measure_percentile) (explicit
495    /// percentile with a documented content policy). May be removed
496    /// in 0.3.0 if no usage case emerges.
497    ///
498    /// Same input contract as [`measure_max`](Self::measure_max).
499    #[doc(hidden)]
500    fn measure_robust(
501        px: PixelSlice<'_>,
502        white: DiffuseWhite,
503        method: LightLevelMethod,
504    ) -> Option<ContentLightLevel>;
505
506    /// Percentile-aware MaxCLL + mean MaxFALL.
507    ///
508    /// **Secondary API** — the recommended production default is
509    /// [`measure_max`](Self::measure_max). Use this when your content
510    /// policy needs explicit percentile-based defect rejection (e.g.
511    /// sensor-noisy capture path where single hot pixels would
512    /// over-drive downstream tone-mapping).
513    ///
514    /// `percentile` is in `[0.0, 1.0]` and **has no default** — the
515    /// caller commits to a percentile value explicitly per content
516    /// policy. `1.0` is the spec-literal max (use
517    /// [`measure_max`](Self::measure_max) directly if that's the goal).
518    /// `0.99999` ([`DEFAULT_PERCENTILE`](ContentLightLevel::DEFAULT_PERCENTILE))
519    /// is the tail-tightest tested value in the 2026-06-22 audited
520    /// shootout — trades ~11 % more clearly-different pixels overall
521    /// for ~1.5 % tighter worst-1-5 % tail.
522    ///
523    /// Same input contract as [`measure_histogram`](Self::measure_histogram).
524    /// MaxFALL is always the arithmetic mean (CTA-861.3 / spec-literal),
525    /// independent of `percentile`.
526    #[doc(hidden)]
527    fn measure_percentile(
528        px: PixelSlice<'_>,
529        white: DiffuseWhite,
530        percentile: f32,
531        method: LightLevelMethod,
532    ) -> Option<ContentLightLevel>;
533
534    /// Build a log-scale [`LightLevelHistogram`] of per-pixel light levels
535    /// from relative-linear `RgbF32` / `RgbaF32` pixels.
536    ///
537    /// `white` anchors the relative scale to absolute cd/m² (sample `1.0`
538    /// = `white` nits; [`DiffuseWhite::BT2408`] = 203 is the convention).
539    /// `method` picks the per-pixel reduction (see [`LightLevelMethod`]).
540    ///
541    /// The histogram is the *primitive* — call [`LightLevelHistogram::max`],
542    /// [`LightLevelHistogram::mean`], [`LightLevelHistogram::percentile`]
543    /// (or [`bins`](LightLevelHistogram::bins) for custom CDF walks) to
544    /// derive whatever readouts your content policy requires. See the
545    /// issue #54 design rationale for why we don't bake a fixed
546    /// percentile into a single-call API.
547    ///
548    /// Returns `None` for non-relative-linear `RgbF32`/`RgbaF32` input;
549    /// `Some(empty)` for zero-area input (`total_pixels() == 0`,
550    /// readouts return `0.0`). Strided rows handled; alpha ignored.
551    #[doc(hidden)]
552    fn measure_histogram(
553        px: PixelSlice<'_>,
554        white: DiffuseWhite,
555        method: LightLevelMethod,
556    ) -> Option<LightLevelHistogram>;
557}
558
559impl CllMeasure for ContentLightLevel {
560    fn measure_max(
561        px: PixelSlice<'_>,
562        white: DiffuseWhite,
563        method: LightLevelMethod,
564    ) -> Option<ContentLightLevel> {
565        let desc = px.descriptor();
566        let channels = match desc.pixel_format() {
567            PixelFormat::RgbF32 => 3,
568            PixelFormat::RgbaF32 => 4,
569            _ => return None,
570        };
571        if desc.transfer != TransferFunction::Linear {
572            return None;
573        }
574        let w = px.width() as usize;
575        let h = px.rows() as usize;
576        if w == 0 || h == 0 {
577            return Some(ContentLightLevel::new(0, 0));
578        }
579
580        let stride = px.stride();
581        let bytes = px.as_strided_bytes();
582        let row_len = w * channels * 4;
583        let white_nits = white.nits();
584
585        let (row_max, row_sum) =
586            simd_kernel::scan_max_mean_simd(bytes, h, stride, channels, row_len, method);
587
588        let wn = f64::from(white_nits);
589        let max_nits = f64::from(row_max) * wn;
590        let fall_nits = row_sum / (w as f64 * h as f64) * wn;
591        Some(ContentLightLevel::new(
592            nits_to_u16(max_nits),
593            nits_to_u16(fall_nits),
594        ))
595    }
596
597    fn measure_max_smoothed(
598        px: PixelSlice<'_>,
599        white: DiffuseWhite,
600        method: LightLevelMethod,
601    ) -> Option<ContentLightLevel> {
602        let desc = px.descriptor();
603        let channels = match desc.pixel_format() {
604            PixelFormat::RgbF32 => 3,
605            PixelFormat::RgbaF32 => 4,
606            _ => return None,
607        };
608        if desc.transfer != TransferFunction::Linear {
609            return None;
610        }
611        let w = px.width() as usize;
612        let h = px.rows() as usize;
613        if w == 0 || h == 0 {
614            return Some(ContentLightLevel::new(0, 0));
615        }
616
617        let stride = px.stride();
618        let bytes = px.as_strided_bytes();
619        let row_len = w * channels * 4;
620        let white_nits = white.nits();
621
622        // Scalar streaming path — auto-vectorises to ~1.3 Gpix/s on Zen 4.
623        // A hand-rolled SIMD kernel built shifted-by-1 vectors via array
624        // round-trips (magetypes f32x8 has no lane-shift/permute), and
625        // the store→load forwarding on each chunk cost ~25% net vs the
626        // auto-vectorised scalar. The right SIMD path is a two-pass
627        // design (deinterleave+reduce into a row scratch, then 3-tap
628        // box-max over the scratch), but that's a separate commit.
629        let mut max_rel = 0.0_f32;
630        let mut sum_rel = 0.0_f64;
631        for row in 0..h {
632            let row_bytes = &bytes[row * stride..row * stride + row_len];
633            let floats: &[f32] = bytemuck::cast_slice(row_bytes);
634            let (rm, rs) = if channels == 3 {
635                scan_row_max_mean_smoothed::<3>(floats, method)
636            } else {
637                scan_row_max_mean_smoothed::<4>(floats, method)
638            };
639            max_rel = max_rel.max(rm);
640            sum_rel += rs;
641        }
642
643        let wn = f64::from(white_nits);
644        let max_nits = f64::from(max_rel) * wn;
645        let fall_nits = sum_rel / (w as f64 * h as f64) * wn;
646        Some(ContentLightLevel::new(
647            nits_to_u16(max_nits),
648            nits_to_u16(fall_nits),
649        ))
650    }
651
652    fn measure_robust(
653        px: PixelSlice<'_>,
654        white: DiffuseWhite,
655        method: LightLevelMethod,
656    ) -> Option<ContentLightLevel> {
657        <ContentLightLevel as CllMeasure>::measure_percentile(
658            px,
659            white,
660            ContentLightLevel::DEFAULT_PERCENTILE,
661            method,
662        )
663    }
664
665    fn measure_percentile(
666        px: PixelSlice<'_>,
667        white: DiffuseWhite,
668        percentile: f32,
669        method: LightLevelMethod,
670    ) -> Option<ContentLightLevel> {
671        let h = <ContentLightLevel as CllMeasure>::measure_histogram(px, white, method)?;
672        Some(ContentLightLevel::new(
673            nits_to_u16(f64::from(h.percentile(percentile))),
674            nits_to_u16(f64::from(h.mean())),
675        ))
676    }
677
678    fn measure_histogram(
679        px: PixelSlice<'_>,
680        white: DiffuseWhite,
681        method: LightLevelMethod,
682    ) -> Option<LightLevelHistogram> {
683        let desc = px.descriptor();
684        let channels = match desc.pixel_format() {
685            PixelFormat::RgbF32 => 3,
686            PixelFormat::RgbaF32 => 4,
687            _ => return None,
688        };
689        if desc.transfer != TransferFunction::Linear {
690            return None;
691        }
692        let w = px.width() as usize;
693        let h = px.rows() as usize;
694
695        if w == 0 || h == 0 {
696            return Some(LightLevelHistogram {
697                bins: vec![0u32; LightLevelHistogram::NUM_BINS].into_boxed_slice(),
698                total: 0,
699                sum_nits: 0.0,
700                literal_max_nits: 0.0,
701                method,
702            });
703        }
704
705        let stride = px.stride();
706        let bytes = px.as_strided_bytes();
707        let row_len = w * channels * 4;
708        let white_nits = white.nits();
709
710        // SIMD path: 8 sub-histograms (one per SIMD lane on V3 / emulated
711        // on NEON & WASM128) avoid the cross-lane scatter conflict on the
712        // hot histogram increment. Reduces at the end. archmage + magetypes
713        // are hard deps of zenpixels-convert, so SIMD dispatch is always
714        // available (tier-fallback handles boxes without V3/NEON).
715        Some(simd_kernel::measure_histogram_simd(
716            bytes, w, h, stride, channels, row_len, white_nits, method,
717        ))
718    }
719}
720
721/// Test-only helper that derives the same `(MaxCLL, MaxFALL)` pair
722/// via the histogram path, so the `measure_max_and_measure_histogram
723/// _max_agree_bit_exact` test can cross-check the two paths against
724/// each other.
725#[cfg(test)]
726fn measure_max_via_histogram_for_test(
727    px: PixelSlice<'_>,
728    white: DiffuseWhite,
729    method: LightLevelMethod,
730) -> Option<ContentLightLevel> {
731    let h = <ContentLightLevel as CllMeasure>::measure_histogram(px, white, method)?;
732    Some(ContentLightLevel::new(
733        nits_to_u16(f64::from(h.max())),
734        nits_to_u16(f64::from(h.mean())),
735    ))
736}
737
738// ============================================================================
739// SIMD measure_histogram path
740// ============================================================================
741
742mod simd_kernel {
743    use super::{LightLevelHistogram, LightLevelMethod, bin_for_nits};
744
745    /// One SIMD-lane-worth of sub-histogram. We allocate `LANES` of these
746    /// so each lane writes to its own histogram and no cross-lane scatter
747    /// conflict happens. Lane width is fixed at 8 across all tiers — V3
748    /// (AVX2) is natively 8, NEON / WASM128 are 4 lanes wide so magetypes
749    /// emulates 8-wide via two registers, and the scalar tier loops one
750    /// pixel at a time. 8 × 1024 × 4 bytes = 32 KiB, which fits in a
751    /// modern L1d (32–48 KiB) so the histogram pages stay hot through
752    /// the scan.
753    const LANES: usize = 8;
754
755    /// Flush the f32 lane sums into the f64 running total every this many
756    /// chunks (256 chunks = 2 048 samples per lane). f32 accumulation error
757    /// grows with the number of sequential adds; flushing bounds the f32
758    /// span to 2 048 adds regardless of row width, so MaxFALL stays within
759    /// the ±1-nit parity contract even for panorama-wide rows at PQ-peak
760    /// nit levels. Cost: one horizontal reduce per 2 048 pixels (~free).
761    const SUM_FLUSH_CHUNKS: u32 = 256;
762
763    // BT.2020 NCL luma coefficients — shared with `bt2446a` via the parent
764    // module's `BT2020_L*` constants. Re-aliased here so the SIMD splat and
765    // scalar tail use the same names that previously appeared in this kernel.
766    use crate::hdr::{BT2020_LB as KB, BT2020_LG as KG, BT2020_LR as KR};
767
768    /// Per-lane sub-histograms flattened into a single heap allocation
769    /// of `LANES × NUM_BINS` u32s. We address sub-histogram `i` as
770    /// `&mut sub_hists[i*NUM_BINS .. (i+1)*NUM_BINS]`. Flat storage
771    /// keeps `#![forbid(unsafe_code)]` honoured (no array-shape
772    /// transmute) while still giving each SIMD lane its own
773    /// conflict-free histogram.
774    type SubHists = alloc::boxed::Box<[u32]>;
775
776    fn zero_subhists() -> SubHists {
777        alloc::vec![0u32; LANES * LightLevelHistogram::NUM_BINS].into_boxed_slice()
778    }
779
780    /// Reduce the per-lane sub-histograms into the final flat histogram.
781    fn merge_subhists(sub: &SubHists, out: &mut [u32]) {
782        debug_assert_eq!(out.len(), LightLevelHistogram::NUM_BINS);
783        for bin in 0..LightLevelHistogram::NUM_BINS {
784            let mut total: u32 = 0;
785            for lane in 0..LANES {
786                total = total.wrapping_add(sub[lane * LightLevelHistogram::NUM_BINS + bin]);
787            }
788            out[bin] = total;
789        }
790    }
791
792    /// Main entry. Builds the histogram via the tiered SIMD kernel
793    /// (dispatched via `archmage::incant!`) and returns the populated
794    /// `LightLevelHistogram`. Mirrors the scalar `measure_histogram`
795    /// path's contract: same inputs, same output.
796    #[allow(clippy::too_many_arguments)]
797    pub(super) fn measure_histogram_simd(
798        bytes: &[u8],
799        w: usize,
800        h: usize,
801        stride: usize,
802        channels: usize,
803        row_len: usize,
804        white_nits: f32,
805        method: LightLevelMethod,
806    ) -> LightLevelHistogram {
807        let mut sub = zero_subhists();
808        let mut sum_nits = 0.0_f64;
809        let mut literal_max_nits = 0.0_f32;
810
811        for row in 0..h {
812            let row_bytes = &bytes[row * stride..row * stride + row_len];
813            let floats: &[f32] = bytemuck::cast_slice(row_bytes);
814            match method {
815                LightLevelMethod::MaxRgb => {
816                    if channels == 3 {
817                        archmage::incant!(
818                            accumulate_strip_max_rgb_tier::<3>(
819                                floats,
820                                white_nits,
821                                &mut sub,
822                                &mut sum_nits,
823                                &mut literal_max_nits,
824                            ),
825                            [v3, neon, wasm128, scalar]
826                        );
827                    } else {
828                        archmage::incant!(
829                            accumulate_strip_max_rgb_tier::<4>(
830                                floats,
831                                white_nits,
832                                &mut sub,
833                                &mut sum_nits,
834                                &mut literal_max_nits,
835                            ),
836                            [v3, neon, wasm128, scalar]
837                        );
838                    }
839                }
840                LightLevelMethod::LuminanceBt2020 => {
841                    if channels == 3 {
842                        archmage::incant!(
843                            accumulate_strip_luma_bt2020_tier::<3>(
844                                floats,
845                                white_nits,
846                                &mut sub,
847                                &mut sum_nits,
848                                &mut literal_max_nits,
849                            ),
850                            [v3, neon, wasm128, scalar]
851                        );
852                    } else {
853                        archmage::incant!(
854                            accumulate_strip_luma_bt2020_tier::<4>(
855                                floats,
856                                white_nits,
857                                &mut sub,
858                                &mut sum_nits,
859                                &mut literal_max_nits,
860                            ),
861                            [v3, neon, wasm128, scalar]
862                        );
863                    }
864                }
865            }
866        }
867
868        let mut bins = alloc::vec![0u32; LightLevelHistogram::NUM_BINS].into_boxed_slice();
869        merge_subhists(&sub, &mut bins);
870
871        LightLevelHistogram {
872            bins,
873            total: (w as u64) * (h as u64),
874            sum_nits,
875            literal_max_nits,
876            method,
877        }
878    }
879
880    /// Tiered SIMD kernel for the `MaxRgb` reduction. Processes one
881    /// row of `N`-channel f32 pixels into the per-lane sub-histograms,
882    /// the running max, and the running f64 sum. The `N` channel-count
883    /// generic is the same shape as the scalar `accumulate_row_max_rgb`
884    /// so the alpha lane (when `N == 4`) is ignored uniformly.
885    #[archmage::magetypes(define(f32x8), v3, neon, wasm128, scalar)]
886    pub(crate) fn accumulate_strip_max_rgb_tier<const N: usize>(
887        token: Token,
888        row: &[f32],
889        white_nits: f32,
890        sub_hists: &mut [u32],
891        sum_nits: &mut f64,
892        literal_max_nits: &mut f32,
893    ) {
894        let zero = f32x8::zero(token);
895        let wn = f32x8::splat(token, white_nits);
896        let log2_min = f32x8::splat(token, LightLevelHistogram::LOG2_MIN);
897        let inv_step = f32x8::splat(token, LightLevelHistogram::inv_log2_step());
898        let bin_min_nits = f32x8::splat(token, LightLevelHistogram::BIN_MIN_NITS);
899        let num_bins_minus_1 = f32x8::splat(token, (LightLevelHistogram::NUM_BINS - 1) as f32);
900
901        let mut local_max = zero;
902        // Accumulate in f32 lanes, flushing into the f64 running total
903        // every `SUM_FLUSH_CHUNKS` chunks so the f32 error span is bounded
904        // regardless of row width (the previous once-per-row conversion
905        // assumed rows ≤ 4K pixels).
906        let mut local_sum = zero;
907        let mut chunks_since_flush = 0u32;
908
909        let mut iter = row.chunks_exact(LANES * N);
910        for chunk in &mut iter {
911            let mut ra = [0.0_f32; LANES];
912            let mut ga = [0.0_f32; LANES];
913            let mut ba = [0.0_f32; LANES];
914            for i in 0..LANES {
915                let base = i * N;
916                ra[i] = chunk[base];
917                ga[i] = chunk[base + 1];
918                ba[i] = chunk[base + 2];
919                // Alpha (chunk[base + 3] when N==4) is ignored.
920            }
921            let r = f32x8::load(token, &ra);
922            let g = f32x8::load(token, &ga);
923            let b = f32x8::load(token, &ba);
924
925            // Tier-consistent NaN/negative fold: `v > 0` is an ORDERED
926            // compare — false for NaN, for negatives, and for zero on
927            // every tier — so the blend picks 0 for all three, matching
928            // the scalar tail's `max(0.0)` semantics exactly. A bare
929            // `zero.max(v)` chain is NOT tier-consistent for NaN input:
930            // x86 `maxps` returns the second operand while NEON/WASM
931            // propagate NaN, which zeroed MaxFALL (and could underreport
932            // MaxCLL) whenever any sample was NaN. Pinned by the
933            // wide-row NaN tests in tests/cll_measure.rs.
934            let r = f32x8::blend(r.simd_gt(zero), r, zero);
935            let g = f32x8::blend(g.simd_gt(zero), g, zero);
936            let b = f32x8::blend(b.simd_gt(zero), b, zero);
937            let m_rel = r.max(g).max(b);
938            let m_nits = m_rel * wn;
939
940            local_max = local_max.max(m_nits);
941            local_sum += m_nits;
942            chunks_since_flush += 1;
943            if chunks_since_flush == SUM_FLUSH_CHUNKS {
944                *sum_nits += f64::from(local_sum.reduce_add());
945                local_sum = zero;
946                chunks_since_flush = 0;
947            }
948
949            // SIMD log2 → bin index. Use `safe = max(m_nits, BIN_MIN_NITS)`
950            // so log2(0) doesn't underflow into NaN/-inf.
951            let safe = m_nits.max(bin_min_nits);
952            let log2 = safe.log2_midp();
953            // bin_f = ((log2 − log2_min) · inv_step), clamped to
954            // `[0, NUM_BINS − 1]` in SIMD before the scalar bin write.
955            let bin_f = ((log2 - log2_min) * inv_step)
956                .max(zero)
957                .min(num_bins_minus_1);
958
959            let nits_arr = m_nits.to_array();
960            let bin_arr = bin_f.to_array();
961            // 8 independent scatter writes — one per lane / sub-histogram.
962            // Lane `i` writes to `sub_hists[i]`, so no cross-lane
963            // conflict is possible.  Same-bin runs WITHIN one sub-
964            // histogram (smooth-tone content) still pay the load-add-
965            // store latency, which is the dominant remaining cost and
966            // the limit on throughput beyond what plain SIMD math gives.
967            for i in 0..LANES {
968                let bin = saturating_bin_scalar(nits_arr[i], bin_arr[i]);
969                sub_hists[i * LightLevelHistogram::NUM_BINS + bin] += 1;
970            }
971        }
972
973        // Reduce SIMD accumulators into the scalar running totals.
974        let row_max = local_max.reduce_max();
975        if row_max > *literal_max_nits {
976            *literal_max_nits = row_max;
977        }
978        *sum_nits += f64::from(local_sum.reduce_add());
979
980        // Scalar tail: pixels left over from the strip not divisible by
981        // `LANES * N`. Reuses the `bin_for_nits` helper from the scalar
982        // path to stay in lock-step with the scalar histogram's bin
983        // boundaries.
984        let remainder = iter.remainder();
985        for chunk in remainder.chunks_exact(N) {
986            let r = chunk[0].max(0.0);
987            let g = chunk[1].max(0.0);
988            let b = chunk[2].max(0.0);
989            let m_rel = r.max(g).max(b);
990            let m_nits = m_rel * white_nits;
991            if m_nits > *literal_max_nits {
992                *literal_max_nits = m_nits;
993            }
994            *sum_nits += f64::from(m_nits);
995            // Tail pixels land in sub_hists[0]; merging at the end sums
996            // all lanes so this is correct regardless of which lane the
997            // tail "lives" in.
998            sub_hists[bin_for_nits(m_nits)] += 1;
999        }
1000    }
1001
1002    /// Tiered SIMD kernel for the `LuminanceBt2020` reduction —
1003    /// `Y = 0.2627·R + 0.6780·G + 0.0593·B` (clamped non-negative).
1004    #[archmage::magetypes(define(f32x8), v3, neon, wasm128, scalar)]
1005    pub(crate) fn accumulate_strip_luma_bt2020_tier<const N: usize>(
1006        token: Token,
1007        row: &[f32],
1008        white_nits: f32,
1009        sub_hists: &mut [u32],
1010        sum_nits: &mut f64,
1011        literal_max_nits: &mut f32,
1012    ) {
1013        let zero = f32x8::zero(token);
1014        let wn = f32x8::splat(token, white_nits);
1015        let kr = f32x8::splat(token, KR);
1016        let kg = f32x8::splat(token, KG);
1017        let kb = f32x8::splat(token, KB);
1018        let log2_min = f32x8::splat(token, LightLevelHistogram::LOG2_MIN);
1019        let inv_step = f32x8::splat(token, LightLevelHistogram::inv_log2_step());
1020        let bin_min_nits = f32x8::splat(token, LightLevelHistogram::BIN_MIN_NITS);
1021        let num_bins_minus_1 = f32x8::splat(token, (LightLevelHistogram::NUM_BINS - 1) as f32);
1022
1023        let mut local_max = zero;
1024        // f32 lane sums flushed to f64 every `SUM_FLUSH_CHUNKS` chunks —
1025        // see `accumulate_strip_max_rgb_tier`.
1026        let mut local_sum = zero;
1027        let mut chunks_since_flush = 0u32;
1028
1029        let mut iter = row.chunks_exact(LANES * N);
1030        for chunk in &mut iter {
1031            let mut ra = [0.0_f32; LANES];
1032            let mut ga = [0.0_f32; LANES];
1033            let mut ba = [0.0_f32; LANES];
1034            for i in 0..LANES {
1035                let base = i * N;
1036                ra[i] = chunk[base];
1037                ga[i] = chunk[base + 1];
1038                ba[i] = chunk[base + 2];
1039            }
1040            // Tier-consistent NaN/negative fold — see the comment in
1041            // `accumulate_strip_max_rgb_tier`. A `.max(zero)` load fold
1042            // propagated NaN on NEON/WASM (and was order-dependent on
1043            // x86), poisoning the luminance dot product.
1044            let r = f32x8::load(token, &ra);
1045            let g = f32x8::load(token, &ga);
1046            let b = f32x8::load(token, &ba);
1047            let r = f32x8::blend(r.simd_gt(zero), r, zero);
1048            let g = f32x8::blend(g.simd_gt(zero), g, zero);
1049            let b = f32x8::blend(b.simd_gt(zero), b, zero);
1050
1051            let y_rel = kr * r + kg * g + kb * b;
1052            let y_nits = y_rel * wn;
1053
1054            local_max = local_max.max(y_nits);
1055            local_sum += y_nits;
1056            chunks_since_flush += 1;
1057            if chunks_since_flush == SUM_FLUSH_CHUNKS {
1058                *sum_nits += f64::from(local_sum.reduce_add());
1059                local_sum = zero;
1060                chunks_since_flush = 0;
1061            }
1062
1063            let safe = y_nits.max(bin_min_nits);
1064            let log2 = safe.log2_midp();
1065            let bin_f = ((log2 - log2_min) * inv_step)
1066                .max(zero)
1067                .min(num_bins_minus_1);
1068
1069            let nits_arr = y_nits.to_array();
1070            let bin_arr = bin_f.to_array();
1071            for i in 0..LANES {
1072                let bin = saturating_bin_scalar(nits_arr[i], bin_arr[i]);
1073                sub_hists[i * LightLevelHistogram::NUM_BINS + bin] += 1;
1074            }
1075        }
1076
1077        let row_max = local_max.reduce_max();
1078        if row_max > *literal_max_nits {
1079            *literal_max_nits = row_max;
1080        }
1081        *sum_nits += f64::from(local_sum.reduce_add());
1082
1083        let remainder = iter.remainder();
1084        for chunk in remainder.chunks_exact(N) {
1085            let r = chunk[0].max(0.0);
1086            let g = chunk[1].max(0.0);
1087            let b = chunk[2].max(0.0);
1088            let y_rel = KR * r + KG * g + KB * b;
1089            let y_nits = y_rel * white_nits;
1090            if y_nits > *literal_max_nits {
1091                *literal_max_nits = y_nits;
1092            }
1093            *sum_nits += f64::from(y_nits);
1094            sub_hists[bin_for_nits(y_nits)] += 1;
1095        }
1096    }
1097
1098    /// Saturating scalar bin index — same semantics as `bin_for_nits` in
1099    /// the parent module, but inlined here so the SIMD hot loop doesn't
1100    /// pay a function-call overhead.
1101    #[inline(always)]
1102    fn saturating_bin_scalar(nits: f32, bin_f: f32) -> usize {
1103        if nits <= LightLevelHistogram::BIN_MIN_NITS {
1104            return 0;
1105        }
1106        if nits >= LightLevelHistogram::BIN_MAX_NITS {
1107            return LightLevelHistogram::NUM_BINS - 1;
1108        }
1109        let b = bin_f as usize;
1110        if b >= LightLevelHistogram::NUM_BINS {
1111            LightLevelHistogram::NUM_BINS - 1
1112        } else {
1113            b
1114        }
1115    }
1116
1117    // ── SOTA fast-path: scan_max_mean (no histogram) ────────────────────
1118    //
1119    // For the spec-conformant CLL reading the caller only needs MaxCLL +
1120    // MaxFALL — the literal max and the arithmetic mean. The histogram
1121    // path's scatter step is wasted work. This pair of SIMD kernels
1122    // strips that out: per-pixel `max(R,G,B)` (or BT.2020 luma), running
1123    // max + sum reduced via `reduce_max` / `reduce_add`. No log2, no
1124    // bin index, no scatter. Returns `(max_rel, sum_rel)` per row; the
1125    // caller scales by `white.nits()` at end-of-image.
1126
1127    /// Top-level dispatcher for the fast measure_max path.
1128    /// Loops rows and calls the right per-method tier kernel.
1129    #[allow(clippy::too_many_arguments)]
1130    pub(super) fn scan_max_mean_simd(
1131        bytes: &[u8],
1132        h: usize,
1133        stride: usize,
1134        channels: usize,
1135        row_len: usize,
1136        method: LightLevelMethod,
1137    ) -> (f32, f64) {
1138        let mut max_rel = 0.0_f32;
1139        let mut sum_rel = 0.0_f64;
1140        for row in 0..h {
1141            let row_bytes = &bytes[row * stride..row * stride + row_len];
1142            let floats: &[f32] = bytemuck::cast_slice(row_bytes);
1143            let (rm, rs) = match method {
1144                LightLevelMethod::MaxRgb => {
1145                    if channels == 3 {
1146                        let mut rm = 0.0_f32;
1147                        let mut rs = 0.0_f64;
1148                        archmage::incant!(
1149                            scan_row_max_rgb_tier::<3>(floats, &mut rm, &mut rs),
1150                            [v3, neon, wasm128, scalar]
1151                        );
1152                        (rm, rs)
1153                    } else {
1154                        let mut rm = 0.0_f32;
1155                        let mut rs = 0.0_f64;
1156                        archmage::incant!(
1157                            scan_row_max_rgb_tier::<4>(floats, &mut rm, &mut rs),
1158                            [v3, neon, wasm128, scalar]
1159                        );
1160                        (rm, rs)
1161                    }
1162                }
1163                LightLevelMethod::LuminanceBt2020 => {
1164                    if channels == 3 {
1165                        let mut rm = 0.0_f32;
1166                        let mut rs = 0.0_f64;
1167                        archmage::incant!(
1168                            scan_row_luma_bt2020_tier::<3>(floats, &mut rm, &mut rs),
1169                            [v3, neon, wasm128, scalar]
1170                        );
1171                        (rm, rs)
1172                    } else {
1173                        let mut rm = 0.0_f32;
1174                        let mut rs = 0.0_f64;
1175                        archmage::incant!(
1176                            scan_row_luma_bt2020_tier::<4>(floats, &mut rm, &mut rs),
1177                            [v3, neon, wasm128, scalar]
1178                        );
1179                        (rm, rs)
1180                    }
1181                }
1182            };
1183            max_rel = max_rel.max(rm);
1184            sum_rel += rs;
1185        }
1186        (max_rel, sum_rel)
1187    }
1188
1189    /// Tiered SIMD scan for the `MaxRgb` reduction. Per-pixel
1190    /// `max(0, R, G, B)`, accumulated into a SIMD running max and a
1191    /// SIMD running sum, reduced once per row to scalar. No histogram
1192    /// store — this is the gigapixel-class hot loop.
1193    #[archmage::magetypes(define(f32x8), v3, neon, wasm128, scalar)]
1194    pub(crate) fn scan_row_max_rgb_tier<const N: usize>(
1195        token: Token,
1196        row: &[f32],
1197        row_max_rel: &mut f32,
1198        row_sum_rel: &mut f64,
1199    ) {
1200        let zero = f32x8::zero(token);
1201        let mut local_max = zero;
1202        // f32 lane sums flushed to f64 every `SUM_FLUSH_CHUNKS` chunks —
1203        // see `accumulate_strip_max_rgb_tier`.
1204        let mut local_sum = zero;
1205        let mut chunks_since_flush = 0u32;
1206
1207        let mut iter = row.chunks_exact(LANES * N);
1208        for chunk in &mut iter {
1209            let mut ra = [0.0_f32; LANES];
1210            let mut ga = [0.0_f32; LANES];
1211            let mut ba = [0.0_f32; LANES];
1212            for i in 0..LANES {
1213                let base = i * N;
1214                ra[i] = chunk[base];
1215                ga[i] = chunk[base + 1];
1216                ba[i] = chunk[base + 2];
1217            }
1218            // Tier-consistent NaN/negative fold — see the comment in
1219            // `accumulate_strip_max_rgb_tier`.
1220            let r = f32x8::load(token, &ra);
1221            let g = f32x8::load(token, &ga);
1222            let b = f32x8::load(token, &ba);
1223            let r = f32x8::blend(r.simd_gt(zero), r, zero);
1224            let g = f32x8::blend(g.simd_gt(zero), g, zero);
1225            let b = f32x8::blend(b.simd_gt(zero), b, zero);
1226            let m = r.max(g).max(b);
1227            local_max = local_max.max(m);
1228            local_sum += m;
1229            chunks_since_flush += 1;
1230            if chunks_since_flush == SUM_FLUSH_CHUNKS {
1231                *row_sum_rel += f64::from(local_sum.reduce_add());
1232                local_sum = zero;
1233                chunks_since_flush = 0;
1234            }
1235        }
1236
1237        *row_max_rel = local_max.reduce_max().max(*row_max_rel);
1238        *row_sum_rel += f64::from(local_sum.reduce_add());
1239
1240        // Scalar tail.
1241        for chunk in iter.remainder().chunks_exact(N) {
1242            let m = 0.0_f32.max(chunk[0]).max(chunk[1]).max(chunk[2]);
1243            if m > *row_max_rel {
1244                *row_max_rel = m;
1245            }
1246            *row_sum_rel += f64::from(m);
1247        }
1248    }
1249
1250    /// Tiered SIMD scan for the `LuminanceBt2020` reduction.
1251    #[archmage::magetypes(define(f32x8), v3, neon, wasm128, scalar)]
1252    pub(crate) fn scan_row_luma_bt2020_tier<const N: usize>(
1253        token: Token,
1254        row: &[f32],
1255        row_max_rel: &mut f32,
1256        row_sum_rel: &mut f64,
1257    ) {
1258        let zero = f32x8::zero(token);
1259        let kr = f32x8::splat(token, KR);
1260        let kg = f32x8::splat(token, KG);
1261        let kb = f32x8::splat(token, KB);
1262
1263        let mut local_max = zero;
1264        // f32 lane sums flushed to f64 every `SUM_FLUSH_CHUNKS` chunks —
1265        // see `accumulate_strip_max_rgb_tier`.
1266        let mut local_sum = zero;
1267        let mut chunks_since_flush = 0u32;
1268
1269        let mut iter = row.chunks_exact(LANES * N);
1270        for chunk in &mut iter {
1271            let mut ra = [0.0_f32; LANES];
1272            let mut ga = [0.0_f32; LANES];
1273            let mut ba = [0.0_f32; LANES];
1274            for i in 0..LANES {
1275                let base = i * N;
1276                ra[i] = chunk[base];
1277                ga[i] = chunk[base + 1];
1278                ba[i] = chunk[base + 2];
1279            }
1280            // Tier-consistent NaN/negative fold — see the comment in
1281            // `accumulate_strip_max_rgb_tier`.
1282            let r = f32x8::load(token, &ra);
1283            let g = f32x8::load(token, &ga);
1284            let b = f32x8::load(token, &ba);
1285            let r = f32x8::blend(r.simd_gt(zero), r, zero);
1286            let g = f32x8::blend(g.simd_gt(zero), g, zero);
1287            let b = f32x8::blend(b.simd_gt(zero), b, zero);
1288            let y = kr * r + kg * g + kb * b;
1289            local_max = local_max.max(y);
1290            local_sum += y;
1291            chunks_since_flush += 1;
1292            if chunks_since_flush == SUM_FLUSH_CHUNKS {
1293                *row_sum_rel += f64::from(local_sum.reduce_add());
1294                local_sum = zero;
1295                chunks_since_flush = 0;
1296            }
1297        }
1298
1299        *row_max_rel = local_max.reduce_max().max(*row_max_rel);
1300        *row_sum_rel += f64::from(local_sum.reduce_add());
1301
1302        // Scalar tail.
1303        for chunk in iter.remainder().chunks_exact(N) {
1304            let r = chunk[0].max(0.0);
1305            let g = chunk[1].max(0.0);
1306            let b = chunk[2].max(0.0);
1307            let y = KR * r + KG * g + KB * b;
1308            if y > *row_max_rel {
1309                *row_max_rel = y;
1310            }
1311            *row_sum_rel += f64::from(y);
1312        }
1313    }
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319    use alloc::vec::Vec;
1320    use zenpixels::{PixelBuffer, PixelDescriptor};
1321
1322    fn rgbf32(pixels: &[[f32; 3]], w: u32, h: u32) -> PixelBuffer {
1323        let mut data = Vec::with_capacity(pixels.len() * 12);
1324        for p in pixels {
1325            for c in p {
1326                data.extend_from_slice(&c.to_ne_bytes());
1327            }
1328        }
1329        PixelBuffer::from_vec(data, w, h, PixelDescriptor::RGBF32_LINEAR).unwrap()
1330    }
1331
1332    // ── Histogram primitive sanity ──────────────────────────────────────
1333
1334    #[test]
1335    fn fast_log2_round_trips_through_fast_exp2_at_bin_edges() {
1336        // The percentile readout uses `fast_exp2(LOG2_MIN + i / inv_step)`
1337        // to recover the bin's lower-edge cd/m². Pin the round-trip
1338        // accuracy: at any bin edge the result should land within one
1339        // bin-width's relative tolerance of the canonical value.
1340        let inv_step = LightLevelHistogram::inv_log2_step();
1341        for &i in &[0_usize, 1, 100, 500, 1023] {
1342            let log2_edge = LightLevelHistogram::LOG2_MIN + (i as f32) / inv_step;
1343            let recovered = fast_exp2(log2_edge);
1344            // Verify against the f64 reference via the bit-trick identity.
1345            let want = libm_pow2_oracle(f64::from(log2_edge));
1346            let rel = (f64::from(recovered) - want).abs() / want;
1347            assert!(
1348                rel < 0.005,
1349                "bin {i}: fast_exp2 mismatch: got {recovered} want {want}"
1350            );
1351        }
1352    }
1353
1354    /// Independent f64 oracle for `2^x` — we don't have libm in the
1355    /// crate but we do have `f64::powi` / std `f64::exp2`.
1356    #[cfg(feature = "std")]
1357    fn libm_pow2_oracle(x: f64) -> f64 {
1358        x.exp2()
1359    }
1360    /// no_std fallback oracle: split into integer/fraction, multiply.
1361    /// Less accurate than std's `exp2` but plenty for the bin-width
1362    /// tolerance the test demands.
1363    #[cfg(not(feature = "std"))]
1364    fn libm_pow2_oracle(x: f64) -> f64 {
1365        let i = x.floor() as i32;
1366        let f = x - (i as f64);
1367        let pf = 1.0
1368            + f * (0.693_147_180_559_945_3
1369                + f * (0.240_226_506_959_100_7 + f * 0.055_504_108_664_821_58));
1370        let two_i = (1u64 << (i + 1023)) as f64 / (1u64 << 1023) as f64;
1371        two_i * pf
1372    }
1373
1374    #[test]
1375    fn measure_histogram_empty_input_returns_zero_readouts() {
1376        // Zero-area input is well-defined: total=0, all readouts are 0.
1377        // `PixelSlice` requires the byte view to satisfy the f32 alignment
1378        // even for zero rows, so route the empty case through an aligned
1379        // `&[f32]` (`Vec<f32>` is f32-aligned) cast to bytes.
1380        let owned: Vec<f32> = Vec::new();
1381        let bytes: &[u8] = bytemuck::cast_slice(&owned);
1382        let px = PixelSlice::new(bytes, 1, 0, 12, PixelDescriptor::RGBF32_LINEAR).unwrap();
1383        let h = <ContentLightLevel as CllMeasure>::measure_histogram(
1384            px,
1385            DiffuseWhite::BT2408,
1386            LightLevelMethod::MaxRgb,
1387        )
1388        .unwrap();
1389        assert_eq!(h.total_pixels(), 0);
1390        assert_eq!(h.max(), 0.0);
1391        assert_eq!(h.mean(), 0.0);
1392        assert_eq!(h.percentile(0.5), 0.0);
1393    }
1394
1395    #[test]
1396    fn measure_max_matches_cta_literal_spec() {
1397        // CTA-861.3 strict: MaxCLL = largest per-pixel max(R,G,B) ·
1398        // white_nits, MaxFALL = mean of same. Pin against the same
1399        // values the legacy deprecated `measure` returns.
1400        let buf = rgbf32(&[[1.0; 3], [2.0; 3]], 2, 1);
1401        let cll = <ContentLightLevel as CllMeasure>::measure_max(
1402            buf.as_slice(),
1403            DiffuseWhite::BT2408,
1404            LightLevelMethod::MaxRgb,
1405        )
1406        .unwrap();
1407        assert_eq!(cll.max_content_light_level, 406);
1408        assert_eq!(cll.max_frame_average_light_level, 305);
1409    }
1410
1411    #[test]
1412    fn measure_max_luminance_bt2020_method_uses_luma_weights() {
1413        // Pure red @ 1.0 with BT.2020 luma: Y = 0.2627 · 1.0 = 0.2627
1414        // → 0.2627 · 203 = 53.3279 → rounds to 53.
1415        let buf = rgbf32(&[[1.0, 0.0, 0.0]], 1, 1);
1416        let cll = <ContentLightLevel as CllMeasure>::measure_max(
1417            buf.as_slice(),
1418            DiffuseWhite::BT2408,
1419            LightLevelMethod::LuminanceBt2020,
1420        )
1421        .unwrap();
1422        assert_eq!(cll.max_content_light_level, 53);
1423        assert_eq!(cll.max_frame_average_light_level, 53);
1424        // MaxRgb on the same input picks 1.0 → 203.
1425        let cll_max_rgb = <ContentLightLevel as CllMeasure>::measure_max(
1426            buf.as_slice(),
1427            DiffuseWhite::BT2408,
1428            LightLevelMethod::MaxRgb,
1429        )
1430        .unwrap();
1431        assert_eq!(cll_max_rgb.max_content_light_level, 203);
1432    }
1433
1434    #[test]
1435    fn defect_spike_percentile_drops_lone_outlier() {
1436        // Synthetic defect: 10×10 = 100 pixels at 0.5 (= 101.5 nits) plus
1437        // ONE stuck/specular pixel at 50.0 (= 10 150 nits, then saturated
1438        // to BIN_MAX_NITS = 10 000). Spec-literal MaxCLL pins to 10 000
1439        // (saturating-bin clipped from 10 150). p99.99 (drop the top
1440        // 0.01% = 0.01 pixels rounded down → drops the spike since the
1441        // threshold lands strictly below 100) returns the background
1442        // ~101.5. This is the defect-rejection use case.
1443        let mut pixels = alloc::vec![[0.5_f32; 3]; 100];
1444        pixels[0] = [50.0; 3]; // the outlier
1445        let buf = rgbf32(&pixels, 10, 10);
1446
1447        let lit = <ContentLightLevel as CllMeasure>::measure_max(
1448            buf.as_slice(),
1449            DiffuseWhite::BT2408,
1450            LightLevelMethod::MaxRgb,
1451        )
1452        .unwrap();
1453        // Spec-literal preserves the spike (saturated to the bin range).
1454        assert!(
1455            lit.max_content_light_level >= 9000,
1456            "defect spike: spec literal MaxCLL = {} (expected near 10000)",
1457            lit.max_content_light_level
1458        );
1459
1460        // p99 drops the top 1% (~1 pixel) — the spike goes; background ≈ 101.
1461        let pct = <ContentLightLevel as CllMeasure>::measure_percentile(
1462            buf.as_slice(),
1463            DiffuseWhite::BT2408,
1464            0.99,
1465            LightLevelMethod::MaxRgb,
1466        )
1467        .unwrap();
1468        assert!(
1469            pct.max_content_light_level < 200,
1470            "p99 should drop the lone defect: got {}",
1471            pct.max_content_light_level
1472        );
1473    }
1474
1475    #[test]
1476    fn night_stars_literal_max_preserves_sparse_bright_content() {
1477        // Astrophotography case (issue #54 motivating example): 1100
1478        // pixels total — 1000 dark-sky at 0.005 and 100 "stars" at 5.0.
1479        // Spec-literal MaxCLL keeps the stars visible; a fixed-percentile
1480        // API at p < 91% would silently clip them, exactly the failure
1481        // mode the issue calls out.
1482        let mut pixels: Vec<[f32; 3]> = alloc::vec![[0.005_f32; 3]; 1100];
1483        for star in pixels.iter_mut().take(100) {
1484            *star = [5.0; 3];
1485        }
1486        let buf = rgbf32(&pixels, 100, 11); // 100 × 11 = 1100 pixels total
1487
1488        // Spec-literal preserves the stars at ~1015 nits.
1489        let lit = <ContentLightLevel as CllMeasure>::measure_max(
1490            buf.as_slice(),
1491            DiffuseWhite::BT2408,
1492            LightLevelMethod::MaxRgb,
1493        )
1494        .unwrap();
1495        assert!(
1496            lit.max_content_light_level > 900 && lit.max_content_light_level < 1100,
1497            "night stars: spec literal MaxCLL = {} (expected near 1015)",
1498            lit.max_content_light_level
1499        );
1500
1501        // p99.99 also keeps them (only 0.01% = 0.11 pixels → 0 pixels
1502        // dropped, full literal max preserved through the percentile).
1503        let pct_high = <ContentLightLevel as CllMeasure>::measure_percentile(
1504            buf.as_slice(),
1505            DiffuseWhite::BT2408,
1506            0.9999,
1507            LightLevelMethod::MaxRgb,
1508        )
1509        .unwrap();
1510        assert!(
1511            pct_high.max_content_light_level > 900,
1512            "p99.99 must keep the stars (none are defects): got {}",
1513            pct_high.max_content_light_level
1514        );
1515
1516        // A naive caller picking p90 would drop the stars (the threshold
1517        // is at the 990th pixel, which is in the dark-sky region). This
1518        // is the failure mode a fixed-percentile API would silently
1519        // create — we let the caller choose so they make the call
1520        // explicitly.
1521        let pct_low = <ContentLightLevel as CllMeasure>::measure_percentile(
1522            buf.as_slice(),
1523            DiffuseWhite::BT2408,
1524            0.90,
1525            LightLevelMethod::MaxRgb,
1526        )
1527        .unwrap();
1528        assert!(
1529            pct_low.max_content_light_level < 100,
1530            "p90 demonstrably loses sparse-bright content: got {}",
1531            pct_low.max_content_light_level
1532        );
1533    }
1534
1535    #[test]
1536    fn percentile_zero_and_one_are_well_defined() {
1537        let buf = rgbf32(&[[0.0; 3], [0.5; 3], [1.0; 3]], 3, 1);
1538        let h = <ContentLightLevel as CllMeasure>::measure_histogram(
1539            buf.as_slice(),
1540            DiffuseWhite::BT2408,
1541            LightLevelMethod::MaxRgb,
1542        )
1543        .unwrap();
1544        // p=1.0 → spec-literal max, exact.
1545        assert!((h.percentile(1.0) - 203.0).abs() < 0.01);
1546        // p=0.0 → 0 (matches the documented contract).
1547        assert_eq!(h.percentile(0.0), 0.0);
1548    }
1549
1550    #[test]
1551    fn percentile_interpolates_within_bin_when_threshold_lands_high() {
1552        // 10 000 pixels all at 5.0 (= 1015 nits exactly). The literal max
1553        // is 1015 — with linear interpolation the percentile readout at
1554        // p=0.9999 should land near the literal max (one bin ≈ 0.02 stops
1555        // wide; the threshold lands 99.99 % of the way through the bin,
1556        // putting the interpolated value within ~0.01 stops of the max).
1557        // Naïve floor-of-bin would read ≈ 1002 (one bin below).
1558        let buf = rgbf32(&[[5.0_f32; 3]; 10_000], 100, 100);
1559        let h = <ContentLightLevel as CllMeasure>::measure_histogram(
1560            buf.as_slice(),
1561            DiffuseWhite::BT2408,
1562            LightLevelMethod::MaxRgb,
1563        )
1564        .unwrap();
1565        let p = h.percentile(0.9999);
1566        // Allow [1010, 1015]: interpolation never overshoots the literal
1567        // max (cap inside `percentile`) and lands within one nit at this
1568        // density.
1569        assert!(
1570            (1010.0..=1015.0).contains(&p),
1571            "p99.99 interpolated within bin: expected ≈1015, got {p}"
1572        );
1573    }
1574
1575    #[test]
1576    fn percentile_interpolation_never_exceeds_literal_max() {
1577        // Single bin gets the threshold-1 pixel inside it; with
1578        // interpolation the readout could round above `literal_max_nits`
1579        // if not capped. Pin the cap.
1580        let buf = rgbf32(&[[5.0; 3]; 1000], 100, 10);
1581        let h = <ContentLightLevel as CllMeasure>::measure_histogram(
1582            buf.as_slice(),
1583            DiffuseWhite::BT2408,
1584            LightLevelMethod::MaxRgb,
1585        )
1586        .unwrap();
1587        for &p in &[0.5_f32, 0.9, 0.95, 0.99, 0.999, 0.9999, 0.99999] {
1588            let v = h.percentile(p);
1589            assert!(
1590                v <= h.max() + 1e-3,
1591                "p={p}: percentile {v} must not exceed literal max {}",
1592                h.max()
1593            );
1594        }
1595    }
1596
1597    #[test]
1598    fn percentile_interpolation_beats_floor_precision_on_dense_content() {
1599        // 1 MP image of pure 5.0 (= 1015 nits). Floor-of-bin would
1600        // undershoot the literal by ~13 nits (~2 % = one log2 bin width
1601        // at this brightness). Interpolation should report within ~1 nit
1602        // of literal.
1603        let pixels: Vec<[f32; 3]> = alloc::vec![[5.0_f32; 3]; 1024 * 1024];
1604        let buf = rgbf32(&pixels, 1024, 1024);
1605        let h = <ContentLightLevel as CllMeasure>::measure_histogram(
1606            buf.as_slice(),
1607            DiffuseWhite::BT2408,
1608            LightLevelMethod::MaxRgb,
1609        )
1610        .unwrap();
1611        let p = h.percentile(0.9999);
1612        assert!(
1613            (p - h.max()).abs() < 2.0,
1614            "1 MP solid: interpolated p99.99 = {p}, literal max = {} \
1615             (expected within ~1 nit; floor-of-bin would be ~1002)",
1616            h.max()
1617        );
1618    }
1619
1620    #[test]
1621    fn percentile_clamps_nan_and_out_of_range_inputs() {
1622        let buf = rgbf32(&[[0.5; 3]], 1, 1);
1623        let h = <ContentLightLevel as CllMeasure>::measure_histogram(
1624            buf.as_slice(),
1625            DiffuseWhite::BT2408,
1626            LightLevelMethod::MaxRgb,
1627        )
1628        .unwrap();
1629        assert_eq!(h.percentile(f32::NAN), 0.0); // NaN → 0 per doc
1630        assert!(h.percentile(2.0) > 0.0); // > 1.0 clamps to literal max
1631        assert_eq!(h.percentile(-0.5), 0.0); // < 0 clamps to 0
1632    }
1633
1634    #[test]
1635    fn measure_histogram_rejects_non_linear_or_non_rgb_f32() {
1636        // Non-Linear transfer: rejected.
1637        let desc = PixelDescriptor::RGBF32_LINEAR.with_transfer(TransferFunction::Srgb);
1638        let mut data = Vec::new();
1639        for c in [0.5_f32; 3] {
1640            data.extend_from_slice(&c.to_ne_bytes());
1641        }
1642        let buf = PixelBuffer::from_vec(data, 1, 1, desc).unwrap();
1643        assert!(
1644            <ContentLightLevel as CllMeasure>::measure_histogram(
1645                buf.as_slice(),
1646                DiffuseWhite::BT2408,
1647                LightLevelMethod::MaxRgb,
1648            )
1649            .is_none()
1650        );
1651        // Non-f32 format: rejected.
1652        let desc = PixelDescriptor::RGB8_SRGB;
1653        let buf = PixelBuffer::from_vec(alloc::vec![0u8; 3], 1, 1, desc).unwrap();
1654        assert!(
1655            <ContentLightLevel as CllMeasure>::measure_histogram(
1656                buf.as_slice(),
1657                DiffuseWhite::BT2408,
1658                LightLevelMethod::MaxRgb,
1659            )
1660            .is_none()
1661        );
1662    }
1663
1664    #[test]
1665    fn histogram_bins_exposed_and_sum_to_total() {
1666        let buf = rgbf32(&[[0.1; 3], [0.5; 3], [1.0; 3], [2.0; 3], [10.0; 3]], 5, 1);
1667        let h = <ContentLightLevel as CllMeasure>::measure_histogram(
1668            buf.as_slice(),
1669            DiffuseWhite::BT2408,
1670            LightLevelMethod::MaxRgb,
1671        )
1672        .unwrap();
1673        let bin_total: u64 = h.bins().iter().map(|&c| c as u64).sum();
1674        assert_eq!(bin_total, h.total_pixels());
1675        assert_eq!(h.total_pixels(), 5);
1676        assert_eq!(h.method(), LightLevelMethod::MaxRgb);
1677    }
1678
1679    // ── Industry-standard accuracy parity ──────────────────────────────
1680
1681    /// Independent f64 oracle for CTA-861.3-A `MaxCLL` + `MaxFALL` —
1682    /// the Psychtoolbox-3 / x265 / libplacebo / Dolby Vision L1
1683    /// formula, restated here in plain f64 so we can pin our
1684    /// implementation against an unambiguous reference.
1685    ///
1686    /// Per `ComputeHDRStaticMetadataType1ContentLightLevels.m`
1687    /// (Psychtoolbox / Mario Kleiner): for each pixel, `light =
1688    /// max(R, G, B)` in cd/m² (after the relative-linear scale ×
1689    /// white_nits anchor). `MaxCLL` = max over all light values;
1690    /// `MaxFALL` = arithmetic mean. Same formula appears in x265's
1691    /// `analyze_src_pics`, in libplacebo's `pl_hdr_metadata_max_cll`,
1692    /// and in `libultrahdr`'s `MaxRGB` reduction.
1693    fn psychtoolbox_oracle_max_rgb(pixels: &[[f32; 3]], white_nits: f32) -> (f64, f64) {
1694        let mut max_nits = 0.0_f64;
1695        let mut sum_nits = 0.0_f64;
1696        for px in pixels {
1697            // Clamp negatives + NaN to 0 (matches our `0.0.max(…)` chain
1698            // and the implicit non-negativity assumption in the spec).
1699            let r = (px[0] as f64).max(0.0);
1700            let g = (px[1] as f64).max(0.0);
1701            let b = (px[2] as f64).max(0.0);
1702            let m_rel = r.max(g).max(b);
1703            let m_nits = m_rel * (white_nits as f64);
1704            if m_nits > max_nits {
1705                max_nits = m_nits;
1706            }
1707            sum_nits += m_nits;
1708        }
1709        let mean_nits = sum_nits / (pixels.len() as f64);
1710        (max_nits, mean_nits)
1711    }
1712
1713    /// BT.2020 NCL luma oracle (the alternate Netflix / Apple TV+
1714    /// pipeline reading; same general shape but uses the BT.2020
1715    /// luminance coefficients).
1716    fn psychtoolbox_oracle_luma_bt2020(pixels: &[[f32; 3]], white_nits: f32) -> (f64, f64) {
1717        let mut max_nits = 0.0_f64;
1718        let mut sum_nits = 0.0_f64;
1719        for px in pixels {
1720            let r = (px[0] as f64).max(0.0);
1721            let g = (px[1] as f64).max(0.0);
1722            let b = (px[2] as f64).max(0.0);
1723            let y = 0.2627 * r + 0.6780 * g + 0.0593 * b;
1724            let y_nits = y * (white_nits as f64);
1725            if y_nits > max_nits {
1726                max_nits = y_nits;
1727            }
1728            sum_nits += y_nits;
1729        }
1730        let mean_nits = sum_nits / (pixels.len() as f64);
1731        (max_nits, mean_nits)
1732    }
1733
1734    #[test]
1735    fn measure_max_matches_psychtoolbox_oracle_small_image() {
1736        // Hand-picked pixels covering: opaque saturated colours, dark
1737        // shadow, near-black, mid-grey, HDR specular peak. The mix
1738        // exercises both the running-max and the f64 sum precision.
1739        let pixels: Vec<[f32; 3]> = alloc::vec![
1740            [1.0, 0.0, 0.0],    // pure red
1741            [0.0, 1.0, 0.0],    // pure green
1742            [0.0, 0.0, 1.0],    // pure blue
1743            [0.5, 0.5, 0.5],    // mid grey
1744            [0.0; 3],           // black
1745            [3.0, 2.5, 4.0],    // HDR specular
1746            [0.18; 3],          // 18% middle grey
1747            [0.95, 0.85, 0.05]  // saturated warm
1748        ];
1749        let buf = rgbf32(&pixels, pixels.len() as u32, 1);
1750        let cll = <ContentLightLevel as CllMeasure>::measure_max(
1751            buf.as_slice(),
1752            DiffuseWhite::BT2408,
1753            LightLevelMethod::MaxRgb,
1754        )
1755        .unwrap();
1756
1757        let (oracle_max, oracle_mean) =
1758            psychtoolbox_oracle_max_rgb(&pixels, DiffuseWhite::BT2408.nits());
1759        let want_max = nits_to_u16(oracle_max);
1760        let want_fall = nits_to_u16(oracle_mean);
1761        assert_eq!(cll.max_content_light_level, want_max);
1762        assert_eq!(cll.max_frame_average_light_level, want_fall);
1763    }
1764
1765    #[test]
1766    fn measure_max_luma_bt2020_matches_oracle() {
1767        // Pure red @ 1.0 with BT.2020 luma: Y = 0.2627 → 53.3279 nits.
1768        // Verify both the MaxRgb and the LuminanceBt2020 methods'
1769        // outputs match their respective oracles for an explicit
1770        // hand-checked answer.
1771        let pixels: Vec<[f32; 3]> = alloc::vec![[1.0, 0.0, 0.0], [0.5, 0.5, 0.5], [2.0, 2.0, 2.0],];
1772        let buf = rgbf32(&pixels, pixels.len() as u32, 1);
1773        let cll = <ContentLightLevel as CllMeasure>::measure_max(
1774            buf.as_slice(),
1775            DiffuseWhite::BT2408,
1776            LightLevelMethod::LuminanceBt2020,
1777        )
1778        .unwrap();
1779        let (oracle_max, oracle_mean) =
1780            psychtoolbox_oracle_luma_bt2020(&pixels, DiffuseWhite::BT2408.nits());
1781        assert_eq!(cll.max_content_light_level, nits_to_u16(oracle_max));
1782        assert_eq!(cll.max_frame_average_light_level, nits_to_u16(oracle_mean));
1783    }
1784
1785    #[test]
1786    fn measure_max_matches_oracle_at_strided_4mp_with_high_dr_outlier() {
1787        // 4 MP-scale image: 2048 × 2048 pixels, deterministic per-pixel
1788        // content + one HDR outlier pixel. Verifies both:
1789        //   (a) the SIMD f64 sum stays in lock-step with the f64 oracle
1790        //       across millions of pixels (precision check), AND
1791        //   (b) the literal max picks up the outlier exactly (bit-exact
1792        //       via the `literal_max_nits` accumulator — no histogram
1793        //       quantisation).
1794        const W: u32 = 2048;
1795        const H: u32 = 2048;
1796        let total = (W as usize) * (H as usize);
1797        let mut pixels: Vec<[f32; 3]> = Vec::with_capacity(total);
1798        for i in 0..total {
1799            let t = (i as f32) / (total as f32);
1800            pixels.push([t * 1.5, (1.0 - t) * 1.5, 0.5 + 0.25 * t]);
1801        }
1802        // One specular peak that strictly exceeds the smooth ramp.
1803        pixels[(W as usize) * (H as usize) / 2] = [25.0; 3];
1804
1805        let buf = rgbf32(&pixels, W, H);
1806        let cll = <ContentLightLevel as CllMeasure>::measure_max(
1807            buf.as_slice(),
1808            DiffuseWhite::BT2408,
1809            LightLevelMethod::MaxRgb,
1810        )
1811        .unwrap();
1812
1813        let (oracle_max, oracle_mean) =
1814            psychtoolbox_oracle_max_rgb(&pixels, DiffuseWhite::BT2408.nits());
1815        // MaxCLL is saturating (u16 caps at 65535). 25.0 × 203 = 5075,
1816        // well below saturation — the test will catch a bin-quantisation
1817        // bug if any sneaks in.
1818        assert_eq!(cll.max_content_light_level, nits_to_u16(oracle_max));
1819        // MaxFALL: allow ±1 u16 code for rounding (f64 → f32 → f64 path
1820        // accumulated across 4 M pixels has microscopic drift).
1821        let want_fall = nits_to_u16(oracle_mean);
1822        let diff = (cll.max_frame_average_light_level as i32 - want_fall as i32).abs();
1823        assert!(
1824            diff <= 1,
1825            "MaxFALL u16 diverged: got {} want {} (oracle f64={:.4})",
1826            cll.max_frame_average_light_level,
1827            want_fall,
1828            oracle_mean
1829        );
1830    }
1831
1832    #[test]
1833    fn measure_max_and_measure_histogram_max_agree_bit_exact() {
1834        // The histogram path's `LightLevelHistogram::max()` returns
1835        // `literal_max_nits` (the bit-exact running max, not the
1836        // bin-quantised lookup). The fast `measure_max` path uses
1837        // the same f32 max accumulator under the hood. The two
1838        // values MUST be identical regardless of input — pin it.
1839        let pixels: Vec<[f32; 3]> = alloc::vec![
1840            [0.1, 0.2, 0.3],
1841            [1.5, 0.5, 0.25],
1842            [0.0, 3.0, 0.5],
1843            [0.7, 0.7, 0.7],
1844        ];
1845        let buf = rgbf32(&pixels, pixels.len() as u32, 1);
1846        let via_max = <ContentLightLevel as CllMeasure>::measure_max(
1847            buf.as_slice(),
1848            DiffuseWhite::BT2408,
1849            LightLevelMethod::MaxRgb,
1850        )
1851        .unwrap();
1852        let via_hist = measure_max_via_histogram_for_test(
1853            buf.as_slice(),
1854            DiffuseWhite::BT2408,
1855            LightLevelMethod::MaxRgb,
1856        )
1857        .unwrap();
1858        assert_eq!(
1859            via_max.max_content_light_level,
1860            via_hist.max_content_light_level
1861        );
1862        assert_eq!(
1863            via_max.max_frame_average_light_level,
1864            via_hist.max_frame_average_light_level
1865        );
1866    }
1867
1868    // ── measure_max_smoothed (3×1 horizontal box filter) ─────────────────
1869
1870    #[test]
1871    fn measure_max_smoothed_suppresses_single_pixel_defect() {
1872        // 10-wide row, one stuck/specular pixel at column 5 = [50, 0, 0]
1873        // (= 10 150 nits, saturated to BIN_MAX_NITS = 10 000 for the
1874        // histogram path; the smoothed path keeps the raw f32 max-of-3
1875        // chain so the un-saturated 50.0 × 203 / 3 ≈ 3 383 nits shows up
1876        // after the box filter — that's the whole point).
1877        let mut pixels = alloc::vec![[0.0_f32; 3]; 10];
1878        pixels[5] = [50.0; 3];
1879        let buf = rgbf32(&pixels, 10, 1);
1880
1881        // Spec-literal max keeps the spike (saturated at 10 000).
1882        let lit = <ContentLightLevel as CllMeasure>::measure_max(
1883            buf.as_slice(),
1884            DiffuseWhite::BT2408,
1885            LightLevelMethod::MaxRgb,
1886        )
1887        .unwrap();
1888        assert!(
1889            lit.max_content_light_level >= 9000,
1890            "control: spec-literal keeps the spike, got {}",
1891            lit.max_content_light_level
1892        );
1893
1894        // Smoothed max replaces m[5]=50 with (m[4]+m[5]+m[6])/3 = 50/3
1895        // ≈ 16.667. × 203 = 3 383.3, rounds to 3 383.
1896        let sm = <ContentLightLevel as CllMeasure>::measure_max_smoothed(
1897            buf.as_slice(),
1898            DiffuseWhite::BT2408,
1899            LightLevelMethod::MaxRgb,
1900        )
1901        .unwrap();
1902        let expected = (50.0_f64 / 3.0) * 203.0; // ≈ 3 383.3
1903        let got = f64::from(sm.max_content_light_level);
1904        assert!(
1905            (got - expected).abs() < 2.0,
1906            "3×1 mean of [0, 50, 0] = 50/3 → {expected:.1} nits, got {got}"
1907        );
1908    }
1909
1910    #[test]
1911    fn measure_max_smoothed_preserves_three_pixel_cluster() {
1912        // 10-wide row, 3 adjacent pixels at 5.0 (centered around column 5).
1913        // Mean of [5, 5, 5] = 5 → spike preserved at full magnitude.
1914        let mut pixels = alloc::vec![[0.0_f32; 3]; 10];
1915        pixels[4] = [5.0; 3];
1916        pixels[5] = [5.0; 3];
1917        pixels[6] = [5.0; 3];
1918        let buf = rgbf32(&pixels, 10, 1);
1919
1920        let sm = <ContentLightLevel as CllMeasure>::measure_max_smoothed(
1921            buf.as_slice(),
1922            DiffuseWhite::BT2408,
1923            LightLevelMethod::MaxRgb,
1924        )
1925        .unwrap();
1926        // 5.0 × 203 = 1 015 nits exactly.
1927        assert!(
1928            sm.max_content_light_level >= 1010 && sm.max_content_light_level <= 1020,
1929            "3-pixel cluster should preserve peak: got {}",
1930            sm.max_content_light_level
1931        );
1932    }
1933
1934    #[test]
1935    fn measure_max_smoothed_two_pixel_cluster_drops_to_two_thirds() {
1936        // Two adjacent bright pixels in a dark row → smoothed peak is
1937        // (0 + hot + hot)/3 = 2·hot/3. Documents the trade-off for
1938        // sub-resolution features.
1939        let mut pixels = alloc::vec![[0.0_f32; 3]; 10];
1940        pixels[4] = [9.0; 3];
1941        pixels[5] = [9.0; 3];
1942        let buf = rgbf32(&pixels, 10, 1);
1943
1944        let sm = <ContentLightLevel as CllMeasure>::measure_max_smoothed(
1945            buf.as_slice(),
1946            DiffuseWhite::BT2408,
1947            LightLevelMethod::MaxRgb,
1948        )
1949        .unwrap();
1950        let expected = (2.0_f64 * 9.0 / 3.0) * 203.0; // 6 · 203 = 1 218
1951        let got = f64::from(sm.max_content_light_level);
1952        assert!(
1953            (got - expected).abs() < 2.0,
1954            "2-pixel cluster: expected {expected:.0}, got {got}"
1955        );
1956    }
1957
1958    #[test]
1959    fn measure_max_smoothed_mean_matches_measure_max_mean() {
1960        // MaxFALL is the literal arithmetic mean (CTA-861.3). Box-filtering
1961        // the input doesn't change the mean (linearity of expectation), and
1962        // we explicitly accumulate the unsmoothed sum, so the two paths
1963        // must agree exactly on MaxFALL for arbitrary content.
1964        let pixels: Vec<[f32; 3]> = alloc::vec![
1965            [0.1, 0.2, 0.3],
1966            [1.5, 0.5, 0.25],
1967            [0.0, 3.0, 0.5],
1968            [0.7, 0.7, 0.7],
1969            [50.0, 0.0, 0.0], // a defect
1970            [0.1, 0.2, 0.3],
1971        ];
1972        let buf = rgbf32(&pixels, pixels.len() as u32, 1);
1973        let strict = <ContentLightLevel as CllMeasure>::measure_max(
1974            buf.as_slice(),
1975            DiffuseWhite::BT2408,
1976            LightLevelMethod::MaxRgb,
1977        )
1978        .unwrap();
1979        let smooth = <ContentLightLevel as CllMeasure>::measure_max_smoothed(
1980            buf.as_slice(),
1981            DiffuseWhite::BT2408,
1982            LightLevelMethod::MaxRgb,
1983        )
1984        .unwrap();
1985        assert_eq!(
1986            strict.max_frame_average_light_level, smooth.max_frame_average_light_level,
1987            "MaxFALL must match the spec-literal arithmetic mean"
1988        );
1989        // And the smoothed MaxCLL is strictly below the spec-literal here
1990        // because the defect drives the spec-literal reading.
1991        assert!(
1992            smooth.max_content_light_level < strict.max_content_light_level,
1993            "smoothed must suppress the defect: strict={}, smooth={}",
1994            strict.max_content_light_level,
1995            smooth.max_content_light_level
1996        );
1997    }
1998
1999    #[test]
2000    fn measure_max_smoothed_mirror_pad_handles_edge_defect() {
2001        // Defect at column 0 (left edge). Mirror padding makes m[-1] = m[0],
2002        // so the smoothed value at i=0 is (m[0]+m[0]+m[1])/3 = (hot+hot+0)/3
2003        // = 2·hot/3. This is the dominant smoothed value, *not* hot/3.
2004        // The test pins the mirror-padded math.
2005        let mut pixels = alloc::vec![[0.0_f32; 3]; 10];
2006        pixels[0] = [30.0; 3];
2007        let buf = rgbf32(&pixels, 10, 1);
2008        let sm = <ContentLightLevel as CllMeasure>::measure_max_smoothed(
2009            buf.as_slice(),
2010            DiffuseWhite::BT2408,
2011            LightLevelMethod::MaxRgb,
2012        )
2013        .unwrap();
2014        let expected = (2.0_f64 * 30.0 / 3.0) * 203.0; // 20·203 = 4 060
2015        let got = f64::from(sm.max_content_light_level);
2016        assert!(
2017            (got - expected).abs() < 2.0,
2018            "edge defect with mirror pad: expected {expected:.0}, got {got}"
2019        );
2020    }
2021
2022    #[test]
2023    fn measure_max_smoothed_degenerate_widths() {
2024        // 1-pixel-wide image: box filter collapses, smoothed == literal.
2025        let buf1 = rgbf32(&[[2.0; 3]], 1, 1);
2026        let sm1 = <ContentLightLevel as CllMeasure>::measure_max_smoothed(
2027            buf1.as_slice(),
2028            DiffuseWhite::BT2408,
2029            LightLevelMethod::MaxRgb,
2030        )
2031        .unwrap();
2032        assert_eq!(sm1.max_content_light_level, 406); // 2.0 × 203
2033
2034        // 2-pixel-wide image: both pixels get mirror padding from
2035        // themselves. (m0+m0+m1)/3 and (m0+m1+m1)/3; max picks whichever
2036        // is bigger. For [2.0, 1.0] the max is (2+2+1)/3 = 5/3 ≈ 1.667
2037        // → 1.667 × 203 = 338.3.
2038        let buf2 = rgbf32(&[[2.0; 3], [1.0; 3]], 2, 1);
2039        let sm2 = <ContentLightLevel as CllMeasure>::measure_max_smoothed(
2040            buf2.as_slice(),
2041            DiffuseWhite::BT2408,
2042            LightLevelMethod::MaxRgb,
2043        )
2044        .unwrap();
2045        let expected2 = (5.0_f64 / 3.0) * 203.0;
2046        let got2 = f64::from(sm2.max_content_light_level);
2047        assert!(
2048            (got2 - expected2).abs() < 1.0,
2049            "width=2: expected {expected2:.0}, got {got2}"
2050        );
2051    }
2052
2053    #[test]
2054    fn measure_max_smoothed_luma_bt2020_method() {
2055        // Pure red @ 5.0 with luma method: Y = 0.2627 · 5.0 = 1.3135.
2056        // Surround with 0 luma; defect at column 5 of a 10-wide row.
2057        // Smoothed peak = 1.3135 / 3 ≈ 0.4378 → · 203 = 88.9.
2058        let mut pixels = alloc::vec![[0.0_f32; 3]; 10];
2059        pixels[5] = [5.0, 0.0, 0.0];
2060        let buf = rgbf32(&pixels, 10, 1);
2061        let sm = <ContentLightLevel as CllMeasure>::measure_max_smoothed(
2062            buf.as_slice(),
2063            DiffuseWhite::BT2408,
2064            LightLevelMethod::LuminanceBt2020,
2065        )
2066        .unwrap();
2067        let expected = (0.262_7_f64 * 5.0 / 3.0) * 203.0; // ≈ 88.9
2068        let got = f64::from(sm.max_content_light_level);
2069        assert!(
2070            (got - expected).abs() < 2.0,
2071            "luma method smoothed: expected {expected:.1}, got {got}"
2072        );
2073    }
2074
2075    #[test]
2076    fn measure_max_smoothed_zero_image_returns_zero() {
2077        let buf = rgbf32(&[[0.0; 3]; 4], 4, 1);
2078        let sm = <ContentLightLevel as CllMeasure>::measure_max_smoothed(
2079            buf.as_slice(),
2080            DiffuseWhite::BT2408,
2081            LightLevelMethod::MaxRgb,
2082        )
2083        .unwrap();
2084        assert_eq!(sm.max_content_light_level, 0);
2085        assert_eq!(sm.max_frame_average_light_level, 0);
2086    }
2087
2088    #[test]
2089    fn measure_max_smoothed_rejects_non_linear_or_non_rgb_f32() {
2090        // Same rejection contract as measure_max — non-Linear transfer.
2091        let desc = PixelDescriptor::RGBF32_LINEAR.with_transfer(TransferFunction::Srgb);
2092        let mut data = Vec::new();
2093        for c in [0.5_f32; 3] {
2094            data.extend_from_slice(&c.to_ne_bytes());
2095        }
2096        let buf = PixelBuffer::from_vec(data, 1, 1, desc).unwrap();
2097        assert!(
2098            <ContentLightLevel as CllMeasure>::measure_max_smoothed(
2099                buf.as_slice(),
2100                DiffuseWhite::BT2408,
2101                LightLevelMethod::MaxRgb,
2102            )
2103            .is_none()
2104        );
2105    }
2106
2107    // ── measure_robust (DEFAULT_PERCENTILE = 0.99999 convenience) ─────────
2108
2109    #[test]
2110    fn default_percentile_constant_is_tail_tightest() {
2111        // Pin the constant so changing it requires a deliberate update.
2112        // 0.99999 = tail-tightest tested value in the 2026-06-22 audited
2113        // HDR→SDR shootout (76 imazen-26 samples × 20 curves × 4 peak
2114        // methods, scored on tail-aware percentiles + OKLab Euclidean ΔE).
2115        // The production default in zenpixels-convert is `measure_max`
2116        // (winning 3 of 6 criteria including the user-visible
2117        // `pct_above_de5`); this constant exists for callers who
2118        // explicitly opt into percentile-based defect rejection via
2119        // `measure_percentile`. See
2120        // `zen/zentone/benchmarks/shootout_2026-06-22_findings_v2.md`.
2121        assert_eq!(ContentLightLevel::DEFAULT_PERCENTILE, 0.99999);
2122    }
2123
2124    #[test]
2125    fn measure_robust_equals_measure_percentile_at_default() {
2126        // Bit-exact alias contract: measure_robust must be the
2127        // measure_percentile(p=DEFAULT_PERCENTILE) reading for arbitrary
2128        // content. If they ever disagree, callers reading the alias get a
2129        // different answer from the explicit call.
2130        let pixels: Vec<[f32; 3]> = alloc::vec![
2131            [0.1, 0.2, 0.3],
2132            [1.5, 0.5, 0.25],
2133            [0.0, 3.0, 0.5],
2134            [0.7, 0.7, 0.7],
2135            [50.0, 0.0, 0.0],
2136            [0.1, 0.2, 0.3],
2137        ];
2138        let buf = rgbf32(&pixels, pixels.len() as u32, 1);
2139
2140        for method in [LightLevelMethod::MaxRgb, LightLevelMethod::LuminanceBt2020] {
2141            let robust = <ContentLightLevel as CllMeasure>::measure_robust(
2142                buf.as_slice(),
2143                DiffuseWhite::BT2408,
2144                method,
2145            )
2146            .unwrap();
2147            let pct = <ContentLightLevel as CllMeasure>::measure_percentile(
2148                buf.as_slice(),
2149                DiffuseWhite::BT2408,
2150                ContentLightLevel::DEFAULT_PERCENTILE,
2151                method,
2152            )
2153            .unwrap();
2154            assert_eq!(robust.max_content_light_level, pct.max_content_light_level);
2155            assert_eq!(
2156                robust.max_frame_average_light_level,
2157                pct.max_frame_average_light_level
2158            );
2159        }
2160    }
2161
2162    #[test]
2163    fn measure_robust_drops_dominant_defect_vs_measure_max() {
2164        // The motivating use case: dense content with one defect-driven
2165        // hot pixel. measure_max returns the spike (CTA-861.3 literal);
2166        // measure_robust returns the background.
2167        let mut pixels = alloc::vec![[0.5_f32; 3]; 100_000];
2168        pixels[0] = [50.0; 3]; // single defect pixel
2169        let buf = rgbf32(&pixels, 1000, 100);
2170
2171        let strict = <ContentLightLevel as CllMeasure>::measure_max(
2172            buf.as_slice(),
2173            DiffuseWhite::BT2408,
2174            LightLevelMethod::MaxRgb,
2175        )
2176        .unwrap();
2177        let robust = <ContentLightLevel as CllMeasure>::measure_robust(
2178            buf.as_slice(),
2179            DiffuseWhite::BT2408,
2180            LightLevelMethod::MaxRgb,
2181        )
2182        .unwrap();
2183
2184        // Spec-literal preserves the spike (saturated near BIN_MAX_NITS).
2185        assert!(strict.max_content_light_level >= 9000);
2186        // Robust drops it — background = 0.5 × 203 ≈ 101.5 nits.
2187        // p=0.9999 over 100 000 pixels means threshold = 100 000 × 0.9999
2188        // = 99 990 pixels worth of CDF; the 99 990th pixel is in the
2189        // background bin (the defect is just 1 pixel). So robust should
2190        // land at background ≈ 101.5 nits.
2191        assert!(
2192            robust.max_content_light_level < 200,
2193            "measure_robust must drop the single defect: got {}",
2194            robust.max_content_light_level
2195        );
2196        // MaxFALL (literal mean) is unchanged by the percentile choice.
2197        assert_eq!(
2198            strict.max_frame_average_light_level,
2199            robust.max_frame_average_light_level
2200        );
2201    }
2202
2203    #[test]
2204    fn measure_robust_preserves_dense_bright_content() {
2205        // 1100-pixel image, 100 stars at 5.0 (= 1015 nits) + 1000 dark.
2206        // Stars are 9 % of pixels — well above the 0.01 % outlier budget,
2207        // so they survive the percentile threshold. Readout lands at the
2208        // bin-edge of the bright bin (one log2 bin ≈ 2 % below the
2209        // literal max — DEFAULT_PERCENTILE docstring covers the
2210        // quantisation).
2211        let mut pixels: Vec<[f32; 3]> = alloc::vec![[0.005_f32; 3]; 1100];
2212        for star in pixels.iter_mut().take(100) {
2213            *star = [5.0; 3];
2214        }
2215        let buf = rgbf32(&pixels, 100, 11);
2216        let robust = <ContentLightLevel as CllMeasure>::measure_robust(
2217            buf.as_slice(),
2218            DiffuseWhite::BT2408,
2219            LightLevelMethod::MaxRgb,
2220        )
2221        .unwrap();
2222        // Linear interpolation within the bin: with the threshold landing
2223        // near the top of the bright bin, the readout is within ~1 nit of
2224        // the literal max (1015). A naïve floor-of-bin readout would
2225        // undershoot to ≈ 1002.
2226        assert!(
2227            robust.max_content_light_level >= 1010 && robust.max_content_light_level <= 1020,
2228            "dense bright content: robust must preserve the peak: got {}",
2229            robust.max_content_light_level
2230        );
2231    }
2232
2233    #[test]
2234    fn measure_robust_sparse_bright_cliff() {
2235        // Image with one bright pixel and 99 dark. p=0.9999 over 100
2236        // pixels → threshold = 99; the dark bin cum hits 99 before the
2237        // bright bin → the bright pixel is dropped. Documents the
2238        // sparse-bright cliff: at small image sizes, single bright
2239        // pixels disappear. Astrophotography wants `measure_max` here.
2240        let mut pixels = alloc::vec![[0.005_f32; 3]; 100];
2241        pixels[0] = [5.0; 3]; // one bright "star"
2242        let buf = rgbf32(&pixels, 10, 10);
2243        let robust = <ContentLightLevel as CllMeasure>::measure_robust(
2244            buf.as_slice(),
2245            DiffuseWhite::BT2408,
2246            LightLevelMethod::MaxRgb,
2247        )
2248        .unwrap();
2249        // 0.005 × 203 = 1.015 — robust reports the dark-bin floor, not
2250        // the star.
2251        assert!(
2252            robust.max_content_light_level < 50,
2253            "sparse-bright cliff: 1-in-100 bright pixel must be dropped: got {}",
2254            robust.max_content_light_level
2255        );
2256        // And measure_max keeps the star.
2257        let strict = <ContentLightLevel as CllMeasure>::measure_max(
2258            buf.as_slice(),
2259            DiffuseWhite::BT2408,
2260            LightLevelMethod::MaxRgb,
2261        )
2262        .unwrap();
2263        assert!(strict.max_content_light_level > 900);
2264    }
2265
2266    #[test]
2267    fn measure_robust_rejects_non_linear_or_non_rgb_f32() {
2268        // Same rejection contract as the rest of the measure family.
2269        let desc = PixelDescriptor::RGBF32_LINEAR.with_transfer(TransferFunction::Srgb);
2270        let mut data = Vec::new();
2271        for c in [0.5_f32; 3] {
2272            data.extend_from_slice(&c.to_ne_bytes());
2273        }
2274        let buf = PixelBuffer::from_vec(data, 1, 1, desc).unwrap();
2275        assert!(
2276            <ContentLightLevel as CllMeasure>::measure_robust(
2277                buf.as_slice(),
2278                DiffuseWhite::BT2408,
2279                LightLevelMethod::MaxRgb,
2280            )
2281            .is_none()
2282        );
2283    }
2284
2285    #[test]
2286    fn measure_robust_zero_image_returns_zero() {
2287        let buf = rgbf32(&[[0.0; 3]; 4], 4, 1);
2288        let robust = <ContentLightLevel as CllMeasure>::measure_robust(
2289            buf.as_slice(),
2290            DiffuseWhite::BT2408,
2291            LightLevelMethod::MaxRgb,
2292        )
2293        .unwrap();
2294        assert_eq!(robust.max_content_light_level, 0);
2295        assert_eq!(robust.max_frame_average_light_level, 0);
2296    }
2297}