Skip to main content

ultrahdr_core/gainmap/
compute.rs

1//! Gain map computation from HDR and SDR images.
2
3use alloc::vec;
4
5use crate::color::gamut::rgb_to_luminance;
6
7use crate::color::transfer::{hlg_eotf, pq_eotf, srgb_eotf};
8use crate::types::TransferFunction;
9use crate::types::{
10    ColorPrimaries, GainMap, GainMapMetadata, PixelBuffer, PixelFormat, PixelSlice, Result,
11};
12use enough::Stop;
13
14/// Configuration for gain map computation.
15///
16/// Boost values (`min_boost`, `max_boost`) are in **linear domain** for
17/// ergonomics — humans think "4× brighter" not "log2(4)=2". These are
18/// converted to log2 when producing [`GainMapMetadata`].
19#[derive(Debug, Clone)]
20pub struct GainMapConfig {
21    /// Scale factor for gain map (1 = same size as image, 4 = 1/4 size).
22    pub scale_factor: u8,
23    /// Gamma to apply to the gain map encoding.
24    pub gamma: f32,
25    /// Use multi-channel (RGB) gain map instead of single-channel luminance.
26    pub multi_channel: bool,
27    /// Minimum gain (linear). 1.0 = no darkening, 0.5 = allow 2× darker.
28    pub min_boost: f32,
29    /// Maximum gain (linear). HDR peak / SDR peak. e.g. 6.0 = ~2.5 stops.
30    pub max_boost: f32,
31    /// Offset for base (SDR) values to avoid division by zero. Linear domain.
32    pub base_offset: f32,
33    /// Offset for alternate (HDR) values. Linear domain.
34    pub alternate_offset: f32,
35    /// Minimum display boost (linear). For metadata headroom.
36    pub base_hdr_headroom: f32,
37    /// Maximum display boost (linear). For metadata headroom.
38    pub alternate_hdr_headroom: f32,
39}
40
41impl Default for GainMapConfig {
42    fn default() -> Self {
43        Self {
44            scale_factor: 4,
45            gamma: 1.0,
46            multi_channel: false,
47            min_boost: 1.0,
48            max_boost: 6.0, // ~2.5 stops
49            base_offset: 1.0 / 64.0,
50            alternate_offset: 1.0 / 64.0,
51            base_hdr_headroom: 1.0,
52            alternate_hdr_headroom: 6.0,
53        }
54    }
55}
56
57/// Compute a gain map from HDR and SDR images.
58///
59/// The gain map represents the ratio between HDR and SDR pixel values,
60/// encoded as 8-bit values in the range `[0, 255]`.
61///
62/// The `stop` parameter enables cooperative cancellation. Pass `Unstoppable`
63/// when cancellation is not needed.
64pub fn compute_gainmap(
65    hdr: &PixelBuffer,
66    sdr: &PixelBuffer,
67    config: &GainMapConfig,
68    stop: impl Stop,
69) -> Result<(GainMap, GainMapMetadata)> {
70    compute_gainmap_slice(hdr.as_slice(), sdr.as_slice(), config, stop)
71}
72
73/// [`compute_gainmap`] variant that takes borrowed [`PixelSlice`]s.
74pub fn compute_gainmap_slice(
75    hdr: PixelSlice<'_>,
76    sdr: PixelSlice<'_>,
77    config: &GainMapConfig,
78    stop: impl Stop,
79) -> Result<(GainMap, GainMapMetadata)> {
80    crate::types::validate_ultrahdr_slice(&hdr)?;
81    crate::types::validate_ultrahdr_slice(&sdr)?;
82
83    let hdr_w = hdr.width();
84    let hdr_h = hdr.rows();
85    let sdr_w = sdr.width();
86    let sdr_h = sdr.rows();
87
88    if hdr_w != sdr_w || hdr_h != sdr_h {
89        return Err(crate::types::Error::DimensionMismatch {
90            hdr_w,
91            hdr_h,
92            sdr_w,
93            sdr_h,
94        });
95    }
96
97    let scale = config.scale_factor.max(1) as u32;
98    let gm_width = hdr_w.div_ceil(scale);
99    let gm_height = hdr_h.div_ceil(scale);
100
101    // Track actual min/max boost values found
102    let mut actual_min_boost = f32::MAX;
103    let mut actual_max_boost = f32::MIN;
104
105    // Compute gain map
106    let gainmap = if config.multi_channel {
107        compute_multichannel_gainmap(
108            &hdr,
109            &sdr,
110            gm_width,
111            gm_height,
112            scale,
113            config,
114            &mut actual_min_boost,
115            &mut actual_max_boost,
116            &stop,
117        )?
118    } else {
119        compute_luminance_gainmap(
120            &hdr,
121            &sdr,
122            gm_width,
123            gm_height,
124            scale,
125            config,
126            &mut actual_min_boost,
127            &mut actual_max_boost,
128            &stop,
129        )?
130    };
131
132    // Clamp actual values to configured range
133    actual_min_boost = actual_min_boost.max(config.min_boost);
134    actual_max_boost = actual_max_boost.min(config.max_boost);
135
136    // Build metadata (convert linear boost values to log2 domain)
137    let metadata = crate::types::metadata_from_arrays(
138        [(actual_min_boost as f64).log2(); 3],
139        [(actual_max_boost as f64).log2(); 3],
140        [config.gamma as f64; 3],
141        [config.base_offset as f64; 3],
142        [config.alternate_offset as f64; 3],
143        (config.base_hdr_headroom as f64).log2(),
144        (config.alternate_hdr_headroom.max(actual_max_boost) as f64).log2(),
145        true,
146        false,
147    );
148
149    Ok((gainmap, metadata))
150}
151
152/// Compute single-channel (luminance) gain map.
153#[allow(clippy::too_many_arguments)]
154fn compute_luminance_gainmap(
155    hdr: &PixelSlice<'_>,
156    sdr: &PixelSlice<'_>,
157    gm_width: u32,
158    gm_height: u32,
159    scale: u32,
160    config: &GainMapConfig,
161    actual_min_boost: &mut f32,
162    actual_max_boost: &mut f32,
163    stop: &impl Stop,
164) -> Result<GainMap> {
165    let mut gainmap = GainMap::new(gm_width, gm_height)?;
166    let hdr_w = hdr.width();
167    let hdr_h = hdr.rows();
168    let hdr_gamut = hdr.descriptor().primaries;
169    let sdr_gamut = sdr.descriptor().primaries;
170
171    // Materialize one gain-map row's worth of subsampled HDR + SDR linear RGB,
172    // then delegate quantization to `compute_gain_row`. Three channels —
173    // luminance is gamut-derived from RGB inside the row kernel.
174    let row_len = gm_width as usize * 3;
175    let mut hdr_row_rgb = vec![0.0f32; row_len];
176    let mut sdr_row_rgb = vec![0.0f32; row_len];
177    let mut min_max = (*actual_min_boost, *actual_max_boost);
178
179    for gy in 0..gm_height {
180        // Check for cancellation once per row
181        stop.check()?;
182
183        // Sample center pixel of each block on this row.
184        let y = (gy * scale + scale / 2).min(hdr_h - 1);
185        for gx in 0..gm_width {
186            let x = (gx * scale + scale / 2).min(hdr_w - 1);
187            let hdr_rgb = get_linear_rgb(hdr, x, y);
188            let sdr_rgb = get_linear_rgb(sdr, x, y);
189            let off = gx as usize * 3;
190            hdr_row_rgb[off] = hdr_rgb[0];
191            hdr_row_rgb[off + 1] = hdr_rgb[1];
192            hdr_row_rgb[off + 2] = hdr_rgb[2];
193            sdr_row_rgb[off] = sdr_rgb[0];
194            sdr_row_rgb[off + 1] = sdr_rgb[1];
195            sdr_row_rgb[off + 2] = sdr_rgb[2];
196        }
197
198        let row_start = (gy * gm_width) as usize;
199        let row_end = row_start + gm_width as usize;
200        compute_gain_row(
201            &hdr_row_rgb,
202            &sdr_row_rgb,
203            3,
204            hdr_gamut,
205            sdr_gamut,
206            &mut gainmap.data[row_start..row_end],
207            config,
208            &mut min_max,
209        );
210    }
211
212    *actual_min_boost = min_max.0;
213    *actual_max_boost = min_max.1;
214    Ok(gainmap)
215}
216
217/// Compute and quantize gain-map bytes for one row, given paired HDR + SDR
218/// linear-RGB rows.
219///
220/// Inputs:
221/// - `hdr_row` and `sdr_row`: interleaved linear f32 RGB(A) of equal length.
222///   `channels` is 3 or 4 (alpha is read but doesn't affect the gain
223///   computation — only the first three channels are used).
224/// - `hdr_primaries` / `sdr_primaries`: color primaries used to weight RGB
225///   into luminance. Pass the same value for both when the inputs already
226///   share a gamut.
227/// - `gainmap_byte_out`: u8 output, one byte per pixel for single-channel
228///   (luminance) gain maps; `len = hdr_row.len() / channels`.
229/// - `config`: the gain-map configuration (offsets, min/max boost, gamma).
230/// - `observed_min_max`: `(min, max)` f32 accumulator for metadata bounds —
231///   updated in place across calls so callers can stitch row-level invocations
232///   into a whole-image min/max.
233///
234/// Used by `compute_gainmap` internally and by zenjpeg's encode flow to fuse
235/// splitter + gain quantization in a single row pass. Bit-identical to the
236/// per-cell math in `compute_and_encode_gain`.
237#[allow(clippy::too_many_arguments)]
238pub fn compute_gain_row(
239    hdr_row: &[f32],
240    sdr_row: &[f32],
241    channels: u8,
242    hdr_primaries: ColorPrimaries,
243    sdr_primaries: ColorPrimaries,
244    gainmap_byte_out: &mut [u8],
245    config: &GainMapConfig,
246    observed_min_max: &mut (f32, f32),
247) {
248    debug_assert!(channels == 3 || channels == 4);
249    let chan = channels as usize;
250    debug_assert_eq!(hdr_row.len(), sdr_row.len());
251    debug_assert_eq!(gainmap_byte_out.len(), hdr_row.len() / chan);
252
253    let log_min = config.min_boost.ln();
254    let log_range = config.max_boost.ln() - log_min;
255
256    for (i, byte_out) in gainmap_byte_out.iter_mut().enumerate() {
257        let off = i * chan;
258        let hdr_rgb = [hdr_row[off], hdr_row[off + 1], hdr_row[off + 2]];
259        let sdr_rgb = [sdr_row[off], sdr_row[off + 1], sdr_row[off + 2]];
260        let hdr_lum = rgb_to_luminance(hdr_rgb, hdr_primaries);
261        let sdr_lum = rgb_to_luminance(sdr_rgb, sdr_primaries);
262        *byte_out = compute_and_encode_gain(
263            hdr_lum,
264            sdr_lum,
265            config,
266            log_min,
267            log_range,
268            &mut observed_min_max.0,
269            &mut observed_min_max.1,
270        );
271    }
272}
273
274/// Compute the gain for one channel of one cell, track min/max, and quantize
275/// to the 8-bit gain map byte. Used by both the batch `compute_gainmap` and
276/// the streaming `RowEncoder`.
277///
278/// `log_min` and `log_range` are `config.min_boost.ln()` and
279/// `log(max_boost) - log(min_boost)` respectively — pre-computed by the
280/// caller and reused across every cell.
281pub(super) fn compute_and_encode_gain(
282    hdr: f32,
283    sdr: f32,
284    config: &GainMapConfig,
285    log_min: f32,
286    log_range: f32,
287    actual_min_boost: &mut f32,
288    actual_max_boost: &mut f32,
289) -> u8 {
290    let gain = (hdr + config.alternate_offset) / (sdr + config.base_offset).max(0.001);
291    *actual_min_boost = actual_min_boost.min(gain);
292    *actual_max_boost = actual_max_boost.max(gain);
293    let gain_clamped = gain.clamp(config.min_boost, config.max_boost);
294    let log_gain = gain_clamped.ln();
295    let normalized = if log_range > 0.0 {
296        (log_gain - log_min) / log_range
297    } else {
298        0.5
299    };
300    let gamma_corrected = normalized.powf(config.gamma);
301    (gamma_corrected * 255.0).round().clamp(0.0, 255.0) as u8
302}
303
304/// Compute multi-channel (RGB) gain map.
305#[allow(clippy::too_many_arguments)]
306fn compute_multichannel_gainmap(
307    hdr: &PixelSlice<'_>,
308    sdr: &PixelSlice<'_>,
309    gm_width: u32,
310    gm_height: u32,
311    scale: u32,
312    config: &GainMapConfig,
313    actual_min_boost: &mut f32,
314    actual_max_boost: &mut f32,
315    stop: &impl Stop,
316) -> Result<GainMap> {
317    let mut gainmap = GainMap::new_multichannel(gm_width, gm_height)?;
318    let hdr_w = hdr.width();
319    let hdr_h = hdr.rows();
320
321    let log_min = config.min_boost.ln();
322    let log_max = config.max_boost.ln();
323    let log_range = log_max - log_min;
324
325    for gy in 0..gm_height {
326        // Check for cancellation once per row
327        stop.check()?;
328
329        for gx in 0..gm_width {
330            let x = (gx * scale + scale / 2).min(hdr_w - 1);
331            let y = (gy * scale + scale / 2).min(hdr_h - 1);
332
333            let hdr_rgb = get_linear_rgb(hdr, x, y);
334            let sdr_rgb = get_linear_rgb(sdr, x, y);
335
336            for c in 0..3 {
337                let encoded = compute_and_encode_gain(
338                    hdr_rgb[c],
339                    sdr_rgb[c],
340                    config,
341                    log_min,
342                    log_range,
343                    actual_min_boost,
344                    actual_max_boost,
345                );
346                let idx = (gy * gm_width + gx) as usize * 3 + c;
347                gainmap.data[idx] = encoded;
348            }
349        }
350    }
351
352    Ok(gainmap)
353}
354
355/// Apply EOTF to a float-domain RGB triple based on the descriptor's transfer.
356///
357/// Used for f32 / f16 inputs where the pixel values aren't bytes — PQ and
358/// HLG-encoded floats are common in HDR pipelines (Apple `kCGColorSpacePQ`,
359/// EXR with non-linear transfer, GPU compositors). `Linear` passes through.
360/// `Srgb` runs the EOTF directly on the float values.
361#[inline]
362fn apply_transfer_to_linear(rgb: [f32; 3], transfer: TransferFunction) -> [f32; 3] {
363    match transfer {
364        TransferFunction::Linear => rgb,
365        TransferFunction::Srgb => [srgb_eotf(rgb[0]), srgb_eotf(rgb[1]), srgb_eotf(rgb[2])],
366        TransferFunction::Pq => [pq_eotf(rgb[0]), pq_eotf(rgb[1]), pq_eotf(rgb[2])],
367        TransferFunction::Hlg => [
368            // hlg_eotf returns nits at 1000-nit peak; normalize to SDR-relative.
369            hlg_eotf(rgb[0], 1000.0) / 1000.0,
370            hlg_eotf(rgb[1], 1000.0) / 1000.0,
371            hlg_eotf(rgb[2], 1000.0) / 1000.0,
372        ],
373        _ => rgb,
374    }
375}
376
377/// Extract linear RGB `[0,1]` from a pixel slice at the given pixel position.
378///
379/// Applies the appropriate EOTF conversion (sRGB, PQ, HLG) based on the
380/// image's declared transfer function.
381fn get_linear_rgb(img: &PixelSlice<'_>, x: u32, y: u32) -> [f32; 3] {
382    let desc = img.descriptor();
383    let format = desc.pixel_format();
384    let transfer = desc.transfer();
385    let stride = img.stride();
386    let data = img.as_strided_bytes();
387    match format {
388        PixelFormat::Rgba8 | PixelFormat::Rgb8 => {
389            let bpp = if format == PixelFormat::Rgba8 { 4 } else { 3 };
390            let idx = y as usize * stride + x as usize * bpp;
391            let r = data[idx] as f32 / 255.0;
392            let g = data[idx + 1] as f32 / 255.0;
393            let b = data[idx + 2] as f32 / 255.0;
394
395            // Apply EOTF based on transfer function
396            match transfer {
397                TransferFunction::Srgb => [srgb_eotf(r), srgb_eotf(g), srgb_eotf(b)],
398                TransferFunction::Linear => [r, g, b],
399                _ => [srgb_eotf(r), srgb_eotf(g), srgb_eotf(b)], // Assume sRGB for 8-bit
400            }
401        }
402
403        PixelFormat::RgbaF32 => {
404            let idx = y as usize * stride + x as usize * 16;
405            let r = f32::from_le_bytes(data[idx..idx + 4].try_into().unwrap());
406            let g = f32::from_le_bytes(data[idx + 4..idx + 8].try_into().unwrap());
407            let b = f32::from_le_bytes(data[idx + 8..idx + 12].try_into().unwrap());
408            apply_transfer_to_linear([r, g, b], transfer)
409        }
410
411        PixelFormat::RgbaF16 | PixelFormat::RgbF16 => {
412            let bpp = if format == PixelFormat::RgbaF16 { 8 } else { 6 };
413            let idx = y as usize * stride + x as usize * bpp;
414            let r = half::f16::from_le_bytes([data[idx], data[idx + 1]]).to_f32();
415            let g = half::f16::from_le_bytes([data[idx + 2], data[idx + 3]]).to_f32();
416            let b = half::f16::from_le_bytes([data[idx + 4], data[idx + 5]]).to_f32();
417            apply_transfer_to_linear([r, g, b], transfer)
418        }
419
420        PixelFormat::Gray8 => {
421            let idx = y as usize * stride + x as usize;
422            let v = data[idx] as f32 / 255.0;
423            let linear = srgb_eotf(v);
424            [linear, linear, linear]
425        }
426        _ => [0.0, 0.0, 0.0],
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::ColorPrimaries;
434    use crate::types::new_pixel_buffer;
435
436    #[test]
437    fn test_gainmap_config_default() {
438        let config = GainMapConfig::default();
439        assert_eq!(config.scale_factor, 4);
440        assert_eq!(config.gamma, 1.0);
441        assert!(!config.multi_channel);
442    }
443
444    #[test]
445    fn test_compute_gainmap_basic() {
446        // Create simple test images
447        let mut hdr = new_pixel_buffer(
448            8,
449            8,
450            PixelFormat::Rgba8,
451            ColorPrimaries::Bt709,
452            TransferFunction::Srgb,
453        )
454        .unwrap();
455        {
456            let mut slice = hdr.as_slice_mut();
457            let bytes = slice.as_strided_bytes_mut();
458            for i in 0..bytes.len() / 4 {
459                bytes[i * 4] = 180;
460                bytes[i * 4 + 1] = 180;
461                bytes[i * 4 + 2] = 180;
462                bytes[i * 4 + 3] = 255;
463            }
464        }
465
466        let mut sdr = new_pixel_buffer(
467            8,
468            8,
469            PixelFormat::Rgba8,
470            ColorPrimaries::Bt709,
471            TransferFunction::Srgb,
472        )
473        .unwrap();
474        {
475            let mut slice = sdr.as_slice_mut();
476            let bytes = slice.as_strided_bytes_mut();
477            for i in 0..bytes.len() / 4 {
478                bytes[i * 4] = 128;
479                bytes[i * 4 + 1] = 128;
480                bytes[i * 4 + 2] = 128;
481                bytes[i * 4 + 3] = 255;
482            }
483        }
484
485        let config = GainMapConfig {
486            scale_factor: 2,
487            ..Default::default()
488        };
489
490        let (gainmap, metadata) =
491            compute_gainmap(&hdr, &sdr, &config, enough::Unstoppable).unwrap();
492
493        // Check dimensions
494        assert_eq!(gainmap.width, 4);
495        assert_eq!(gainmap.height, 4);
496        assert_eq!(gainmap.channels, 1);
497
498        // Check metadata is populated
499        assert!(metadata.channels[0].max >= 1.0);
500    }
501
502    // ========================================================================
503    // Gain encoding reference values (C++ libultrahdr parity)
504    //
505    // Tests the gain → encoded byte mapping with known inputs.
506    // Parameters: min_boost=0.25 (log2=-2), max_boost=4.0 (log2=2), gamma=1.0
507    // offset_sdr=offset_hdr=1/64
508    //
509    // Encoding formula:
510    //   gain = (hdr + offset) / (sdr + offset)
511    //   log_gain = ln(clamp(gain, min_boost, max_boost))
512    //   normalized = (log_gain - ln(min_boost)) / (ln(max_boost) - ln(min_boost))
513    //   encoded = round(normalized * 255)
514    // ========================================================================
515
516    /// Helper: compute the expected encoded byte for a given (sdr, hdr) pair.
517    fn encode_gain_reference(sdr: f32, hdr: f32, min_boost: f32, max_boost: f32) -> u8 {
518        let offset = 1.0 / 64.0;
519        let gain = (hdr + offset) / (sdr + offset);
520        let gain_clamped = gain.clamp(min_boost, max_boost);
521        let log_min = min_boost.ln();
522        let log_max = max_boost.ln();
523        let log_range = log_max - log_min;
524        let normalized = (gain_clamped.ln() - log_min) / log_range;
525        (normalized * 255.0).round().clamp(0.0, 255.0) as u8
526    }
527
528    /// Test gain encoding against reference (sdr, hdr) pairs.
529    ///
530    /// Parameters match C++ test: min_boost=0.25, max_boost=4.0, gamma=1.0
531    #[test]
532    fn test_gain_encoding_cpp_reference() {
533        let min_boost = 0.25_f32;
534        let max_boost = 4.0_f32;
535
536        // (sdr_linear, hdr_linear, description)
537        let cases: &[(f32, f32, &str)] = &[
538            // Same intensity → gain=1.0 → log(1)=0 → normalized=0.5 → 128
539            (0.5, 0.5, "equal SDR/HDR"),
540            // HDR is 4x SDR → gain=4.0 → max → 255
541            (0.25, 1.0, "HDR 4x brighter"),
542            // HDR is 0.25x SDR → gain=0.25 → min → 0
543            (1.0, 0.25, "HDR 4x darker"),
544            // Black pixels: gain dominated by offset
545            (0.0, 0.0, "both black"),
546            // SDR black, HDR bright: gain capped at max_boost
547            (0.0, 1.0, "SDR black HDR bright"),
548            // Mid range
549            (0.18, 0.36, "HDR ~2x mid-gray"),
550            // HDR slightly brighter
551            (0.5, 0.75, "HDR 1.5x"),
552        ];
553
554        for &(sdr, hdr, desc) in cases {
555            let expected = encode_gain_reference(sdr, hdr, min_boost, max_boost);
556            // Verify the reference function itself is consistent
557            let offset = 1.0 / 64.0;
558            let gain = (hdr + offset) / (sdr + offset);
559            let gain_clamped = gain.clamp(min_boost, max_boost);
560
561            // Validate gain direction
562            if sdr > 0.01 && hdr > 0.01 {
563                if hdr > sdr * 1.5 {
564                    assert!(
565                        expected > 128,
566                        "{}: hdr>sdr but encoded={} (gain={})",
567                        desc,
568                        expected,
569                        gain
570                    );
571                }
572                if hdr < sdr * 0.7 {
573                    assert!(
574                        expected < 128,
575                        "{}: hdr<sdr but encoded={} (gain={})",
576                        desc,
577                        expected,
578                        gain
579                    );
580                }
581            }
582
583            // Log the encoding for verification
584            eprintln!(
585                "  {}: sdr={:.3}, hdr={:.3}, gain={:.4}, clamped={:.4}, encoded={}",
586                desc, sdr, hdr, gain, gain_clamped, expected
587            );
588        }
589    }
590
591    /// Helper: create an 8x8 HDR image (RgbaF32, Linear, BT.709) filled with a uniform color.
592    fn make_hdr_8x8(r: f32, g: f32, b: f32) -> PixelBuffer {
593        let w = 8u32;
594        let h = 8u32;
595        let pixel_count = (w * h) as usize;
596        let mut data = Vec::with_capacity(pixel_count * 16);
597        for _ in 0..pixel_count {
598            data.extend_from_slice(&r.to_le_bytes());
599            data.extend_from_slice(&g.to_le_bytes());
600            data.extend_from_slice(&b.to_le_bytes());
601            data.extend_from_slice(&1.0f32.to_le_bytes());
602        }
603        crate::types::pixel_buffer_from_vec(
604            data,
605            w,
606            h,
607            PixelFormat::RgbaF32,
608            ColorPrimaries::Bt709,
609            TransferFunction::Linear,
610        )
611        .unwrap()
612    }
613
614    /// Helper: create an 8x8 SDR image (Rgba8, Srgb, BT.709) filled with a uniform color.
615    fn make_sdr_8x8(r: u8, g: u8, b: u8) -> PixelBuffer {
616        let w = 8u32;
617        let h = 8u32;
618        let pixel_count = (w * h) as usize;
619        let mut data = vec![0u8; pixel_count * 4];
620        for i in 0..pixel_count {
621            data[i * 4] = r;
622            data[i * 4 + 1] = g;
623            data[i * 4 + 2] = b;
624            data[i * 4 + 3] = 255;
625        }
626        crate::types::pixel_buffer_from_vec(
627            data,
628            w,
629            h,
630            PixelFormat::Rgba8,
631            ColorPrimaries::Bt709,
632            TransferFunction::Srgb,
633        )
634        .unwrap()
635    }
636
637    #[test]
638    fn test_compute_gainmap_multichannel() {
639        let hdr = make_hdr_8x8(0.8, 0.5, 0.3);
640        let sdr = make_sdr_8x8(180, 128, 100);
641
642        let config = GainMapConfig {
643            multi_channel: true,
644            scale_factor: 1,
645            ..Default::default()
646        };
647
648        let (gainmap, _metadata) =
649            compute_gainmap(&hdr, &sdr, &config, enough::Unstoppable).unwrap();
650
651        assert_eq!(gainmap.channels, 3);
652        assert_eq!(
653            gainmap.data.len(),
654            (gainmap.width * gainmap.height) as usize * 3
655        );
656    }
657
658    #[test]
659    fn test_compute_gainmap_scale_factor_1() {
660        let hdr = make_hdr_8x8(0.5, 0.5, 0.5);
661        let sdr = make_sdr_8x8(186, 186, 186);
662
663        let config = GainMapConfig {
664            scale_factor: 1,
665            ..Default::default()
666        };
667
668        let (gainmap, _metadata) =
669            compute_gainmap(&hdr, &sdr, &config, enough::Unstoppable).unwrap();
670
671        assert_eq!(gainmap.width, 8);
672        assert_eq!(gainmap.height, 8);
673    }
674
675    #[test]
676    fn test_compute_gainmap_scale_factor_8() {
677        let hdr = make_hdr_8x8(0.5, 0.5, 0.5);
678        let sdr = make_sdr_8x8(186, 186, 186);
679
680        let config = GainMapConfig {
681            scale_factor: 8,
682            ..Default::default()
683        };
684
685        let (gainmap, _metadata) =
686            compute_gainmap(&hdr, &sdr, &config, enough::Unstoppable).unwrap();
687
688        // 8 / 8 = 1 (div_ceil)
689        assert_eq!(gainmap.width, 8u32.div_ceil(8));
690        assert_eq!(gainmap.height, 8u32.div_ceil(8));
691    }
692
693    #[test]
694    fn test_compute_gainmap_uniform_images() {
695        // Both HDR and SDR are mid-gray: 0.5 linear, 186 sRGB
696        let hdr = make_hdr_8x8(0.5, 0.5, 0.5);
697        let sdr = make_sdr_8x8(186, 186, 186);
698
699        let config = GainMapConfig {
700            scale_factor: 1,
701            ..Default::default()
702        };
703
704        let (gainmap, _metadata) =
705            compute_gainmap(&hdr, &sdr, &config, enough::Unstoppable).unwrap();
706
707        // All pixels should have roughly the same encoded value since inputs are uniform
708        let first = gainmap.data[0];
709        for &val in &gainmap.data {
710            assert!(
711                (val as i16 - first as i16).unsigned_abs() <= 1,
712                "non-uniform gainmap: first={}, got={}",
713                first,
714                val
715            );
716        }
717    }
718
719    #[test]
720    fn test_compute_gainmap_bright_hdr() {
721        // HDR is very bright (5.0 linear), SDR is mid (186 sRGB ~ 0.5 linear)
722        let hdr = make_hdr_8x8(5.0, 5.0, 5.0);
723        let sdr = make_sdr_8x8(186, 186, 186);
724
725        let config = GainMapConfig {
726            scale_factor: 1,
727            max_boost: 12.0,
728            alternate_hdr_headroom: 12.0,
729            ..Default::default()
730        };
731
732        let (gainmap, _metadata) =
733            compute_gainmap(&hdr, &sdr, &config, enough::Unstoppable).unwrap();
734
735        // Gainmap values should be high — a large positive gain means brighter bytes
736        // The encoding maps min_content_boost → 0, max_content_boost → 255
737        // gain ~= 5.0/0.5 = 10.0, which is well above 1.0 midpoint
738        let avg: f32 =
739            gainmap.data.iter().map(|&v| v as f32).sum::<f32>() / gainmap.data.len() as f32;
740        assert!(
741            avg > 128.0,
742            "bright HDR should produce high gainmap values, got average {}",
743            avg
744        );
745    }
746
747    #[test]
748    fn test_compute_gainmap_dimension_mismatch() {
749        let hdr = make_hdr_8x8(0.5, 0.5, 0.5);
750        // Create a 4x4 SDR image
751        let sdr = crate::types::pixel_buffer_from_vec(
752            vec![128u8; 4 * 4 * 4],
753            4,
754            4,
755            PixelFormat::Rgba8,
756            ColorPrimaries::Bt709,
757            TransferFunction::Srgb,
758        )
759        .unwrap();
760
761        let config = GainMapConfig::default();
762        let result = compute_gainmap(&hdr, &sdr, &config, enough::Unstoppable);
763        assert!(result.is_err());
764        assert!(matches!(
765            result.unwrap_err(),
766            crate::types::Error::DimensionMismatch { .. }
767        ));
768    }
769
770    #[test]
771    fn compute_gain_row_matches_compute_gainmap() {
772        // Regression gate: feeding `compute_gain_row` the same per-row inputs
773        // that `compute_gainmap` would internally must produce the same bytes
774        // and the same observed min/max. If this test diverges from
775        // `compute_gainmap`'s output, the row kernel and the batch path are
776        // out of sync — that's a contract break.
777        let hdr = make_hdr_8x8(0.6, 0.4, 0.2);
778        let sdr = make_sdr_8x8(160, 110, 70);
779        let config = GainMapConfig {
780            scale_factor: 1,
781            ..Default::default()
782        };
783        let (gainmap_batch, _meta) =
784            compute_gainmap(&hdr, &sdr, &config, enough::Unstoppable).unwrap();
785
786        // Build the same linear RGB rows that compute_luminance_gainmap feeds
787        // into compute_gain_row, and verify byte-for-byte parity.
788        let hdr_slice = hdr.as_slice();
789        let sdr_slice = sdr.as_slice();
790        let w = hdr_slice.width() as usize;
791        let h = hdr_slice.rows() as usize;
792        let mut min_max = (f32::MAX, f32::MIN);
793        let mut row_bytes = vec![0u8; w];
794        let mut hdr_row_rgb = vec![0.0f32; w * 3];
795        let mut sdr_row_rgb = vec![0.0f32; w * 3];
796        for y in 0..h {
797            for x in 0..w {
798                let h_rgb = get_linear_rgb(&hdr_slice, x as u32, y as u32);
799                let s_rgb = get_linear_rgb(&sdr_slice, x as u32, y as u32);
800                hdr_row_rgb[x * 3..x * 3 + 3].copy_from_slice(&h_rgb);
801                sdr_row_rgb[x * 3..x * 3 + 3].copy_from_slice(&s_rgb);
802            }
803            compute_gain_row(
804                &hdr_row_rgb,
805                &sdr_row_rgb,
806                3,
807                hdr_slice.descriptor().primaries,
808                sdr_slice.descriptor().primaries,
809                &mut row_bytes,
810                &config,
811                &mut min_max,
812            );
813            // The batch result is contiguous — compare row by row.
814            let expected = &gainmap_batch.data[y * w..y * w + w];
815            assert_eq!(row_bytes, expected, "row {y} bytes diverged");
816        }
817    }
818
819    #[test]
820    fn test_compute_gainmap_cancellation() {
821        /// A Stop implementation that cancels immediately
822        struct ImmediateCancel;
823
824        impl enough::Stop for ImmediateCancel {
825            fn check(&self) -> std::result::Result<(), enough::StopReason> {
826                Err(enough::StopReason::Cancelled)
827            }
828        }
829
830        // Create minimal images
831        let hdr = new_pixel_buffer(
832            8,
833            8,
834            PixelFormat::Rgba8,
835            ColorPrimaries::Bt709,
836            TransferFunction::Srgb,
837        )
838        .unwrap();
839        let sdr = new_pixel_buffer(
840            8,
841            8,
842            PixelFormat::Rgba8,
843            ColorPrimaries::Bt709,
844            TransferFunction::Srgb,
845        )
846        .unwrap();
847        let config = GainMapConfig::default();
848
849        // Should return Stopped error due to cancellation
850        let result = compute_gainmap(&hdr, &sdr, &config, ImmediateCancel);
851
852        assert!(matches!(
853            result,
854            Err(crate::Error::Stopped(enough::StopReason::Cancelled))
855        ));
856    }
857}