Skip to main content

otf_pixels_codec_jpeg/
idct.rs

1//! The inverse DCT, in fixed point.
2//!
3//! # Why fixed point
4//!
5//! ADR-0011: integer arithmetic gives the same answer on every machine, which
6//! is what lets the engine promise byte-identical output run to run and across
7//! thread counts. A float IDCT would be a per-target result, and "the same
8//! image twice" would stop being a testable property.
9//!
10//! The transform is the usual even/odd decomposition of the 8-point IDCT
11//! (Loeffler-Ligtenberg-Moerkotte), applied to columns then rows. Constants
12//! are scaled by 2^12; the column pass keeps two extra bits of precision for
13//! the row pass to spend, so the passes shift right by 10 and 17. The two
14//! constant scales and the two shifts leave a net 2^-3, which is exactly the
15//! 1/8 a normalized 2-D IDCT ends on.
16//!
17//! Accuracy is within the ±1 tolerance the JPEG conformance spec allows, which
18//! is why decoded output is compared against reference decoders with a
19//! tolerance rather than for equality — no two IDCTs agree exactly, and the
20//! format does not require them to.
21
22/// Fixed-point scale for the transform constants.
23///
24/// Shared with the forward transform in [`crate::fdct`]: the two directions
25/// are built from the same eight constants, and letting them drift apart
26/// would be a silent accuracy bug in whichever one was not being tested.
27pub(crate) const SCALE: u32 = 12;
28
29/// Right shift applied after the column pass, keeping two bits of headroom.
30const COLUMN_SHIFT: u32 = SCALE - 2;
31
32/// Right shift applied after the row pass, spending that headroom.
33const ROW_SHIFT: u32 = SCALE + 5;
34
35/// The rounding term for a right shift of `bits`.
36pub(crate) const fn rounding(bits: u32) -> i64 {
37    1 << (bits - 1)
38}
39
40/// Round a float constant into [`SCALE`]-bit fixed point.
41const fn fixed(value: f64) -> i64 {
42    (value * (1_i64 << SCALE) as f64 + 0.5) as i64
43}
44
45// The eight distinct constants the decomposition needs.
46pub(crate) const C_0_298: i64 = fixed(0.298_631_336);
47pub(crate) const C_0_390: i64 = fixed(0.390_180_644);
48pub(crate) const C_0_541: i64 = fixed(0.541_196_100);
49pub(crate) const C_0_765: i64 = fixed(0.765_366_865);
50pub(crate) const C_0_899: i64 = fixed(0.899_976_223);
51pub(crate) const C_1_175: i64 = fixed(1.175_875_602);
52pub(crate) const C_1_501: i64 = fixed(1.501_321_110);
53pub(crate) const C_1_847: i64 = fixed(1.847_759_065);
54pub(crate) const C_1_961: i64 = fixed(1.961_570_560);
55pub(crate) const C_2_053: i64 = fixed(2.053_119_869);
56pub(crate) const C_2_562: i64 = fixed(2.562_915_447);
57pub(crate) const C_3_072: i64 = fixed(3.072_711_026);
58
59/// One 8-point inverse DCT, returning values scaled by 2^`SCALE`.
60///
61/// Splitting even and odd inputs is what makes this eleven multiplies instead
62/// of sixty-four: the even half is a 4-point transform, and the odd half's
63/// four outputs share three of their products.
64fn transform([s0, s1, s2, s3, s4, s5, s6, s7]: [i64; 8]) -> [i64; 8] {
65    // Even part: inputs 0, 2, 4, 6.
66    let shared = (s2 + s6) * C_0_541;
67    let even2 = shared - (s6 * C_1_847);
68    let even3 = shared + (s2 * C_0_765);
69    let sum = (s0 + s4) << SCALE;
70    let difference = (s0 - s4) << SCALE;
71
72    let x0 = sum + even3;
73    let x3 = sum - even3;
74    let x1 = difference + even2;
75    let x2 = difference - even2;
76
77    // Odd part: inputs 1, 3, 5, 7. The four cross sums below are each used
78    // twice, which is the saving the decomposition exists for.
79    let a = s7 + s3;
80    let b = s5 + s1;
81    let c = s7 + s1;
82    let d = s5 + s3;
83    let common = (c + d) * C_1_175;
84
85    let p1 = common - (c * C_0_899);
86    let p2 = common - (d * C_2_562);
87    let p3 = -(a * C_1_961);
88    let p4 = -(b * C_0_390);
89
90    let y0 = (s7 * C_0_298) + p1 + p3;
91    let y1 = (s5 * C_2_053) + p2 + p4;
92    let y2 = (s3 * C_3_072) + p2 + p3;
93    let y3 = (s1 * C_1_501) + p1 + p4;
94
95    [
96        x0 + y3,
97        x1 + y2,
98        x2 + y1,
99        x3 + y0,
100        x3 - y0,
101        x2 - y1,
102        x1 - y2,
103        x0 - y3,
104    ]
105}
106
107/// Inverse-transform one dequantized block into 8 rows of `out`, level-shifted
108/// and clamped to `u8`.
109///
110/// `out` is written at `offset` with `stride` bytes between rows, so blocks
111/// land directly in a component plane with no intermediate copy. Rows that
112/// fall outside `out` are dropped rather than being an error: the bottom and
113/// right edges of an image are padded to whole blocks by the format, and that
114/// padding has nowhere to go.
115pub fn block(coefficients: &[i32; 64], out: &mut [u8], offset: usize, stride: usize) {
116    // A block with no AC terms — most of a smooth photograph — is one value.
117    // It is computed through the same two passes' arithmetic, so the shortcut
118    // changes no output: the column pass makes column 0 `flat` and the rest
119    // zero, and the row pass turns `[flat, 0, ..]` into `flat << SCALE`.
120    let [dc, ac @ ..] = coefficients;
121    if ac.iter().all(|&value| value == 0) {
122        let flat = ((i64::from(*dc) << SCALE) + rounding(COLUMN_SHIFT)) >> COLUMN_SHIFT;
123        let shifted = ((flat << SCALE) + rounding(ROW_SHIFT)) >> ROW_SHIFT;
124        let value = (shifted + 128).clamp(0, 255) as u8;
125        for row in 0..8 {
126            // Clipped like the general path: edge padding has nowhere to go.
127            let start = offset + row * stride;
128            if let Some(target) = out.get_mut(start..(start + 8).min(out.len())) {
129                target.fill(value);
130            }
131        }
132        return;
133    }
134
135    let mut columns = [0_i64; 64];
136
137    for column in 0..8 {
138        let input: [i64; 8] =
139            std::array::from_fn(|row| i64::from(*coefficients.get(column + row * 8).unwrap_or(&0)));
140
141        // An all-zero AC column — the common case in flat areas — is a
142        // constant, so the whole transform collapses to one broadcast.
143        let [dc, rest @ ..] = input;
144        if rest.iter().all(|&value| value == 0) {
145            let flat = ((dc << SCALE) + rounding(COLUMN_SHIFT)) >> COLUMN_SHIFT;
146            for row in 0..8 {
147                if let Some(slot) = columns.get_mut(column + row * 8) {
148                    *slot = flat;
149                }
150            }
151            continue;
152        }
153
154        let output = transform(input);
155        for (row, value) in output.into_iter().enumerate() {
156            if let Some(slot) = columns.get_mut(column + row * 8) {
157                *slot = (value + rounding(COLUMN_SHIFT)) >> COLUMN_SHIFT;
158            }
159        }
160    }
161
162    for row in 0..8 {
163        let input: [i64; 8] =
164            std::array::from_fn(|column| *columns.get(column + row * 8).unwrap_or(&0));
165        // As for columns: a row with no AC terms transforms to a constant.
166        let [first, rest @ ..] = input;
167        let output = if rest.iter().all(|&value| value == 0) {
168            [first << SCALE; 8]
169        } else {
170            transform(input)
171        };
172
173        let Some(target) = out.get_mut(offset + row * stride..) else {
174            continue;
175        };
176        for (slot, value) in target.iter_mut().take(8).zip(output) {
177            // Undo the column pass's headroom and both constant scales, then
178            // re-centre: JPEG codes samples as signed values around zero, so
179            // the 128 the encoder subtracted comes back here.
180            let shifted = (value + rounding(ROW_SHIFT)) >> ROW_SHIFT;
181            *slot = (shifted + 128).clamp(0, 255) as u8;
182        }
183    }
184}
185
186/// How much of an image a decode produces, as eighths of full size.
187///
188/// # What it buys
189///
190/// A downsampled block can be produced directly from its coefficients, so a
191/// thumbnail never materializes the full-resolution image for the rest of the
192/// pipeline to carry: at 1/8 the resize, colour conversion and every later op
193/// see one sixty-fourth of the pixels. The reduced transform is also cheaper
194/// than the full one at 1/8 and 1/4, though at 1/2 it is not — it folds all
195/// sixty-four coefficients into sixteen outputs. The saving that matters is
196/// downstream, not in the transform.
197///
198/// This is a *resolution* reduction, not random access: rows still arrive top
199/// to bottom, and every coefficient is still entropy-decoded. A scaled decode
200/// is therefore a decoder configuration rather than a
201/// [`DecodeCapability`](otf_pixels_core::DecodeCapability).
202#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
203#[non_exhaustive]
204pub enum Scale {
205    /// 1/8 scale: the DC coefficient alone.
206    Eighth,
207    /// 1/4 scale.
208    Quarter,
209    /// 1/2 scale.
210    Half,
211    /// Full resolution.
212    #[default]
213    Full,
214}
215
216impl Scale {
217    /// Every scale, coarsest first.
218    pub const ALL: [Self; 4] = [Self::Eighth, Self::Quarter, Self::Half, Self::Full];
219
220    /// The side of the block this scale produces, in samples: `M` of `M/8`.
221    #[must_use]
222    pub const fn block_size(self) -> u32 {
223        match self {
224            Self::Eighth => 1,
225            Self::Quarter => 2,
226            Self::Half => 4,
227            Self::Full => 8,
228        }
229    }
230
231    /// The size `source` decodes to at this scale.
232    ///
233    /// Rounded up, as the format requires: an image 61 pixels wide is 8 blocks
234    /// across, and at 1/8 scale those 8 blocks are 8 samples.
235    #[must_use]
236    pub const fn apply(self, source: u32) -> u32 {
237        let scaled = source.div_ceil(8 / self.block_size());
238        // A one-pixel image still has a block, and so still has a sample.
239        if scaled == 0 { 1 } else { scaled }
240    }
241
242    /// The coarsest scale still at least `target` in both axes.
243    ///
244    /// Never returns a scale that would decode *below* the target: enlarging
245    /// afterwards would throw away detail and then invent it back, which is
246    /// worse than decoding one step larger and shrinking properly.
247    #[must_use]
248    pub fn fitting(source: (u32, u32), target: (u32, u32)) -> Self {
249        Self::ALL
250            .into_iter()
251            .find(|scale| scale.apply(source.0) >= target.0 && scale.apply(source.1) >= target.1)
252            .unwrap_or(Self::Full)
253    }
254}
255
256/// Basis tables for the reduced transforms: `BOX_M[u][x]` maps coefficient
257/// `u` onto reduced output `x`, scaled by `2^SCALE`.
258///
259/// # What these are
260///
261/// Each entry is `C(u) * (M/8) * sum over the full-resolution samples that
262/// output `x` covers, of cos((2t+1)*u*pi/16)`. That makes the reduced
263/// transform *exactly* the box average of the full one — the average of the
264/// `8/M` samples it replaces — rather than an approximation of it.
265///
266/// The obvious alternative, inverse-transforming only the top-left `MxM`
267/// coefficients, is not the same thing and is measurably worse: it discards
268/// the high-frequency coefficients outright instead of folding their
269/// contribution into the average, so detailed images come out visibly wrong.
270/// Against libjpeg's own scaled decode on a noise fixture at 1/2, truncation
271/// was ten times further from the true downsample than this is.
272///
273/// Note `u = 4` contributing nothing at 1/2 scale, and every odd-indexed
274/// coefficient contributing nothing at 1/8: those are the terms that average
275/// to zero over their group. libjpeg's reduced transforms drop exactly the
276/// same terms, which is a useful sign that both are computing the same thing.
277///
278/// Hardcoded rather than computed, because `cos` is not available in a `const`
279/// context and because a value computed at run time could differ in its last
280/// bit between targets — which ADR-0011 forbids. The tests check every entry
281/// against the real cosines, so a transcription slip fails loudly.
282const BOX_1: [[i64; 1]; 8] = [[2896], [0], [0], [0], [0], [0], [0], [0]];
283
284/// The 1/4-scale basis.
285const BOX_2: [[i64; 2]; 8] = [
286    [2896, 2896],
287    [2624, -2624],
288    [0, 0],
289    [-922, 922],
290    [0, 0],
291    [616, -616],
292    [0, 0],
293    [-522, 522],
294];
295
296/// The 1/2-scale basis.
297const BOX_4: [[i64; 4]; 8] = [
298    [2896, 2896, 2896, 2896],
299    [3711, 1537, -1537, -3711],
300    [2676, -2676, -2676, 2676],
301    [1303, -3146, 3146, -1303],
302    [0, 0, 0, 0],
303    [-871, 2102, -2102, 871],
304    [-1108, 1108, 1108, -1108],
305    [-738, -306, 306, 738],
306];
307
308/// Inverse-transform a block at a reduced scale, into `MxM` samples.
309///
310/// Both passes multiply by a `2^SCALE` constant and the transform carries the
311/// 1/4 every 2-D IDCT ends on, so the result is descaled by `2 * SCALE + 2` —
312/// the same total the full transform applies, which is what makes the two
313/// agree on a flat block.
314fn reduced<const M: usize>(
315    coefficients: &[i32; 64],
316    basis: &[[i64; M]; 8],
317    out: &mut [u8],
318    offset: usize,
319    stride: usize,
320) {
321    // Rows first. Every coefficient row contributes, unlike a truncating
322    // transform: that is the whole difference between this and a crop of the
323    // low frequencies.
324    let mut rows = [[0_i64; M]; 8];
325    for (v, row) in rows.iter_mut().enumerate() {
326        for (u, weights) in basis.iter().enumerate() {
327            // Most coefficients in a real block are zero, and skipping them
328            // is worth more than any arrangement of the loop below.
329            let coefficient = i64::from(coefficients.get(v * 8 + u).copied().unwrap_or(0));
330            if coefficient == 0 {
331                continue;
332            }
333            for (slot, &weight) in row.iter_mut().zip(weights.iter()) {
334                *slot += coefficient * weight;
335            }
336        }
337    }
338
339    for y in 0..M {
340        let Some(target) = out.get_mut(offset + y * stride..) else {
341            continue;
342        };
343        for (x, slot) in target.iter_mut().take(M).enumerate() {
344            let mut sum = 0_i64;
345            for (v, weights) in basis.iter().enumerate() {
346                let value = rows.get(v).and_then(|row| row.get(x)).copied().unwrap_or(0);
347                if value != 0 {
348                    sum += value * weights.get(y).copied().unwrap_or(0);
349                }
350            }
351            let shifted = (sum + rounding(2 * SCALE + 2)) >> (2 * SCALE + 2);
352            *slot = (shifted + 128).clamp(0, 255) as u8;
353        }
354    }
355}
356
357/// Inverse-transform one dequantized block at `scale`.
358///
359/// Writes `scale.block_size()` rows of that many samples at `offset`, so a
360/// caller's plane geometry scales with the same factor and nothing else in the
361/// decoder needs to know a reduced transform happened.
362pub fn scaled_block(
363    coefficients: &[i32; 64],
364    scale: Scale,
365    out: &mut [u8],
366    offset: usize,
367    stride: usize,
368) {
369    match scale {
370        Scale::Eighth => reduced(coefficients, &BOX_1, out, offset, stride),
371        Scale::Quarter => reduced(coefficients, &BOX_2, out, offset, stride),
372        Scale::Half => reduced(coefficients, &BOX_4, out, offset, stride),
373        // The full transform is separable and specialized; the general form
374        // above would be correct but several times slower.
375        Scale::Full => block(coefficients, out, offset, stride),
376    }
377}
378
379#[cfg(test)]
380#[allow(
381    clippy::unwrap_used,
382    clippy::indexing_slicing,
383    reason = "tests operate on known-good values and assert shapes directly"
384)]
385mod tests {
386    use super::*;
387
388    /// The textbook floating-point IDCT, to check the fixed-point one against.
389    fn reference(coefficients: &[i32; 64]) -> [f64; 64] {
390        let mut out = [0.0; 64];
391        for y in 0..8 {
392            for x in 0..8 {
393                let mut sum = 0.0;
394                for v in 0..8 {
395                    for u in 0..8 {
396                        let cu = if u == 0 { 1.0 / 2.0_f64.sqrt() } else { 1.0 };
397                        let cv = if v == 0 { 1.0 / 2.0_f64.sqrt() } else { 1.0 };
398                        sum += cu
399                            * cv
400                            * f64::from(coefficients[v * 8 + u])
401                            * (((2 * x + 1) as f64 * u as f64 * std::f64::consts::PI) / 16.0).cos()
402                            * (((2 * y + 1) as f64 * v as f64 * std::f64::consts::PI) / 16.0).cos();
403                    }
404                }
405                out[y * 8 + x] = sum / 4.0 + 128.0;
406            }
407        }
408        out
409    }
410
411    /// Both passes through `transform`, with neither shortcut: what `block`
412    /// must reproduce exactly.
413    fn without_shortcuts(coefficients: &[i32; 64]) -> Vec<u8> {
414        let mut columns = [0_i64; 64];
415        for column in 0..8 {
416            let input: [i64; 8] =
417                std::array::from_fn(|row| i64::from(coefficients[column + row * 8]));
418            for (row, value) in transform(input).into_iter().enumerate() {
419                columns[column + row * 8] = (value + rounding(COLUMN_SHIFT)) >> COLUMN_SHIFT;
420            }
421        }
422        let mut out = vec![0_u8; 64];
423        for row in 0..8 {
424            let input: [i64; 8] = std::array::from_fn(|column| columns[column + row * 8]);
425            for (column, value) in transform(input).into_iter().enumerate() {
426                let shifted = (value + rounding(ROW_SHIFT)) >> ROW_SHIFT;
427                out[row * 8 + column] = (shifted + 128).clamp(0, 255) as u8;
428            }
429        }
430        out
431    }
432
433    #[test]
434    fn the_shortcuts_change_no_output() {
435        // DC-only blocks, across and beyond the legal range.
436        for dc in -20_000..=20_000 {
437            let mut coefficients = [0_i32; 64];
438            coefficients[0] = dc;
439            assert_eq!(
440                decode(&coefficients),
441                without_shortcuts(&coefficients),
442                "dc {dc}"
443            );
444        }
445        // Sparse blocks, which leave whole rows with no AC terms after the
446        // column pass.
447        let mut state = 0x9E37_79B9_u32;
448        for _ in 0..20_000 {
449            let mut coefficients = [0_i32; 64];
450            for _ in 0..(state % 6) {
451                state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
452                let at = (state >> 8) as usize % 64;
453                coefficients[at] = ((state >> 16) % 2001) as i32 - 1000;
454            }
455            state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
456            assert_eq!(
457                decode(&coefficients),
458                without_shortcuts(&coefficients),
459                "{coefficients:?}"
460            );
461        }
462    }
463
464    fn decode(coefficients: &[i32; 64]) -> Vec<u8> {
465        let mut out = vec![0_u8; 64];
466        block(coefficients, &mut out, 0, 8);
467        out
468    }
469
470    #[test]
471    fn a_dc_only_block_is_a_flat_grey() {
472        // DC of 8*16 is a level of 16 above the 128 mid-point.
473        let mut coefficients = [0_i32; 64];
474        coefficients[0] = 8 * 16;
475        assert!(decode(&coefficients).iter().all(|&v| v == 144));
476
477        // A zero block is the mid-grey the level shift centres on.
478        assert!(decode(&[0; 64]).iter().all(|&v| v == 128));
479    }
480
481    #[test]
482    fn output_matches_the_reference_idct_within_one_step() {
483        // A spread of blocks: single frequencies, a ramp, and a noisy one.
484        let mut cases: Vec<[i32; 64]> = Vec::new();
485        for index in [0, 1, 8, 9, 27, 63] {
486            let mut block = [0_i32; 64];
487            block[index] = 200;
488            cases.push(block);
489            let mut negative = [0_i32; 64];
490            negative[index] = -300;
491            cases.push(negative);
492        }
493        let mut ramp = [0_i32; 64];
494        for (index, slot) in ramp.iter_mut().enumerate() {
495            *slot = (index as i32 % 7) * 20 - 60;
496        }
497        ramp[0] = 400;
498        cases.push(ramp);
499
500        let mut noisy = [0_i32; 64];
501        let mut state = 12_345_u32;
502        for slot in &mut noisy {
503            state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
504            *slot = ((state >> 16) as i32 % 512) - 256;
505        }
506        cases.push(noisy);
507
508        for coefficients in cases {
509            let ours = decode(&coefficients);
510            let theirs = reference(&coefficients);
511            for (index, (&got, &want)) in ours.iter().zip(theirs.iter()).enumerate() {
512                let want = want.clamp(0.0, 255.0);
513                assert!(
514                    (f64::from(got) - want).abs() <= 1.0,
515                    "sample {index}: got {got}, reference {want:.3}"
516                );
517            }
518        }
519    }
520
521    #[test]
522    fn the_flat_column_shortcut_agrees_with_the_full_transform() {
523        // Only the DC and one AC row are set, so most columns take the
524        // shortcut and one does not. Both must land on the same answer as the
525        // reference transform.
526        let mut coefficients = [0_i32; 64];
527        coefficients[0] = 300;
528        coefficients[3] = -120;
529        let ours = decode(&coefficients);
530        let theirs = reference(&coefficients);
531        for (&got, &want) in ours.iter().zip(theirs.iter()) {
532            assert!((f64::from(got) - want.clamp(0.0, 255.0)).abs() <= 1.0);
533        }
534    }
535
536    #[test]
537    fn extreme_coefficients_clamp_instead_of_wrapping() {
538        // A maximally negative and a maximally positive DC, which without
539        // clamping would wrap around the byte range.
540        let mut coefficients = [0_i32; 64];
541        coefficients[0] = -32_768;
542        assert!(decode(&coefficients).iter().all(|&v| v == 0));
543        coefficients[0] = 32_767;
544        assert!(decode(&coefficients).iter().all(|&v| v == 255));
545    }
546
547    #[test]
548    fn the_basis_tables_match_the_averages_they_stand_for() {
549        // The tables are hardcoded because `cos` is not const and because a
550        // value computed at run time could differ in its last bit between
551        // targets. This is what stops that from being a place a typo hides.
552        let expect = |m: usize, u: usize, x: usize| -> i64 {
553            let group = 8 / m;
554            let c = if u == 0 { 1.0 / 2.0_f64.sqrt() } else { 1.0 };
555            let sum: f64 = (0..group)
556                .map(|k| {
557                    let t = x * group + k;
558                    (((2 * t + 1) as f64 * u as f64 * std::f64::consts::PI) / 16.0).cos()
559                })
560                .sum();
561            (c * (m as f64 / 8.0) * sum * f64::from(1_i32 << SCALE)).round() as i64
562        };
563        for (u, row) in BOX_1.iter().enumerate() {
564            for (x, &value) in row.iter().enumerate() {
565                assert_eq!(value, expect(1, u, x), "BOX_1[{u}][{x}]");
566            }
567        }
568        for (u, row) in BOX_2.iter().enumerate() {
569            for (x, &value) in row.iter().enumerate() {
570                assert_eq!(value, expect(2, u, x), "BOX_2[{u}][{x}]");
571            }
572        }
573        for (u, row) in BOX_4.iter().enumerate() {
574            for (x, &value) in row.iter().enumerate() {
575                assert_eq!(value, expect(4, u, x), "BOX_4[{u}][{x}]");
576            }
577        }
578    }
579
580    #[test]
581    fn a_scaled_block_averages_to_what_the_full_block_averages_to() {
582        // The defining property of a reduced IDCT: it is the full block
583        // low-pass filtered, so the mean must survive. A scaling error shows
584        // up here immediately, because the mean is carried entirely by the DC
585        // coefficient the reduced transform keeps.
586        let mut state = 5_150_u32;
587        for _ in 0..40 {
588            let mut coefficients = [0_i32; 64];
589            for slot in &mut coefficients {
590                state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
591                *slot = ((state >> 20) as i32 % 50) - 25;
592            }
593            coefficients[0] = 200;
594
595            let mut full = vec![0_u8; 64];
596            block(&coefficients, &mut full, 0, 8);
597            // Clamping would break the premise rather than the transform: a
598            // sample pinned at 0 or 255 has had energy discarded, and the
599            // means would then legitimately differ. The AC range above keeps
600            // the block clear of both ends, and this is what proves it.
601            assert!(
602                full.iter().all(|&v| v > 0 && v < 255),
603                "the test block clamped; the mean comparison would be vacuous"
604            );
605            let mean = full.iter().map(|&v| u32::from(v)).sum::<u32>() as f64 / 64.0;
606
607            for scale in [Scale::Eighth, Scale::Quarter, Scale::Half] {
608                let m = scale.block_size() as usize;
609                let mut small = vec![0_u8; m * m];
610                scaled_block(&coefficients, scale, &mut small, 0, m);
611                let got = small.iter().map(|&v| u32::from(v)).sum::<u32>() as f64 / (m * m) as f64;
612                assert!(
613                    (got - mean).abs() <= 1.5,
614                    "{scale:?}: mean {got:.2} against the full block's {mean:.2}"
615                );
616            }
617        }
618    }
619
620    #[test]
621    fn a_flat_block_stays_flat_at_every_scale() {
622        let mut coefficients = [0_i32; 64];
623        coefficients[0] = 8 * 16;
624        for scale in Scale::ALL {
625            let m = scale.block_size() as usize;
626            let mut out = vec![0_u8; m * m];
627            scaled_block(&coefficients, scale, &mut out, 0, m);
628            assert!(
629                out.iter().all(|&v| v == 144),
630                "{scale:?}: {out:?} is not a flat 144"
631            );
632        }
633    }
634
635    #[test]
636    fn a_scaled_block_is_a_box_downsample_of_the_full_one() {
637        // Not "close to" but *is*: the basis is defined as the average over
638        // the samples each reduced output replaces, so the only difference
639        // from box-averaging the full transform is fixed-point rounding in
640        // two places. This is the property the whole design rests on, and the
641        // one a truncating transform would fail outright.
642        let mut coefficients = [0_i32; 64];
643        coefficients[0] = 400;
644        coefficients[1] = -180;
645        coefficients[8] = 120;
646        coefficients[9] = 60;
647        coefficients[2] = 40;
648        coefficients[16] = -30;
649
650        let mut full = vec![0_u8; 64];
651        block(&coefficients, &mut full, 0, 8);
652
653        for scale in [Scale::Quarter, Scale::Half] {
654            let m = scale.block_size() as usize;
655            let factor = 8 / m;
656            let mut small = vec![0_u8; m * m];
657            scaled_block(&coefficients, scale, &mut small, 0, m);
658
659            for y in 0..m {
660                for x in 0..m {
661                    let mut sum = 0_u32;
662                    for dy in 0..factor {
663                        for dx in 0..factor {
664                            sum += u32::from(full[(y * factor + dy) * 8 + x * factor + dx]);
665                        }
666                    }
667                    let boxed = sum as f64 / (factor * factor) as f64;
668                    let got = f64::from(small[y * m + x]);
669                    assert!(
670                        (got - boxed).abs() <= 1.5,
671                        "{scale:?} at ({x},{y}): {got} against box average {boxed:.1}"
672                    );
673                }
674            }
675        }
676    }
677
678    #[test]
679    fn scales_map_sizes_the_way_the_format_counts_blocks() {
680        assert_eq!(Scale::Eighth.apply(64), 8);
681        assert_eq!(Scale::Quarter.apply(64), 16);
682        assert_eq!(Scale::Half.apply(64), 32);
683        assert_eq!(Scale::Full.apply(64), 64);
684        // Rounded up: 61 pixels is 8 blocks, and 8 blocks is 8 samples at 1/8.
685        assert_eq!(Scale::Eighth.apply(61), 8);
686        assert_eq!(Scale::Quarter.apply(61), 16);
687        // Never zero, however small the source.
688        assert_eq!(Scale::Eighth.apply(1), 1);
689        assert_eq!(Scale::Eighth.apply(3), 1);
690    }
691
692    #[test]
693    fn fitting_never_decodes_below_the_target() {
694        let source = (4000_u32, 3000_u32);
695        // A 200x150 thumbnail fits inside the 1/8 decode (500x375).
696        assert_eq!(Scale::fitting(source, (200, 150)), Scale::Eighth);
697        // 600 wide does not fit 1/8's 500, so the next step up is used.
698        assert_eq!(Scale::fitting(source, (600, 450)), Scale::Quarter);
699        assert_eq!(Scale::fitting(source, (1200, 900)), Scale::Half);
700        assert_eq!(Scale::fitting(source, (3000, 2250)), Scale::Full);
701        // A target larger than the source cannot be met by any scale, so the
702        // most detailed one is the right answer rather than an error.
703        assert_eq!(Scale::fitting(source, (9000, 9000)), Scale::Full);
704
705        // Both axes have to fit, not just one.
706        assert_eq!(Scale::fitting(source, (200, 400)), Scale::Quarter);
707    }
708
709    #[test]
710    fn blocks_write_at_a_stride_and_clip_at_the_end_of_the_buffer() {
711        let mut coefficients = [0_i32; 64];
712        coefficients[0] = 8 * 16;
713
714        // A 16-wide plane, block written to the right half.
715        let mut plane = vec![0_u8; 16 * 8];
716        block(&coefficients, &mut plane, 8, 16);
717        for row in 0..8 {
718            assert_eq!(&plane[row * 16..row * 16 + 8], &[0_u8; 8]);
719            assert_eq!(&plane[row * 16 + 8..row * 16 + 16], &[144_u8; 8]);
720        }
721
722        // A plane with only three rows: the rest of the block is dropped.
723        let mut short = vec![0_u8; 8 * 3];
724        block(&coefficients, &mut short, 0, 8);
725        assert!(short.iter().all(|&v| v == 144));
726    }
727}