ph-color 0.1.1

Fixed-point no_std color math for embedded targets: conversion, transfer functions, matrices, gain, and interpolation
Documentation
//! Policy-free bit-depth reduction for the dither seam.
//!
//! Color ends at quantization. These functions either truncate a [`Q0_16`]
//! channel and return the discarded residual, or apply the crate's fixed
//! round-to-nearest convention. Parameterized rounding, noise, and dither
//! policy belong on the far side of the seam. The `BITS`-wide code itself is
//! not [`Q0_16`] — it is a narrower, differently-scaled domain that starts
//! here and ends at [`expand`].

use crate::fixed::Q0_16;

/// Truncate a [`Q0_16`] channel toward zero to a `BITS`-wide code.
///
/// `BITS` must be in `1..=16`. The quantized value `q` occupies the low
/// `BITS` bits (`0..=2^BITS-1`). [`expand`] reconstructs the truncated
/// [`Q0_16`] value (zeros in discarded bits). The residual is the signed
/// error in UQ0.16 units: `v - expand::<BITS>(q)`.
///
/// For unsigned channels, truncation toward zero never yields a negative
/// residual; the type is [`i32`] so a later dither stage can carry a signed
/// error without this crate choosing a rounding policy.
#[must_use]
pub const fn quantize<const BITS: u32>(v: Q0_16) -> (u16, i32) {
    const { assert!(BITS >= 1 && BITS <= 16, "BITS must be in 1..=16") };
    let shift = 16u32.saturating_sub(BITS);
    let q = match v.to_raw().checked_shr(shift) {
        Some(code) => code,
        None => 0,
    };
    let residual = (v.to_raw() as i32).saturating_sub(expand::<BITS>(q).to_raw() as i32);
    (q, residual)
}

/// Round a [`Q0_16`] channel to the nearest `BITS`-wide code.
///
/// `BITS` must be in `1..=16`. Ties round away from zero, matching the
/// rounding used by matrix and gain operations. The result saturates at
/// `2^BITS - 1`.
///
/// Because [`expand`] zero-fills discarded bits, full scale has no code to
/// round up into: inputs in the top half-bin all map to `2^BITS - 1`, so the
/// top bin is one and a half bins wide. The bottom bin is correspondingly
/// half width. Both asymmetries are deliberate — the alternative at the top
/// is emitting an out-of-range code.
///
/// This is the zero-parameter case of bit-depth reduction. Any decision that
/// takes a threshold, position, frame, or accumulator is dither policy and is
/// not provided here; use [`quantize`] and own the decision.
#[must_use]
pub const fn quantize_round<const BITS: u32>(v: Q0_16) -> u16 {
    const { assert!(BITS >= 1 && BITS <= 16, "BITS must be in 1..=16") };
    let (q, residual) = quantize::<BITS>(v);
    let max = match 1u16.checked_shl(BITS) {
        Some(width) => width.saturating_sub(1),
        None => u16::MAX,
    };
    let half = 1i32 << 15u32.saturating_sub(BITS);
    if residual >= half && q < max {
        q.saturating_add(1)
    } else {
        q
    }
}

/// Reconstruct a [`Q0_16`] value from a `BITS`-wide quantized code.
///
/// `BITS` must be in `1..=16`. Only the low `BITS` bits of `q` are used; they
/// are shifted into the high bits of the [`Q0_16`] result so discarded bits
/// are zeros. This is lossless with respect to [`quantize`]:
/// `expand::<BITS>(quantize::<BITS>(v).0) + residual == v`.
#[must_use]
pub const fn expand<const BITS: u32>(q: u16) -> Q0_16 {
    const { assert!(BITS >= 1 && BITS <= 16, "BITS must be in 1..=16") };
    let shift = 16u32.saturating_sub(BITS);
    let code = match 1u16.checked_shl(BITS) {
        None => q,
        Some(width) => q & width.saturating_sub(1),
    };
    match code.checked_shl(shift) {
        Some(v) => Q0_16::from_raw(v),
        None => Q0_16::ZERO,
    }
}

#[cfg(test)]
mod tests {
    use super::{Q0_16, expand, quantize, quantize_round};

    fn max_code<const BITS: u32>() -> u16 {
        match 1u16.checked_shl(BITS) {
            Some(width) => width.saturating_sub(1),
            None => u16::MAX,
        }
    }

    fn expected_rounded<const BITS: u32>(v: u16) -> u16 {
        let shift = 16u32.saturating_sub(BITS);
        let bin = 1u32.checked_shl(shift).unwrap_or(1);
        let half = bin.checked_shr(1).unwrap_or(0);
        let numerator = u32::from(v).saturating_add(half);
        let rounded = numerator.checked_div(bin).unwrap_or(0);
        rounded.min(u32::from(max_code::<BITS>())) as u16
    }

    fn rounded_matches_reference<const BITS: u32>(v: u16) {
        assert_eq!(
            quantize_round::<BITS>(Q0_16::from_raw(v)),
            expected_rounded::<BITS>(v),
            "BITS={BITS} v={v}"
        );
    }

    fn rounded_excursion_is_bounded<const BITS: u32>(v: u16) {
        let q = quantize::<BITS>(Q0_16::from_raw(v)).0;
        let rounded = quantize_round::<BITS>(Q0_16::from_raw(v));
        assert!(
            rounded == q || rounded == q.saturating_add(1),
            "BITS={BITS} v={v} q={q} rounded={rounded}"
        );
    }

    fn rounded_is_in_range<const BITS: u32>(v: u16) {
        let rounded = quantize_round::<BITS>(Q0_16::from_raw(v));
        let max = max_code::<BITS>();
        assert!(
            rounded <= max,
            "BITS={BITS} v={v} rounded={rounded} max={max}"
        );
    }

    fn full_scale_clamps<const BITS: u32>() {
        assert_eq!(
            quantize_round::<BITS>(Q0_16::ONE),
            max_code::<BITS>(),
            "BITS={BITS}"
        );
    }

    fn tie_rounds_up<const BITS: u32>() {
        let half = 1u16.checked_shl(15u32.saturating_sub(BITS)).unwrap_or(0);
        assert_eq!(
            quantize::<BITS>(Q0_16::from_raw(half)),
            (0, i32::from(half)),
            "BITS={BITS}"
        );
        assert_eq!(
            quantize_round::<BITS>(Q0_16::from_raw(half)),
            1,
            "BITS={BITS}"
        );
    }

    fn rounded_is_consistent_with_quantize<const BITS: u32>(v: u16) {
        let (q, residual) = quantize::<BITS>(Q0_16::from_raw(v));
        let half = 1i32 << 15u32.saturating_sub(BITS);
        let increment = u16::from(residual >= half);
        let expected = q.saturating_add(increment).min(max_code::<BITS>());
        assert_eq!(
            quantize_round::<BITS>(Q0_16::from_raw(v)),
            expected,
            "BITS={BITS} v={v} q={q} residual={residual}"
        );
    }

    fn identity_holds<const BITS: u32>(v: u16) {
        let (q, residual) = quantize::<BITS>(Q0_16::from_raw(v));
        let reconstructed = (expand::<BITS>(q).to_raw() as i32).saturating_add(residual);
        assert_eq!(reconstructed, i32::from(v), "BITS={BITS} v={v}");
    }

    #[test]
    fn edges_identity() {
        identity_holds::<1>(0);
        identity_holds::<1>(1);
        identity_holds::<1>(65535);
        identity_holds::<8>(0);
        identity_holds::<8>(1);
        identity_holds::<8>(65535);
        identity_holds::<16>(0);
        identity_holds::<16>(1);
        identity_holds::<16>(65535);
    }

    #[test]
    fn bits16_is_identity_with_zero_residual() {
        assert_eq!(quantize::<16>(Q0_16::from_raw(0)), (0, 0));
        assert_eq!(quantize::<16>(Q0_16::from_raw(1)), (1, 0));
        assert_eq!(quantize::<16>(Q0_16::from_raw(65535)), (65535, 0));
        assert_eq!(expand::<16>(0xABCD), Q0_16::from_raw(0xABCD));
    }

    #[test]
    fn truncates_toward_zero_without_rounding() {
        // BITS=8 discards 8 LSBs. 0x8080 is the midpoint of that bin;
        // rounding-to-nearest would bump the 8-bit code from 0x80 to 0x81.
        let (q, residual) = quantize::<8>(Q0_16::from_raw(0x8080));
        assert_eq!(q, 0x80);
        assert_eq!(residual, 0x80);
        assert_eq!(expand::<8>(q), Q0_16::from_raw(0x8000));
        assert_ne!(q, 0x81);
    }

    #[test]
    fn expand_uses_only_the_bits_wide_code() {
        assert_eq!(expand::<8>(0x80), Q0_16::from_raw(0x8000));
        assert_eq!(expand::<8>(0x80FF), Q0_16::from_raw(0xFF00));
    }

    #[test]
    fn exhaustive_identity_all_valid_bits() {
        for v in 0..=u16::MAX {
            identity_holds::<1>(v);
            identity_holds::<2>(v);
            identity_holds::<3>(v);
            identity_holds::<4>(v);
            identity_holds::<5>(v);
            identity_holds::<6>(v);
            identity_holds::<7>(v);
            identity_holds::<8>(v);
            identity_holds::<9>(v);
            identity_holds::<10>(v);
            identity_holds::<11>(v);
            identity_holds::<12>(v);
            identity_holds::<13>(v);
            identity_holds::<14>(v);
            identity_holds::<15>(v);
            identity_holds::<16>(v);
        }
    }

    #[test]
    fn quantize_round_matches_round_half_away_from_zero_exhaustively() {
        for v in 0..=u16::MAX {
            rounded_matches_reference::<1>(v);
            rounded_matches_reference::<2>(v);
            rounded_matches_reference::<3>(v);
            rounded_matches_reference::<4>(v);
            rounded_matches_reference::<5>(v);
            rounded_matches_reference::<6>(v);
            rounded_matches_reference::<7>(v);
            rounded_matches_reference::<8>(v);
            rounded_matches_reference::<9>(v);
            rounded_matches_reference::<10>(v);
            rounded_matches_reference::<11>(v);
            rounded_matches_reference::<12>(v);
            rounded_matches_reference::<13>(v);
            rounded_matches_reference::<14>(v);
            rounded_matches_reference::<15>(v);
            rounded_matches_reference::<16>(v);
        }
    }

    #[test]
    fn quantize_round_moves_at_most_one_code_exhaustively() {
        for v in 0..=u16::MAX {
            rounded_excursion_is_bounded::<1>(v);
            rounded_excursion_is_bounded::<2>(v);
            rounded_excursion_is_bounded::<3>(v);
            rounded_excursion_is_bounded::<4>(v);
            rounded_excursion_is_bounded::<5>(v);
            rounded_excursion_is_bounded::<6>(v);
            rounded_excursion_is_bounded::<7>(v);
            rounded_excursion_is_bounded::<8>(v);
            rounded_excursion_is_bounded::<9>(v);
            rounded_excursion_is_bounded::<10>(v);
            rounded_excursion_is_bounded::<11>(v);
            rounded_excursion_is_bounded::<12>(v);
            rounded_excursion_is_bounded::<13>(v);
            rounded_excursion_is_bounded::<14>(v);
            rounded_excursion_is_bounded::<15>(v);
            rounded_excursion_is_bounded::<16>(v);
        }
    }

    #[test]
    fn quantize_round_stays_in_range_exhaustively() {
        for v in 0..=u16::MAX {
            rounded_is_in_range::<1>(v);
            rounded_is_in_range::<2>(v);
            rounded_is_in_range::<3>(v);
            rounded_is_in_range::<4>(v);
            rounded_is_in_range::<5>(v);
            rounded_is_in_range::<6>(v);
            rounded_is_in_range::<7>(v);
            rounded_is_in_range::<8>(v);
            rounded_is_in_range::<9>(v);
            rounded_is_in_range::<10>(v);
            rounded_is_in_range::<11>(v);
            rounded_is_in_range::<12>(v);
            rounded_is_in_range::<13>(v);
            rounded_is_in_range::<14>(v);
            rounded_is_in_range::<15>(v);
            rounded_is_in_range::<16>(v);
        }
    }

    #[test]
    fn quantize_round_clamps_full_scale_at_every_width() {
        full_scale_clamps::<1>();
        full_scale_clamps::<2>();
        full_scale_clamps::<3>();
        full_scale_clamps::<4>();
        full_scale_clamps::<5>();
        full_scale_clamps::<6>();
        full_scale_clamps::<7>();
        full_scale_clamps::<8>();
        full_scale_clamps::<9>();
        full_scale_clamps::<10>();
        full_scale_clamps::<11>();
        full_scale_clamps::<12>();
        full_scale_clamps::<13>();
        full_scale_clamps::<14>();
        full_scale_clamps::<15>();
        full_scale_clamps::<16>();
    }

    #[test]
    fn quantize_round_is_identity_at_16_bits_exhaustively() {
        for v in 0..=u16::MAX {
            assert_eq!(quantize_round::<16>(Q0_16::from_raw(v)), v, "v={v}");
        }
    }

    #[test]
    fn quantize_round_ties_round_up_at_every_reduced_width() {
        tie_rounds_up::<1>();
        tie_rounds_up::<2>();
        tie_rounds_up::<3>();
        tie_rounds_up::<4>();
        tie_rounds_up::<5>();
        tie_rounds_up::<6>();
        tie_rounds_up::<7>();
        tie_rounds_up::<8>();
        tie_rounds_up::<9>();
        tie_rounds_up::<10>();
        tie_rounds_up::<11>();
        tie_rounds_up::<12>();
        tie_rounds_up::<13>();
        tie_rounds_up::<14>();
        tie_rounds_up::<15>();
        // BITS=16 discards no bits, so no integer UQ0.16 input can carry an
        // exact half-bin residual. Its exhaustive identity test covers it.
    }

    #[test]
    fn quantize_round_is_consistent_with_quantize_exhaustively() {
        for v in 0..=u16::MAX {
            rounded_is_consistent_with_quantize::<1>(v);
            rounded_is_consistent_with_quantize::<2>(v);
            rounded_is_consistent_with_quantize::<3>(v);
            rounded_is_consistent_with_quantize::<4>(v);
            rounded_is_consistent_with_quantize::<5>(v);
            rounded_is_consistent_with_quantize::<6>(v);
            rounded_is_consistent_with_quantize::<7>(v);
            rounded_is_consistent_with_quantize::<8>(v);
            rounded_is_consistent_with_quantize::<9>(v);
            rounded_is_consistent_with_quantize::<10>(v);
            rounded_is_consistent_with_quantize::<11>(v);
            rounded_is_consistent_with_quantize::<12>(v);
            rounded_is_consistent_with_quantize::<13>(v);
            rounded_is_consistent_with_quantize::<14>(v);
            rounded_is_consistent_with_quantize::<15>(v);
            rounded_is_consistent_with_quantize::<16>(v);
        }
    }

    #[test]
    fn quantize_round_matches_reference_values() {
        assert_eq!(quantize_round::<1>(Q0_16::from_raw(0xFFFF)), 1);
        assert_eq!(quantize_round::<1>(Q0_16::from_raw(0x8080)), 1);
        assert_eq!(quantize_round::<1>(Q0_16::from_raw(0x007F)), 0);
        assert_eq!(quantize_round::<4>(Q0_16::from_raw(0xFFFF)), 15);
        assert_eq!(quantize_round::<4>(Q0_16::from_raw(0x8080)), 8);
        assert_eq!(quantize_round::<4>(Q0_16::from_raw(0x007F)), 0);
        assert_eq!(quantize_round::<8>(Q0_16::from_raw(0xFFFF)), 255);
        assert_eq!(quantize_round::<8>(Q0_16::from_raw(0x8080)), 129);
        assert_eq!(quantize_round::<8>(Q0_16::from_raw(0x007F)), 0);
        assert_eq!(quantize_round::<15>(Q0_16::from_raw(0xFFFF)), 32767);
        assert_eq!(quantize_round::<15>(Q0_16::from_raw(0x8080)), 16448);
        assert_eq!(quantize_round::<15>(Q0_16::from_raw(0x007F)), 64);
        assert_eq!(quantize_round::<16>(Q0_16::from_raw(0xFFFF)), 65535);
        assert_eq!(quantize_round::<16>(Q0_16::from_raw(0x8080)), 32896);
        assert_eq!(quantize_round::<16>(Q0_16::from_raw(0x007F)), 127);
    }
}