Skip to main content

ph_color/
quantize.rs

1//! Policy-free bit-depth reduction for the dither seam.
2//!
3//! Color ends at quantization. These functions either truncate a [`Q0_16`]
4//! channel and return the discarded residual, or apply the crate's fixed
5//! round-to-nearest convention. Parameterized rounding, noise, and dither
6//! policy belong on the far side of the seam. The `BITS`-wide code itself is
7//! not [`Q0_16`] — it is a narrower, differently-scaled domain that starts
8//! here and ends at [`expand`].
9
10use crate::fixed::Q0_16;
11
12/// Truncate a [`Q0_16`] channel toward zero to a `BITS`-wide code.
13///
14/// `BITS` must be in `1..=16`. The quantized value `q` occupies the low
15/// `BITS` bits (`0..=2^BITS-1`). [`expand`] reconstructs the truncated
16/// [`Q0_16`] value (zeros in discarded bits). The residual is the signed
17/// error in UQ0.16 units: `v - expand::<BITS>(q)`.
18///
19/// For unsigned channels, truncation toward zero never yields a negative
20/// residual; the type is [`i32`] so a later dither stage can carry a signed
21/// error without this crate choosing a rounding policy.
22#[must_use]
23pub const fn quantize<const BITS: u32>(v: Q0_16) -> (u16, i32) {
24    const { assert!(BITS >= 1 && BITS <= 16, "BITS must be in 1..=16") };
25    let shift = 16u32.saturating_sub(BITS);
26    let q = match v.to_raw().checked_shr(shift) {
27        Some(code) => code,
28        None => 0,
29    };
30    let residual = (v.to_raw() as i32).saturating_sub(expand::<BITS>(q).to_raw() as i32);
31    (q, residual)
32}
33
34/// Round a [`Q0_16`] channel to the nearest `BITS`-wide code.
35///
36/// `BITS` must be in `1..=16`. Ties round away from zero, matching the
37/// rounding used by matrix and gain operations. The result saturates at
38/// `2^BITS - 1`.
39///
40/// Because [`expand`] zero-fills discarded bits, full scale has no code to
41/// round up into: inputs in the top half-bin all map to `2^BITS - 1`, so the
42/// top bin is one and a half bins wide. The bottom bin is correspondingly
43/// half width. Both asymmetries are deliberate — the alternative at the top
44/// is emitting an out-of-range code.
45///
46/// This is the zero-parameter case of bit-depth reduction. Any decision that
47/// takes a threshold, position, frame, or accumulator is dither policy and is
48/// not provided here; use [`quantize`] and own the decision.
49#[must_use]
50pub const fn quantize_round<const BITS: u32>(v: Q0_16) -> u16 {
51    const { assert!(BITS >= 1 && BITS <= 16, "BITS must be in 1..=16") };
52    let (q, residual) = quantize::<BITS>(v);
53    let max = match 1u16.checked_shl(BITS) {
54        Some(width) => width.saturating_sub(1),
55        None => u16::MAX,
56    };
57    let half = 1i32 << 15u32.saturating_sub(BITS);
58    if residual >= half && q < max {
59        q.saturating_add(1)
60    } else {
61        q
62    }
63}
64
65/// Reconstruct a [`Q0_16`] value from a `BITS`-wide quantized code.
66///
67/// `BITS` must be in `1..=16`. Only the low `BITS` bits of `q` are used; they
68/// are shifted into the high bits of the [`Q0_16`] result so discarded bits
69/// are zeros. This is lossless with respect to [`quantize`]:
70/// `expand::<BITS>(quantize::<BITS>(v).0) + residual == v`.
71#[must_use]
72pub const fn expand<const BITS: u32>(q: u16) -> Q0_16 {
73    const { assert!(BITS >= 1 && BITS <= 16, "BITS must be in 1..=16") };
74    let shift = 16u32.saturating_sub(BITS);
75    let code = match 1u16.checked_shl(BITS) {
76        None => q,
77        Some(width) => q & width.saturating_sub(1),
78    };
79    match code.checked_shl(shift) {
80        Some(v) => Q0_16::from_raw(v),
81        None => Q0_16::ZERO,
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::{Q0_16, expand, quantize, quantize_round};
88
89    fn max_code<const BITS: u32>() -> u16 {
90        match 1u16.checked_shl(BITS) {
91            Some(width) => width.saturating_sub(1),
92            None => u16::MAX,
93        }
94    }
95
96    fn expected_rounded<const BITS: u32>(v: u16) -> u16 {
97        let shift = 16u32.saturating_sub(BITS);
98        let bin = 1u32.checked_shl(shift).unwrap_or(1);
99        let half = bin.checked_shr(1).unwrap_or(0);
100        let numerator = u32::from(v).saturating_add(half);
101        let rounded = numerator.checked_div(bin).unwrap_or(0);
102        rounded.min(u32::from(max_code::<BITS>())) as u16
103    }
104
105    fn rounded_matches_reference<const BITS: u32>(v: u16) {
106        assert_eq!(
107            quantize_round::<BITS>(Q0_16::from_raw(v)),
108            expected_rounded::<BITS>(v),
109            "BITS={BITS} v={v}"
110        );
111    }
112
113    fn rounded_excursion_is_bounded<const BITS: u32>(v: u16) {
114        let q = quantize::<BITS>(Q0_16::from_raw(v)).0;
115        let rounded = quantize_round::<BITS>(Q0_16::from_raw(v));
116        assert!(
117            rounded == q || rounded == q.saturating_add(1),
118            "BITS={BITS} v={v} q={q} rounded={rounded}"
119        );
120    }
121
122    fn rounded_is_in_range<const BITS: u32>(v: u16) {
123        let rounded = quantize_round::<BITS>(Q0_16::from_raw(v));
124        let max = max_code::<BITS>();
125        assert!(
126            rounded <= max,
127            "BITS={BITS} v={v} rounded={rounded} max={max}"
128        );
129    }
130
131    fn full_scale_clamps<const BITS: u32>() {
132        assert_eq!(
133            quantize_round::<BITS>(Q0_16::ONE),
134            max_code::<BITS>(),
135            "BITS={BITS}"
136        );
137    }
138
139    fn tie_rounds_up<const BITS: u32>() {
140        let half = 1u16.checked_shl(15u32.saturating_sub(BITS)).unwrap_or(0);
141        assert_eq!(
142            quantize::<BITS>(Q0_16::from_raw(half)),
143            (0, i32::from(half)),
144            "BITS={BITS}"
145        );
146        assert_eq!(
147            quantize_round::<BITS>(Q0_16::from_raw(half)),
148            1,
149            "BITS={BITS}"
150        );
151    }
152
153    fn rounded_is_consistent_with_quantize<const BITS: u32>(v: u16) {
154        let (q, residual) = quantize::<BITS>(Q0_16::from_raw(v));
155        let half = 1i32 << 15u32.saturating_sub(BITS);
156        let increment = u16::from(residual >= half);
157        let expected = q.saturating_add(increment).min(max_code::<BITS>());
158        assert_eq!(
159            quantize_round::<BITS>(Q0_16::from_raw(v)),
160            expected,
161            "BITS={BITS} v={v} q={q} residual={residual}"
162        );
163    }
164
165    fn identity_holds<const BITS: u32>(v: u16) {
166        let (q, residual) = quantize::<BITS>(Q0_16::from_raw(v));
167        let reconstructed = (expand::<BITS>(q).to_raw() as i32).saturating_add(residual);
168        assert_eq!(reconstructed, i32::from(v), "BITS={BITS} v={v}");
169    }
170
171    #[test]
172    fn edges_identity() {
173        identity_holds::<1>(0);
174        identity_holds::<1>(1);
175        identity_holds::<1>(65535);
176        identity_holds::<8>(0);
177        identity_holds::<8>(1);
178        identity_holds::<8>(65535);
179        identity_holds::<16>(0);
180        identity_holds::<16>(1);
181        identity_holds::<16>(65535);
182    }
183
184    #[test]
185    fn bits16_is_identity_with_zero_residual() {
186        assert_eq!(quantize::<16>(Q0_16::from_raw(0)), (0, 0));
187        assert_eq!(quantize::<16>(Q0_16::from_raw(1)), (1, 0));
188        assert_eq!(quantize::<16>(Q0_16::from_raw(65535)), (65535, 0));
189        assert_eq!(expand::<16>(0xABCD), Q0_16::from_raw(0xABCD));
190    }
191
192    #[test]
193    fn truncates_toward_zero_without_rounding() {
194        // BITS=8 discards 8 LSBs. 0x8080 is the midpoint of that bin;
195        // rounding-to-nearest would bump the 8-bit code from 0x80 to 0x81.
196        let (q, residual) = quantize::<8>(Q0_16::from_raw(0x8080));
197        assert_eq!(q, 0x80);
198        assert_eq!(residual, 0x80);
199        assert_eq!(expand::<8>(q), Q0_16::from_raw(0x8000));
200        assert_ne!(q, 0x81);
201    }
202
203    #[test]
204    fn expand_uses_only_the_bits_wide_code() {
205        assert_eq!(expand::<8>(0x80), Q0_16::from_raw(0x8000));
206        assert_eq!(expand::<8>(0x80FF), Q0_16::from_raw(0xFF00));
207    }
208
209    #[test]
210    fn exhaustive_identity_all_valid_bits() {
211        for v in 0..=u16::MAX {
212            identity_holds::<1>(v);
213            identity_holds::<2>(v);
214            identity_holds::<3>(v);
215            identity_holds::<4>(v);
216            identity_holds::<5>(v);
217            identity_holds::<6>(v);
218            identity_holds::<7>(v);
219            identity_holds::<8>(v);
220            identity_holds::<9>(v);
221            identity_holds::<10>(v);
222            identity_holds::<11>(v);
223            identity_holds::<12>(v);
224            identity_holds::<13>(v);
225            identity_holds::<14>(v);
226            identity_holds::<15>(v);
227            identity_holds::<16>(v);
228        }
229    }
230
231    #[test]
232    fn quantize_round_matches_round_half_away_from_zero_exhaustively() {
233        for v in 0..=u16::MAX {
234            rounded_matches_reference::<1>(v);
235            rounded_matches_reference::<2>(v);
236            rounded_matches_reference::<3>(v);
237            rounded_matches_reference::<4>(v);
238            rounded_matches_reference::<5>(v);
239            rounded_matches_reference::<6>(v);
240            rounded_matches_reference::<7>(v);
241            rounded_matches_reference::<8>(v);
242            rounded_matches_reference::<9>(v);
243            rounded_matches_reference::<10>(v);
244            rounded_matches_reference::<11>(v);
245            rounded_matches_reference::<12>(v);
246            rounded_matches_reference::<13>(v);
247            rounded_matches_reference::<14>(v);
248            rounded_matches_reference::<15>(v);
249            rounded_matches_reference::<16>(v);
250        }
251    }
252
253    #[test]
254    fn quantize_round_moves_at_most_one_code_exhaustively() {
255        for v in 0..=u16::MAX {
256            rounded_excursion_is_bounded::<1>(v);
257            rounded_excursion_is_bounded::<2>(v);
258            rounded_excursion_is_bounded::<3>(v);
259            rounded_excursion_is_bounded::<4>(v);
260            rounded_excursion_is_bounded::<5>(v);
261            rounded_excursion_is_bounded::<6>(v);
262            rounded_excursion_is_bounded::<7>(v);
263            rounded_excursion_is_bounded::<8>(v);
264            rounded_excursion_is_bounded::<9>(v);
265            rounded_excursion_is_bounded::<10>(v);
266            rounded_excursion_is_bounded::<11>(v);
267            rounded_excursion_is_bounded::<12>(v);
268            rounded_excursion_is_bounded::<13>(v);
269            rounded_excursion_is_bounded::<14>(v);
270            rounded_excursion_is_bounded::<15>(v);
271            rounded_excursion_is_bounded::<16>(v);
272        }
273    }
274
275    #[test]
276    fn quantize_round_stays_in_range_exhaustively() {
277        for v in 0..=u16::MAX {
278            rounded_is_in_range::<1>(v);
279            rounded_is_in_range::<2>(v);
280            rounded_is_in_range::<3>(v);
281            rounded_is_in_range::<4>(v);
282            rounded_is_in_range::<5>(v);
283            rounded_is_in_range::<6>(v);
284            rounded_is_in_range::<7>(v);
285            rounded_is_in_range::<8>(v);
286            rounded_is_in_range::<9>(v);
287            rounded_is_in_range::<10>(v);
288            rounded_is_in_range::<11>(v);
289            rounded_is_in_range::<12>(v);
290            rounded_is_in_range::<13>(v);
291            rounded_is_in_range::<14>(v);
292            rounded_is_in_range::<15>(v);
293            rounded_is_in_range::<16>(v);
294        }
295    }
296
297    #[test]
298    fn quantize_round_clamps_full_scale_at_every_width() {
299        full_scale_clamps::<1>();
300        full_scale_clamps::<2>();
301        full_scale_clamps::<3>();
302        full_scale_clamps::<4>();
303        full_scale_clamps::<5>();
304        full_scale_clamps::<6>();
305        full_scale_clamps::<7>();
306        full_scale_clamps::<8>();
307        full_scale_clamps::<9>();
308        full_scale_clamps::<10>();
309        full_scale_clamps::<11>();
310        full_scale_clamps::<12>();
311        full_scale_clamps::<13>();
312        full_scale_clamps::<14>();
313        full_scale_clamps::<15>();
314        full_scale_clamps::<16>();
315    }
316
317    #[test]
318    fn quantize_round_is_identity_at_16_bits_exhaustively() {
319        for v in 0..=u16::MAX {
320            assert_eq!(quantize_round::<16>(Q0_16::from_raw(v)), v, "v={v}");
321        }
322    }
323
324    #[test]
325    fn quantize_round_ties_round_up_at_every_reduced_width() {
326        tie_rounds_up::<1>();
327        tie_rounds_up::<2>();
328        tie_rounds_up::<3>();
329        tie_rounds_up::<4>();
330        tie_rounds_up::<5>();
331        tie_rounds_up::<6>();
332        tie_rounds_up::<7>();
333        tie_rounds_up::<8>();
334        tie_rounds_up::<9>();
335        tie_rounds_up::<10>();
336        tie_rounds_up::<11>();
337        tie_rounds_up::<12>();
338        tie_rounds_up::<13>();
339        tie_rounds_up::<14>();
340        tie_rounds_up::<15>();
341        // BITS=16 discards no bits, so no integer UQ0.16 input can carry an
342        // exact half-bin residual. Its exhaustive identity test covers it.
343    }
344
345    #[test]
346    fn quantize_round_is_consistent_with_quantize_exhaustively() {
347        for v in 0..=u16::MAX {
348            rounded_is_consistent_with_quantize::<1>(v);
349            rounded_is_consistent_with_quantize::<2>(v);
350            rounded_is_consistent_with_quantize::<3>(v);
351            rounded_is_consistent_with_quantize::<4>(v);
352            rounded_is_consistent_with_quantize::<5>(v);
353            rounded_is_consistent_with_quantize::<6>(v);
354            rounded_is_consistent_with_quantize::<7>(v);
355            rounded_is_consistent_with_quantize::<8>(v);
356            rounded_is_consistent_with_quantize::<9>(v);
357            rounded_is_consistent_with_quantize::<10>(v);
358            rounded_is_consistent_with_quantize::<11>(v);
359            rounded_is_consistent_with_quantize::<12>(v);
360            rounded_is_consistent_with_quantize::<13>(v);
361            rounded_is_consistent_with_quantize::<14>(v);
362            rounded_is_consistent_with_quantize::<15>(v);
363            rounded_is_consistent_with_quantize::<16>(v);
364        }
365    }
366
367    #[test]
368    fn quantize_round_matches_reference_values() {
369        assert_eq!(quantize_round::<1>(Q0_16::from_raw(0xFFFF)), 1);
370        assert_eq!(quantize_round::<1>(Q0_16::from_raw(0x8080)), 1);
371        assert_eq!(quantize_round::<1>(Q0_16::from_raw(0x007F)), 0);
372        assert_eq!(quantize_round::<4>(Q0_16::from_raw(0xFFFF)), 15);
373        assert_eq!(quantize_round::<4>(Q0_16::from_raw(0x8080)), 8);
374        assert_eq!(quantize_round::<4>(Q0_16::from_raw(0x007F)), 0);
375        assert_eq!(quantize_round::<8>(Q0_16::from_raw(0xFFFF)), 255);
376        assert_eq!(quantize_round::<8>(Q0_16::from_raw(0x8080)), 129);
377        assert_eq!(quantize_round::<8>(Q0_16::from_raw(0x007F)), 0);
378        assert_eq!(quantize_round::<15>(Q0_16::from_raw(0xFFFF)), 32767);
379        assert_eq!(quantize_round::<15>(Q0_16::from_raw(0x8080)), 16448);
380        assert_eq!(quantize_round::<15>(Q0_16::from_raw(0x007F)), 64);
381        assert_eq!(quantize_round::<16>(Q0_16::from_raw(0xFFFF)), 65535);
382        assert_eq!(quantize_round::<16>(Q0_16::from_raw(0x8080)), 32896);
383        assert_eq!(quantize_round::<16>(Q0_16::from_raw(0x007F)), 127);
384    }
385}