Skip to main content

j2k_transcode/
dct97_2d.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Constrained 2D DCT to irreversible 9/7 wavelet transforms.
4//!
5//! The production float path performs a separable 8x8 IDCT into a reusable
6//! spatial plane, then applies the separable single-level 9/7 transform.
7
8use rayon::prelude::*;
9
10use crate::allocation::{
11    checked_add_allocation_bytes, checked_allocation_bytes, checked_allocation_len,
12    checked_capacity_bytes, try_vec_filled, try_vec_reserve_len, try_vec_resize_with,
13    TranscodeAllocationError,
14};
15use crate::dct_grid::{high_len, idct8_basis_table, low_len, validate_dct_block_grid};
16use crate::{DctTransformError, Dwt97TwoDimensional};
17
18#[cfg(test)]
19use crate::dct_grid::idct8_basis;
20
21const ALPHA: f64 = j2k_codec_math::dwt::DWT97_ALPHA_F64;
22const BETA: f64 = j2k_codec_math::dwt::DWT97_BETA_F64;
23const GAMMA: f64 = j2k_codec_math::dwt::DWT97_GAMMA_F64;
24const DELTA: f64 = j2k_codec_math::dwt::DWT97_DELTA_F64;
25const KAPPA: f64 = j2k_codec_math::dwt::DWT97_KAPPA_F64;
26const INV_KAPPA: f64 = j2k_codec_math::dwt::DWT97_INV_KAPPA_F64;
27const PARALLEL_IDCT_MIN_SAMPLES: usize = 64 * 64;
28
29#[cfg(test)]
30impl Dwt97TwoDimensional<f64> {
31    /// Maximum absolute coefficient difference across matching bands.
32    #[must_use]
33    pub(crate) fn max_abs_diff(&self, other: &Self) -> f64 {
34        assert_eq!(self.low_width, other.low_width);
35        assert_eq!(self.low_height, other.low_height);
36        assert_eq!(self.high_width, other.high_width);
37        assert_eq!(self.high_height, other.high_height);
38
39        self.ll
40            .iter()
41            .zip(other.ll.iter())
42            .chain(self.hl.iter().zip(other.hl.iter()))
43            .chain(self.lh.iter().zip(other.lh.iter()))
44            .chain(self.hh.iter().zip(other.hh.iter()))
45            .map(|(actual, expected)| (actual - expected).abs())
46            .fold(0.0, f64::max)
47    }
48}
49
50/// Scratch storage for repeated DCT-grid to 9/7 transform calls.
51#[derive(Debug, Default)]
52pub struct Dct97GridScratch {
53    geometry: Option<(usize, usize)>,
54    spatial_samples: Vec<f64>,
55    plane: Dwt97PlaneScratch,
56}
57
58#[derive(Debug, Default)]
59struct Dwt97PlaneScratch {
60    row_low: Vec<f64>,
61    row_high: Vec<f64>,
62    lift_workspace: Vec<f64>,
63}
64
65impl Dct97GridScratch {
66    pub(crate) fn retained_bytes(&self) -> Result<usize, TranscodeAllocationError> {
67        let mut total = checked_capacity_bytes::<f64>(self.spatial_samples.capacity())?;
68        for capacity in [
69            self.plane.row_low.capacity(),
70            self.plane.row_high.capacity(),
71            self.plane.lift_workspace.capacity(),
72        ] {
73            total = checked_add_allocation_bytes(total, checked_capacity_bytes::<f64>(capacity)?)?;
74        }
75        Ok(total)
76    }
77
78    #[cfg(test)]
79    fn spatial_sample_capacity(&self) -> usize {
80        self.spatial_samples.capacity()
81    }
82}
83
84/// Reference path for a DCT block grid:
85/// DCT coefficients -> float IDCT samples -> separable linearized 9/7.
86pub fn dct8x8_blocks_then_dwt97_float(
87    blocks: &[[[f64; 8]; 8]],
88    block_cols: usize,
89    block_rows: usize,
90    width: usize,
91    height: usize,
92) -> Result<Dwt97TwoDimensional<f64>, DctTransformError> {
93    let mut scratch = Dct97GridScratch::default();
94    dct8x8_blocks_then_dwt97_float_with_scratch(
95        blocks,
96        block_cols,
97        block_rows,
98        width,
99        height,
100        &mut scratch,
101    )
102}
103
104/// Reference 9/7 path with caller-owned spatial-sample scratch:
105/// DCT coefficients -> float IDCT samples -> separable linearized 9/7.
106pub fn dct8x8_blocks_then_dwt97_float_with_scratch(
107    blocks: &[[[f64; 8]; 8]],
108    block_cols: usize,
109    block_rows: usize,
110    width: usize,
111    height: usize,
112    scratch: &mut Dct97GridScratch,
113) -> Result<Dwt97TwoDimensional<f64>, DctTransformError> {
114    validate_grid(blocks.len(), block_cols, block_rows, width, height)?;
115    let sample_count = checked_allocation_len::<f64>(width, height)?;
116    validate_grid_workspace(sample_count, width, height)?;
117    if scratch.geometry != Some((width, height)) {
118        scratch.plane = Dwt97PlaneScratch::default();
119        scratch.geometry = Some((width, height));
120    }
121
122    try_vec_resize_with(&mut scratch.spatial_samples, sample_count, || 0.0)?;
123    let block_row_sample_count = checked_allocation_len::<u8>(width, 8)?;
124    idct8x8_blocks_to_samples(
125        blocks,
126        block_cols,
127        width,
128        height,
129        block_row_sample_count,
130        &mut scratch.spatial_samples,
131    );
132
133    linearized_97_2d_from_plane_with_plane_scratch(
134        &scratch.spatial_samples,
135        width,
136        height,
137        &mut scratch.plane,
138    )
139}
140
141#[cfg(test)]
142pub(crate) fn linearized_97_2d_from_plane(
143    samples: &[f64],
144    width: usize,
145    height: usize,
146) -> Result<Dwt97TwoDimensional<f64>, DctTransformError> {
147    let mut scratch = Dct97GridScratch::default();
148    linearized_97_2d_from_plane_with_scratch(samples, width, height, &mut scratch)
149}
150
151pub(crate) fn linearized_97_2d_from_plane_with_scratch(
152    samples: &[f64],
153    width: usize,
154    height: usize,
155    scratch: &mut Dct97GridScratch,
156) -> Result<Dwt97TwoDimensional<f64>, DctTransformError> {
157    let sample_count = validate_sample_plane(samples, width, height)?;
158    validate_plane_workspace(sample_count, width, height)?;
159    if scratch.geometry == Some((width, height)) {
160        scratch.spatial_samples = Vec::new();
161    } else {
162        *scratch = Dct97GridScratch::default();
163        scratch.geometry = Some((width, height));
164    }
165    linearized_97_2d_from_plane_with_plane_scratch(samples, width, height, &mut scratch.plane)
166}
167
168fn linearized_97_2d_from_plane_with_plane_scratch(
169    samples: &[f64],
170    width: usize,
171    height: usize,
172    scratch: &mut Dwt97PlaneScratch,
173) -> Result<Dwt97TwoDimensional<f64>, DctTransformError> {
174    let low_width = low_len(width);
175    let low_height = low_len(height);
176    let high_width = high_len(width);
177    let high_height = high_len(height);
178
179    try_vec_resize_with(
180        &mut scratch.row_low,
181        checked_allocation_len::<f64>(height, low_width)?,
182        || 0.0,
183    )?;
184    try_vec_resize_with(
185        &mut scratch.row_high,
186        checked_allocation_len::<f64>(height, high_width)?,
187        || 0.0,
188    )?;
189    scratch.lift_workspace.clear();
190    try_vec_reserve_len(&mut scratch.lift_workspace, width.max(height))?;
191
192    for y in 0..height {
193        let start = y * width;
194        let row = &samples[start..start + width];
195        let low_start = y * low_width;
196        let high_start = y * high_width;
197        linearized_97_split_contiguous_into(
198            row,
199            &mut scratch.row_low[low_start..low_start + low_width],
200            &mut scratch.row_high[high_start..high_start + high_width],
201            &mut scratch.lift_workspace,
202        )?;
203    }
204
205    let mut ll = try_vec_filled(checked_allocation_len::<f64>(low_width, low_height)?, 0.0)?;
206    let mut lh = try_vec_filled(checked_allocation_len::<f64>(low_width, high_height)?, 0.0)?;
207    for x in 0..low_width {
208        linearized_97_split_strided_into(
209            Dwt97StridedSplit {
210                samples: &scratch.row_low,
211                stride: low_width,
212                height,
213                band_width: low_width,
214            },
215            x,
216            &mut ll,
217            &mut lh,
218            &mut scratch.lift_workspace,
219        )?;
220    }
221
222    let mut hl = try_vec_filled(checked_allocation_len::<f64>(high_width, low_height)?, 0.0)?;
223    let mut hh = try_vec_filled(checked_allocation_len::<f64>(high_width, high_height)?, 0.0)?;
224    for x in 0..high_width {
225        linearized_97_split_strided_into(
226            Dwt97StridedSplit {
227                samples: &scratch.row_high,
228                stride: high_width,
229                height,
230                band_width: high_width,
231            },
232            x,
233            &mut hl,
234            &mut hh,
235            &mut scratch.lift_workspace,
236        )?;
237    }
238
239    Ok(Dwt97TwoDimensional {
240        ll,
241        hl,
242        lh,
243        hh,
244        low_width,
245        low_height,
246        high_width,
247        high_height,
248    })
249}
250
251#[cfg(test)]
252fn idct8x8_sample(block: &[[f64; 8]; 8], x: usize, y: usize) -> f64 {
253    let mut sample = 0.0;
254    for (freq_y, row) in block.iter().enumerate() {
255        let y_basis = idct8_basis(y, freq_y);
256        for (freq_x, coefficient) in row.iter().copied().enumerate() {
257            sample += coefficient * y_basis * idct8_basis(x, freq_x);
258        }
259    }
260    sample
261}
262
263fn idct8x8_blocks_to_samples(
264    blocks: &[[[f64; 8]; 8]],
265    block_cols: usize,
266    width: usize,
267    height: usize,
268    block_row_sample_count: usize,
269    samples: &mut [f64],
270) {
271    let sample_count = samples.len();
272    let basis = idct8_basis_table();
273    let active_block_cols = width.div_ceil(8);
274    let active_block_rows = height.div_ceil(8);
275    let row_context = Idct8x8RowContext {
276        blocks,
277        block_cols,
278        width,
279        height,
280        basis,
281        active_block_cols,
282    };
283
284    if sample_count >= PARALLEL_IDCT_MIN_SAMPLES {
285        samples
286            .par_chunks_mut(block_row_sample_count)
287            .enumerate()
288            .take(active_block_rows)
289            .for_each(|(block_y, sample_rows)| {
290                idct8x8_block_row_to_samples(&row_context, block_y, sample_rows);
291            });
292    } else {
293        for block_y in 0..active_block_rows {
294            let block_sample_y = block_y * 8;
295            let output_rows = (height - block_sample_y).min(8);
296            let row_start = block_sample_y * width;
297            let row_end = row_start + output_rows * width;
298            idct8x8_block_row_to_samples(&row_context, block_y, &mut samples[row_start..row_end]);
299        }
300    }
301}
302
303#[derive(Clone, Copy)]
304struct Idct8x8RowContext<'a> {
305    blocks: &'a [[[f64; 8]; 8]],
306    block_cols: usize,
307    width: usize,
308    height: usize,
309    basis: &'a [[f64; 8]; 8],
310    active_block_cols: usize,
311}
312
313fn idct8x8_block_row_to_samples(
314    context: &Idct8x8RowContext<'_>,
315    block_y: usize,
316    sample_rows: &mut [f64],
317) {
318    let Idct8x8RowContext {
319        blocks,
320        block_cols,
321        width,
322        height,
323        basis,
324        active_block_cols,
325    } = *context;
326    let block_sample_y = block_y * 8;
327    let output_rows = (height - block_sample_y).min(8);
328    for block_x in 0..active_block_cols {
329        let block_sample_x = block_x * 8;
330        let output_cols = (width - block_sample_x).min(8);
331        let block = &blocks[block_y * block_cols + block_x];
332        let mut vertical = [[0.0; 8]; 8];
333
334        for (local_y, basis_row) in basis.iter().enumerate() {
335            for freq_x in 0..8 {
336                let mut sum = 0.0;
337                for (freq_y, block_row) in block.iter().enumerate() {
338                    sum += basis_row[freq_y] * block_row[freq_x];
339                }
340                vertical[local_y][freq_x] = sum;
341            }
342        }
343
344        for (local_y, vertical_row) in vertical.iter().enumerate().take(output_rows) {
345            let row_offset = local_y * width + block_sample_x;
346            for local_x in 0..output_cols {
347                let mut sample = 0.0;
348                for (freq_x, vertical_value) in vertical_row.iter().enumerate() {
349                    sample += *vertical_value * basis[local_x][freq_x];
350                }
351                sample_rows[row_offset + local_x] = sample;
352            }
353        }
354    }
355}
356
357#[cfg(test)]
358fn linearized_97_from_sample_slice(samples: &[f64]) -> Dwt97OneDimensional {
359    let mut lifted = samples.to_vec();
360    forward_lift_97(&mut lifted);
361
362    Dwt97OneDimensional {
363        low: lifted.iter().step_by(2).copied().collect(),
364        high: lifted.iter().skip(1).step_by(2).copied().collect(),
365    }
366}
367
368fn forward_lift_97(data: &mut [f64]) {
369    let n = data.len();
370    if n < 2 {
371        return;
372    }
373
374    let last_even = if n.is_multiple_of(2) { n - 2 } else { n - 1 };
375
376    for i in (1..n).step_by(2) {
377        let left = data[i - 1];
378        let right = if i + 1 < n {
379            data[i + 1]
380        } else {
381            data[last_even]
382        };
383        data[i] += ALPHA * (left + right);
384    }
385
386    for i in (0..n).step_by(2) {
387        let left = if i > 0 { data[i - 1] } else { data[1] };
388        let right = if i + 1 < n { data[i + 1] } else { left };
389        data[i] += BETA * (left + right);
390    }
391
392    for i in (1..n).step_by(2) {
393        let left = data[i - 1];
394        let right = if i + 1 < n {
395            data[i + 1]
396        } else {
397            data[last_even]
398        };
399        data[i] += GAMMA * (left + right);
400    }
401
402    for i in (0..n).step_by(2) {
403        let left = if i > 0 { data[i - 1] } else { data[1] };
404        let right = if i + 1 < n { data[i + 1] } else { left };
405        data[i] += DELTA * (left + right);
406    }
407
408    for i in (0..n).step_by(2) {
409        data[i] *= INV_KAPPA;
410    }
411    for i in (1..n).step_by(2) {
412        data[i] *= KAPPA;
413    }
414}
415
416fn linearized_97_split_contiguous_into(
417    samples: &[f64],
418    low: &mut [f64],
419    high: &mut [f64],
420    workspace: &mut Vec<f64>,
421) -> Result<(), DctTransformError> {
422    debug_assert_eq!(low.len(), low_len(samples.len()));
423    debug_assert_eq!(high.len(), high_len(samples.len()));
424
425    workspace.clear();
426    try_vec_reserve_len(workspace, samples.len())?;
427    workspace.extend_from_slice(samples);
428    forward_lift_97(workspace);
429
430    for (target, value) in low.iter_mut().zip(workspace.iter().step_by(2)) {
431        *target = *value;
432    }
433    for (target, value) in high.iter_mut().zip(workspace.iter().skip(1).step_by(2)) {
434        *target = *value;
435    }
436    Ok(())
437}
438
439#[derive(Clone, Copy)]
440struct Dwt97StridedSplit<'a> {
441    samples: &'a [f64],
442    stride: usize,
443    height: usize,
444    band_width: usize,
445}
446
447fn linearized_97_split_strided_into(
448    input: Dwt97StridedSplit<'_>,
449    x: usize,
450    low: &mut [f64],
451    high: &mut [f64],
452    workspace: &mut Vec<f64>,
453) -> Result<(), DctTransformError> {
454    let Dwt97StridedSplit {
455        samples,
456        stride,
457        height,
458        band_width,
459    } = input;
460    debug_assert_eq!(low.len(), band_width * low_len(height));
461    debug_assert_eq!(high.len(), band_width * high_len(height));
462
463    workspace.clear();
464    try_vec_reserve_len(workspace, height)?;
465    for y in 0..height {
466        workspace.push(samples[y * stride + x]);
467    }
468    forward_lift_97(workspace);
469
470    for (low_y, value) in workspace.iter().step_by(2).enumerate() {
471        low[low_y * band_width + x] = *value;
472    }
473    for (high_y, value) in workspace.iter().skip(1).step_by(2).enumerate() {
474        high[high_y * band_width + x] = *value;
475    }
476    Ok(())
477}
478
479fn validate_sample_plane(
480    samples: &[f64],
481    width: usize,
482    height: usize,
483) -> Result<usize, DctTransformError> {
484    if width == 0 || height == 0 {
485        return Err(DctTransformError::InvalidSamplePlaneDimensions { width, height });
486    }
487    let sample_count = checked_allocation_len::<f64>(width, height)?;
488    if samples.len() != sample_count {
489        return Err(DctTransformError::SamplePlaneLengthMismatch {
490            sample_count: samples.len(),
491            width,
492            height,
493        });
494    }
495    Ok(sample_count)
496}
497
498fn validate_grid_workspace(
499    sample_count: usize,
500    width: usize,
501    height: usize,
502) -> Result<(), DctTransformError> {
503    let spatial_bytes = checked_allocation_bytes::<f64>(sample_count)?;
504    checked_add_allocation_bytes(
505        spatial_bytes,
506        plane_workspace_bytes(sample_count, width, height)?,
507    )?;
508    Ok(())
509}
510
511fn validate_plane_workspace(
512    sample_count: usize,
513    width: usize,
514    height: usize,
515) -> Result<(), DctTransformError> {
516    plane_workspace_bytes(sample_count, width, height)?;
517    Ok(())
518}
519
520fn plane_workspace_bytes(
521    sample_count: usize,
522    width: usize,
523    height: usize,
524) -> Result<usize, DctTransformError> {
525    let row_and_output_bytes = allocation_product_bytes::<f64>(sample_count, 2)?;
526    let lift_bytes = checked_allocation_bytes::<f64>(width.max(height))?;
527    Ok(checked_add_allocation_bytes(
528        row_and_output_bytes,
529        lift_bytes,
530    )?)
531}
532
533fn allocation_product_bytes<T>(left: usize, right: usize) -> Result<usize, DctTransformError> {
534    let element_count = checked_allocation_len::<T>(left, right)?;
535    Ok(checked_allocation_bytes::<T>(element_count)?)
536}
537
538fn validate_grid(
539    block_count: usize,
540    block_cols: usize,
541    block_rows: usize,
542    width: usize,
543    height: usize,
544) -> Result<(), DctTransformError> {
545    validate_dct_block_grid(block_count, block_cols, block_rows, width, height)?;
546    Ok(())
547}
548
549#[cfg(test)]
550struct Dwt97OneDimensional {
551    low: Vec<f64>,
552    high: Vec<f64>,
553}
554
555#[cfg(test)]
556mod tests {
557    use core::f64::consts::PI;
558
559    use super::*;
560
561    #[test]
562    fn grid_workspace_rejects_aggregate_before_any_single_vector_hits_cap() {
563        let cap = j2k_core::DEFAULT_MAX_HOST_ALLOCATION_BYTES;
564        let sample_count = cap / core::mem::size_of::<f64>() / 4 + 1;
565        assert!(checked_allocation_bytes::<f64>(sample_count).is_ok());
566        assert!(matches!(
567            validate_grid_workspace(sample_count, sample_count, 1),
568            Err(DctTransformError::MemoryCapExceeded { requested, cap: limit })
569                if requested > limit && limit == cap
570        ));
571    }
572
573    fn assert_all_close(values: &[f64], expected: f64, epsilon: f64) {
574        for &value in values {
575            assert!(
576                (value - expected).abs() < epsilon,
577                "value={value} expected={expected} values={values:?}"
578            );
579        }
580    }
581
582    #[test]
583    fn linearized_97_from_constant_signal_places_dc_in_low_pass() {
584        for len in [2usize, 3, 8, 9, 64, 65] {
585            let samples = vec![50.0; len];
586
587            let transformed = linearized_97_from_sample_slice(&samples);
588
589            assert_all_close(&transformed.low, 50.0, 0.001);
590            assert_all_close(&transformed.high, 0.0, 0.001);
591        }
592    }
593
594    #[test]
595    fn linearized_97_2d_from_constant_plane_places_dc_in_ll() {
596        for (width, height) in [(8usize, 8usize), (9, 7)] {
597            let samples = vec![50.0; width * height];
598
599            let transformed = linearized_97_2d_from_plane(&samples, width, height)
600                .expect("small test plane fits the transform workspace");
601
602            assert_all_close(&transformed.ll, 50.0, 0.001);
603            assert_all_close(&transformed.hl, 0.0, 0.001);
604            assert_all_close(&transformed.lh, 0.0, 0.001);
605            assert_all_close(&transformed.hh, 0.0, 0.001);
606        }
607    }
608
609    // -------------------------------------------------------------------------
610    // Independent CDF 9/7 ground truth.
611    //
612    // The CUDA 9/7 kernel is parity-tested against `forward_lift_97` /
613    // `linearized_97_2d_from_plane`, so a bug in the lifting would be faithfully
614    // reproduced by the kernel and pass that parity test unnoticed. These tests
615    // close that gap by validating the lifting against an *independent*
616    // implementation: a direct FIR filter bank using the canonical, fully
617    // normalized CDF 9/7 analysis taps and JPEG2000 whole-sample symmetric
618    // extension. Different arithmetic, same transform.
619    //
620    // The taps themselves are checked against their defining mathematical
621    // properties (DC gains and high-pass vanishing moments) so they cannot
622    // silently drift to "match" a buggy lifting.
623
624    /// Canonical CDF 9/7 analysis low-pass filter (9 taps, even-symmetric).
625    /// Fully normalized so its DC gain is 1 (a constant maps unchanged into the
626    /// low band, matching the lifting's `INV_KAPPA` scaling).
627    const REF_LP: [f64; 9] = [
628        0.026_748_757_410_810,
629        -0.016_864_118_442_875,
630        -0.078_223_266_528_990,
631        0.266_864_118_442_875,
632        0.602_949_018_236_360,
633        0.266_864_118_442_875,
634        -0.078_223_266_528_990,
635        -0.016_864_118_442_875,
636        0.026_748_757_410_810,
637    ];
638
639    /// Canonical CDF 9/7 analysis high-pass filter (7 taps, even-symmetric).
640    /// Fully normalized so its DC gain is 0 (matching the lifting's `KAPPA`
641    /// scaling); it has four vanishing moments.
642    const REF_HP: [f64; 7] = [
643        0.091_271_763_114_250,
644        -0.057_543_526_228_500,
645        -0.591_271_763_114_247,
646        1.115_087_052_456_994,
647        -0.591_271_763_114_247,
648        -0.057_543_526_228_500,
649        0.091_271_763_114_250,
650    ];
651
652    /// Whole-sample symmetric reflection: mirror about index 0 and `n - 1`
653    /// without repeating the endpoints. This is the boundary extension
654    /// `forward_lift_97` implements at the array edges.
655    fn ws_reflect(i: isize, n: usize) -> usize {
656        debug_assert!(n >= 1);
657        if n == 1 {
658            return 0;
659        }
660        let n = isize::try_from(n).expect("signal length fits in isize");
661        let period = 2 * (n - 1);
662        let mut k = i.rem_euclid(period);
663        if k >= n {
664            k = period - k;
665        }
666        usize::try_from(k).expect("reflected index is non-negative")
667    }
668
669    /// Independent single-level forward 9/7 analysis via direct convolution.
670    /// Returns `(low, high)` interleaved-position bands matching `forward_lift_97`
671    /// (`low[m]` centered at sample `2m`, `high[m]` centered at sample `2m + 1`).
672    fn ref_analysis_1d(signal: &[f64]) -> (Vec<f64>, Vec<f64>) {
673        let n = signal.len();
674        if n < 2 {
675            // The lifting leaves <2-length signals unchanged (low = the sample).
676            return (signal.to_vec(), Vec::new());
677        }
678        let mut low = vec![0.0; low_len(n)];
679        let mut high = vec![0.0; high_len(n)];
680        for (m, out) in low.iter_mut().enumerate() {
681            let center = 2 * isize::try_from(m).unwrap();
682            *out = REF_LP
683                .iter()
684                .enumerate()
685                .map(|(t, &tap)| {
686                    tap * signal[ws_reflect(center + isize::try_from(t).unwrap() - 4, n)]
687                })
688                .sum();
689        }
690        for (m, out) in high.iter_mut().enumerate() {
691            let center = 2 * isize::try_from(m).unwrap() + 1;
692            *out = REF_HP
693                .iter()
694                .enumerate()
695                .map(|(t, &tap)| {
696                    tap * signal[ws_reflect(center + isize::try_from(t).unwrap() - 3, n)]
697                })
698                .sum();
699        }
700        (low, high)
701    }
702
703    /// Independent separable 2D forward 9/7 (rows then columns) producing the
704    /// same four-band layout as `linearized_97_2d_from_plane`.
705    fn ref_analysis_2d(samples: &[f64], width: usize, height: usize) -> Dwt97TwoDimensional<f64> {
706        let low_width = low_len(width);
707        let high_width = high_len(width);
708        let low_height = low_len(height);
709        let high_height = high_len(height);
710
711        let mut row_low = vec![0.0; height * low_width];
712        let mut row_high = vec![0.0; height * high_width];
713        for y in 0..height {
714            let (lo, hi) = ref_analysis_1d(&samples[y * width..y * width + width]);
715            row_low[y * low_width..y * low_width + low_width].copy_from_slice(&lo);
716            row_high[y * high_width..y * high_width + high_width].copy_from_slice(&hi);
717        }
718
719        let vertical_split = |source: &[f64], band_width: usize| -> (Vec<f64>, Vec<f64>) {
720            let mut low = vec![0.0; band_width * low_height];
721            let mut high = vec![0.0; band_width * high_height];
722            for x in 0..band_width {
723                let column: Vec<f64> = (0..height).map(|y| source[y * band_width + x]).collect();
724                let (lo, hi) = ref_analysis_1d(&column);
725                for (vy, &value) in lo.iter().enumerate() {
726                    low[vy * band_width + x] = value;
727                }
728                for (vy, &value) in hi.iter().enumerate() {
729                    high[vy * band_width + x] = value;
730                }
731            }
732            (low, high)
733        };
734
735        let (ll, lh) = vertical_split(&row_low, low_width);
736        let (hl, hh) = vertical_split(&row_high, high_width);
737
738        Dwt97TwoDimensional {
739            ll,
740            hl,
741            lh,
742            hh,
743            low_width,
744            low_height,
745            high_width,
746            high_height,
747        }
748    }
749
750    /// Small deterministic PRNG (LCG) for reproducible test signals in [-1, 1).
751    #[expect(
752        clippy::cast_precision_loss,
753        reason = "the deterministic PRNG intentionally maps its upper 53 bits into an f64 fraction"
754    )]
755    fn next_unit(state: &mut u64) -> f64 {
756        *state = state
757            .wrapping_mul(6_364_136_223_846_793_005)
758            .wrapping_add(1_442_695_040_888_963_407);
759        ((*state >> 11) as f64 / (1u64 << 53) as f64).mul_add(2.0, -1.0)
760    }
761
762    fn assert_bands_close(actual: &[f64], expected: &[f64], label: &str, epsilon: f64) {
763        assert_eq!(actual.len(), expected.len(), "{label} band length");
764        for (i, (a, b)) in actual.iter().zip(expected.iter()).enumerate() {
765            assert!(
766                (a - b).abs() <= epsilon,
767                "{label}[{i}] diverged: lifting={a} reference={b} (diff {})",
768                (a - b).abs()
769            );
770        }
771    }
772
773    #[test]
774    #[expect(
775        clippy::cast_precision_loss,
776        reason = "tiny filter-tap indices are exactly representable in f64"
777    )]
778    fn reference_cdf97_taps_satisfy_their_defining_properties() {
779        // Low-pass DC gain 1, high-pass DC gain 0 — the normalization the
780        // lifting's KAPPA scaling targets.
781        let lp_dc: f64 = REF_LP.iter().sum();
782        assert!((lp_dc - 1.0).abs() < 1e-9, "low-pass DC gain = {lp_dc}");
783        let hp_dc: f64 = REF_HP.iter().sum();
784        assert!(hp_dc.abs() < 1e-9, "high-pass DC gain = {hp_dc}");
785
786        // Even symmetry.
787        for k in 0..4 {
788            assert!(
789                (REF_LP[k] - REF_LP[8 - k]).abs() < 1e-15,
790                "low-pass asymmetric at {k}"
791            );
792        }
793        for k in 0..3 {
794            assert!(
795                (REF_HP[k] - REF_HP[6 - k]).abs() < 1e-15,
796                "high-pass asymmetric at {k}"
797            );
798        }
799
800        // Four vanishing moments: the high-pass annihilates polynomials of
801        // degree <= 3 (so a wrong predict coefficient or sign cannot pass).
802        for m in 1..=3 {
803            let moment: f64 = REF_HP
804                .iter()
805                .enumerate()
806                .map(|(k, &tap)| (k as f64 - 3.0).powi(m) * tap)
807                .sum();
808            assert!(moment.abs() < 1e-9, "high-pass moment {m} = {moment}");
809        }
810    }
811
812    #[test]
813    fn forward_lift_97_matches_independent_filter_bank_1d() {
814        let mut state = 0x1234_5678_9abc_def0u64;
815        for n in [2usize, 3, 4, 5, 8, 9, 12, 15, 16, 23, 32, 33, 64, 65] {
816            let signal: Vec<f64> = (0..n).map(|_| next_unit(&mut state) * 100.0).collect();
817            let lifted = linearized_97_from_sample_slice(&signal);
818            let (low, high) = ref_analysis_1d(&signal);
819            assert_bands_close(&lifted.low, &low, &format!("n={n} low"), 1e-9);
820            assert_bands_close(&lifted.high, &high, &format!("n={n} high"), 1e-9);
821        }
822    }
823
824    #[test]
825    #[expect(
826        clippy::cast_precision_loss,
827        reason = "the fixed forty-sample polynomial domain is exactly representable in f64"
828    )]
829    fn forward_lift_97_annihilates_low_degree_polynomials() {
830        // Independent of the filter bank: a correct 9/7 high-pass kills cubics in
831        // the interior (boundary coefficients use symmetric extension). This pins
832        // the predict-step coefficients and signs directly from wavelet theory.
833        let n = 40usize;
834        let polynomials: [[f64; 4]; 4] = [
835            [5.0, 0.0, 0.0, 0.0],
836            [0.0, 2.5, 0.0, 0.0],
837            [1.0, -0.7, 0.3, 0.0],
838            [0.0, 0.0, 0.0, 0.05],
839        ];
840        for coeffs in polynomials {
841            let signal: Vec<f64> = (0..n)
842                .map(|i| {
843                    let x = i as f64;
844                    coeffs[3].mul_add(
845                        x * x * x,
846                        coeffs[2].mul_add(x * x, coeffs[1].mul_add(x, coeffs[0])),
847                    )
848                })
849                .collect();
850            let lifted = linearized_97_from_sample_slice(&signal);
851            // Skip the first/last high-pass coefficients (boundary support).
852            let interior = &lifted.high[3..lifted.high.len() - 3];
853            assert_all_close(interior, 0.0, 1e-6);
854        }
855    }
856
857    #[test]
858    fn linearized_97_2d_matches_independent_separable_filter_bank() {
859        let mut state = 0xfeed_face_dead_beefu64;
860        for (width, height) in [
861            (8usize, 8usize),
862            (16, 16),
863            (24, 16),
864            (15, 13),
865            (16, 23),
866            (9, 7),
867            (32, 32),
868        ] {
869            let samples: Vec<f64> = (0..width * height)
870                .map(|_| next_unit(&mut state) * 100.0)
871                .collect();
872            let got = linearized_97_2d_from_plane(&samples, width, height)
873                .expect("small test plane fits the transform workspace");
874            let want = ref_analysis_2d(&samples, width, height);
875            assert_eq!(
876                (
877                    got.low_width,
878                    got.low_height,
879                    got.high_width,
880                    got.high_height
881                ),
882                (
883                    want.low_width,
884                    want.low_height,
885                    want.high_width,
886                    want.high_height
887                ),
888                "band dimensions for {width}x{height}"
889            );
890            assert_bands_close(&got.ll, &want.ll, &format!("{width}x{height} ll"), 1e-9);
891            assert_bands_close(&got.hl, &want.hl, &format!("{width}x{height} hl"), 1e-9);
892            assert_bands_close(&got.lh, &want.lh, &format!("{width}x{height} lh"), 1e-9);
893            assert_bands_close(&got.hh, &want.hh, &format!("{width}x{height} hh"), 1e-9);
894        }
895    }
896
897    #[test]
898    #[expect(
899        clippy::cast_precision_loss,
900        reason = "the fixed sixteen-sample coordinate domain is exactly representable in f64"
901    )]
902    fn linearized_97_2d_separates_horizontal_and_vertical_detail() {
903        // Catches an HL/LH swap or a row/column transpose independently of the
904        // filter bank: a plane that varies only along x has no vertical detail
905        // (LH and HH must vanish), and vice versa.
906        let (width, height) = (16usize, 16usize);
907
908        let varies_in_x: Vec<f64> = (0..width * height)
909            .map(|i| ((i % width) as f64).sin().mul_add(30.0, 5.0))
910            .collect();
911        let t = linearized_97_2d_from_plane(&varies_in_x, width, height)
912            .expect("small test plane fits the transform workspace");
913        assert_all_close(&t.lh, 0.0, 1e-9);
914        assert_all_close(&t.hh, 0.0, 1e-9);
915
916        let varies_in_y: Vec<f64> = (0..width * height)
917            .map(|i| ((i / width) as f64).cos().mul_add(30.0, 5.0))
918            .collect();
919        let t = linearized_97_2d_from_plane(&varies_in_y, width, height)
920            .expect("small test plane fits the transform workspace");
921        assert_all_close(&t.hl, 0.0, 1e-9);
922        assert_all_close(&t.hh, 0.0, 1e-9);
923    }
924
925    // -------------------------------------------------------------------------
926    // Ground truth: exact mathematical inverse DCT for the float 9/7 path.
927    //
928    // The 9/7 transcode oracle (`dct8x8_blocks_then_dwt97_float`) feeds
929    // `idct8x8_sample` into the wavelet. Validate that IDCT against the defining
930    // DCT-III cosine sum so a basis/normalization/transpose bug cannot hide
931    // inside both the oracle and its CUDA port.
932    #[expect(
933        clippy::cast_precision_loss,
934        reason = "IDCT sample and frequency indices are bounded to the 8x8 block domain"
935    )]
936    fn exact_idct_sample(block: &[[f64; 8]; 8], x: usize, y: usize) -> f64 {
937        let alpha = |k: usize| {
938            if k == 0 {
939                (1.0_f64 / 8.0).sqrt()
940            } else {
941                (2.0_f64 / 8.0).sqrt()
942            }
943        };
944        let cos_term = |sample: usize, freq: usize| {
945            (((2 * sample + 1) as f64) * freq as f64 * PI / 16.0).cos()
946        };
947        let mut acc = 0.0;
948        for (v, row) in block.iter().enumerate() {
949            for (u, &coeff) in row.iter().enumerate() {
950                acc += alpha(u) * alpha(v) * coeff * cos_term(x, u) * cos_term(y, v);
951            }
952        }
953        acc
954    }
955
956    #[test]
957    fn idct8x8_sample_matches_exact_cosine_sum() {
958        let mut state = 0x5151_aaaa_bbbb_ccccu64;
959        for _ in 0..64 {
960            let mut block = [[0.0f64; 8]; 8];
961            for row in &mut block {
962                for coeff in row {
963                    *coeff = next_unit(&mut state) * 64.0;
964                }
965            }
966            for y in 0..8 {
967                for x in 0..8 {
968                    let got = idct8x8_sample(&block, x, y);
969                    let want = exact_idct_sample(&block, x, y);
970                    assert!(
971                        (got - want).abs() < 1e-9,
972                        "idct8x8_sample({x},{y})={got} exact={want}"
973                    );
974                }
975            }
976        }
977    }
978
979    #[test]
980    fn idct8x8_sample_dc_only_is_uniform() {
981        // DC-only block -> uniform plane equal to F(0,0) / 8.
982        let mut block = [[0.0f64; 8]; 8];
983        block[0][0] = 320.0;
984        for y in 0..8 {
985            for x in 0..8 {
986                assert!((idct8x8_sample(&block, x, y) - 40.0).abs() < 1e-9);
987            }
988        }
989    }
990
991    #[test]
992    fn dct8x8_grid_to_2d_97_idct_scratch_path_reuses_spatial_storage() {
993        let large_blocks = structured_blocks(32, 32);
994        let small_blocks = structured_blocks(2, 2);
995        let mut scratch = Dct97GridScratch::default();
996
997        let large = dct8x8_blocks_then_dwt97_float_with_scratch(
998            &large_blocks,
999            32,
1000            32,
1001            255,
1002            241,
1003            &mut scratch,
1004        )
1005        .expect("scratch 9/7 IDCT path accepts covered large grid");
1006        let expected_large = dct8x8_blocks_then_dwt97_float(&large_blocks, 32, 32, 255, 241)
1007            .expect("reference 9/7 IDCT path accepts covered large grid");
1008        let capacity_after_large = scratch.spatial_sample_capacity();
1009
1010        let small =
1011            dct8x8_blocks_then_dwt97_float_with_scratch(&small_blocks, 2, 2, 13, 11, &mut scratch)
1012                .expect("scratch 9/7 IDCT path accepts covered small grid");
1013        let expected_small = dct8x8_blocks_then_dwt97_float(&small_blocks, 2, 2, 13, 11)
1014            .expect("reference 9/7 IDCT path accepts covered small grid");
1015
1016        assert!(capacity_after_large > 0);
1017        assert_eq!(scratch.spatial_sample_capacity(), capacity_after_large);
1018        assert!(large.max_abs_diff(&expected_large) < 1.0e-9);
1019        assert!(small.max_abs_diff(&expected_small) < 1.0e-9);
1020    }
1021
1022    #[expect(
1023        clippy::cast_precision_loss,
1024        reason = "small deterministic test-grid indices are exactly representable in f64"
1025    )]
1026    fn structured_blocks(block_cols: usize, block_rows: usize) -> Vec<[[f64; 8]; 8]> {
1027        let mut blocks = Vec::with_capacity(block_cols * block_rows);
1028        for block_y in 0..block_rows {
1029            for block_x in 0..block_cols {
1030                let mut block = [[0.0; 8]; 8];
1031                block[0][0] = 384.0 + (block_x * 19 + block_y * 23) as f64;
1032                block[0][1] = -17.0 + block_x as f64;
1033                block[1][0] = 11.0 - block_y as f64;
1034                block[2][3] = 7.0;
1035                block[4][4] = -3.0;
1036                block[7][7] = 2.0;
1037                blocks.push(block);
1038            }
1039        }
1040        blocks
1041    }
1042}