Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
60 changes: 57 additions & 3 deletions crates/halflight/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Box<[f32]>> = 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 {
Expand All @@ -313,7 +339,10 @@ pub fn encode_srgb(src: &[f32]) -> Vec<u8> {
}

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 {
Expand Down Expand Up @@ -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<Box<[f32]>> = 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.
Expand Down Expand Up @@ -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}"
);
}
}
}
31 changes: 31 additions & 0 deletions crates/halflight/tests/transfer16.rs
Original file line number Diff line number Diff line change
@@ -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");
}
9 changes: 6 additions & 3 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading