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}