From 80e9f344f669f85cbf4e85d9337b11405691f7ba Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:48:01 -0400 Subject: [PATCH] feat(transfer): add exact 16-bit decode tables --- README.md | 6 +++ crates/halflight/src/lib.rs | 60 ++++++++++++++++++++++++++-- crates/halflight/tests/transfer16.rs | 31 ++++++++++++++ docs/development.md | 9 +++-- 4 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 crates/halflight/tests/transfer16.rs diff --git a/README.md b/README.md index 9c6afb3..ed0814d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ let out = resample(&lin, w, h, 3, 900, 600, Filter::Lanczos3); let rgb8 = encode_srgb(&out); // linear f32 -> u8, exact round trip ``` +For 16-bit Rust inputs, `srgb_lut16()` and `g22_lut16()` return tables with all +65,536 decoded values. Index them directly with the source samples before +resampling; precision is preserved until the final output conversion. Each +curve initializes one 256 KiB table on the heap when first used. Existing +8-bit and JavaScript callers do not allocate these tables. + Zero dependencies. `#![forbid(unsafe_code)]`. MSRV 1.75. Also on npm as a 13.8 KB (gzip) wasm module with the same three calls. diff --git a/crates/halflight/src/lib.rs b/crates/halflight/src/lib.rs index 3f9de82..7c8bc06 100644 --- a/crates/halflight/src/lib.rs +++ b/crates/halflight/src/lib.rs @@ -291,6 +291,32 @@ pub fn srgb_lut() -> &'static [f32; 256] { SRGB_LUT.get_or_init(|| std::array::from_fn(|i| srgb_to_linear_exact(i as u8))) } +static SRGB_LUT16: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// The sRGB decode table for all 65,536 unsigned 16-bit values. +/// +/// Values are normalized by 65535 and evaluated with the same transfer curve +/// as the 8-bit table. There is no interpolation or 8-bit intermediate. Take +/// the table once and index it inside the sample loop. +/// +/// Its 256 KiB allocation is initialized lazily, directly on the heap, and +/// retained for the process lifetime. Calls to the 8-bit API do not allocate it. +/// +/// ``` +/// let table = halflight::srgb_lut16(); +/// let encoded: u16 = 40000; +/// let linear: f32 = table[encoded as usize]; +/// assert!(linear > 0.0 && linear < 1.0); +/// ``` +#[inline] +pub fn srgb_lut16() -> &'static [f32] { + SRGB_LUT16.get_or_init(|| { + (0..=u16::MAX) + .map(|c| srgb_to_linear_normalized(c as f32 / 65535.0)) + .collect() + }) +} + /// sRGB-encoded 8-bit value to linear light in `0.0..=1.0`. #[inline] pub fn srgb_to_linear(c: u8) -> f32 { @@ -313,7 +339,10 @@ pub fn encode_srgb(src: &[f32]) -> Vec { } fn srgb_to_linear_exact(c: u8) -> f32 { - let s = c as f32 / 255.0; + srgb_to_linear_normalized(c as f32 / 255.0) +} + +fn srgb_to_linear_normalized(s: f32) -> f32 { if s <= 0.040_449_936 { s / 12.92 } else { @@ -345,7 +374,27 @@ static G22_LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new(); /// The gamma-2.2 decode table, 256 entries. #[inline] pub fn g22_lut() -> &'static [f32; 256] { - G22_LUT.get_or_init(|| std::array::from_fn(|i| (i as f32 / 255.0).powf(2.2))) + G22_LUT.get_or_init(|| std::array::from_fn(|i| g22_to_linear_normalized(i as f32 / 255.0))) +} + +static G22_LUT16: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// The gamma-2.2 decode table for all 65,536 unsigned 16-bit values. +/// +/// Each entry is `(code / 65535.0).powf(2.2)` in f32, with no interpolation. +/// Like [`srgb_lut16`], it allocates 256 KiB on the heap on first use and +/// retains the table for the process lifetime. Take it once outside loops. +#[inline] +pub fn g22_lut16() -> &'static [f32] { + G22_LUT16.get_or_init(|| { + (0..=u16::MAX) + .map(|c| g22_to_linear_normalized(c as f32 / 65535.0)) + .collect() + }) +} + +fn g22_to_linear_normalized(s: f32) -> f32 { + s.powf(2.2) } /// Gamma-2.2-encoded 8-bit value to linear light. @@ -517,13 +566,18 @@ mod tests { // The table is an optimisation and must not be a second opinion about sRGB. #[test] - fn the_lut_agrees_with_the_expression_it_replaces() { + fn the_luts_agree_with_the_expressions_they_replace() { for c in 0..=255u8 { assert_eq!( srgb_to_linear(c).to_bits(), srgb_to_linear_exact(c).to_bits(), "lut disagrees at {c}" ); + assert_eq!( + g22_to_linear(c).to_bits(), + (c as f32 / 255.0).powf(2.2).to_bits(), + "gamma 2.2 lut disagrees at {c}" + ); } } } diff --git a/crates/halflight/tests/transfer16.rs b/crates/halflight/tests/transfer16.rs new file mode 100644 index 0000000..b398d6d --- /dev/null +++ b/crates/halflight/tests/transfer16.rs @@ -0,0 +1,31 @@ +use halflight::{g22_lut16, srgb_lut16}; + +/// This integration-test binary has one test, so neither global table can be +/// warmed by another test before the small-stack initialization probe runs. +#[test] +fn u16_tables_initialize_on_a_small_stack_and_preserve_every_sample() { + std::thread::Builder::new() + .stack_size(128 * 1024) + .spawn(|| { + let srgb = srgb_lut16(); + let g22 = g22_lut16(); + assert_eq!(srgb.len(), 65536); + assert_eq!(g22.len(), 65536); + + // Preserve zenc's original scalar formulas as an independent + // compatibility oracle. Check all codes, including the sRGB toe. + for c in 0..=u16::MAX { + let s = c as f32 / 65535.0; + let expected_srgb = if s <= 0.040_449_936 { + s / 12.92 + } else { + ((s + 0.055) / 1.055).powf(2.4) + }; + assert_eq!(srgb[c as usize].to_bits(), expected_srgb.to_bits(), "sRGB {c}"); + assert_eq!(g22[c as usize].to_bits(), s.powf(2.2).to_bits(), "gamma 2.2 {c}"); + } + }) + .expect("start small-stack thread") + .join() + .expect("initialize both tables and compare their values"); +} diff --git a/docs/development.md b/docs/development.md index 11be270..0a06308 100644 --- a/docs/development.md +++ b/docs/development.md @@ -28,9 +28,12 @@ cd packages/halflight && npm run build && npm test # wasm + the JS surfa `resample_dyn` must stay bitwise equal, and the test asserts bits, not an epsilon. A SIMD rewrite has to preserve per-channel ascending summation or re-mint every content-addressed output anyone has built on this. -- **The transfer tables are not a second opinion about sRGB.** The LUT is - built from the exact expression it replaces and a test asserts they agree - bitwise at all 256 values. Any faster encode must keep the 8-bit round trip +- **Transfer tables preserve the scalar formulas.** The LUT is built from + the exact expression it replaces. Tests check all 256 8-bit values and all + 65,536 16-bit values bitwise. The 16-bit integration test initializes both + tables on a 128 KiB thread stack; constructing temporary arrays there would + overflow it, so those tables are built directly on the heap. + Any faster encode must keep the 8-bit round trip exact, or it is a different colour space wearing sRGB's name. - **The checkerboard test asserts the incumbents are wrong.** If `image` or `fast_image_resize` change their default to a linear-light path, that test