craft-texture: keep bilinear samples of finite texels finite

The four weighted texels are summed in f64 and a finite sum is clamped
to the f32 range. Rounded weights can add up to slightly more than 1,
which made a constant image of f32::MAX sample to infinity at some
coordinates. Infinite texels still give an infinite or NaN sample.

The crate docs and README now limit the OutOfMemory guarantee of the
try_ forms to the pixel buffer, and name the Arc and the try_resize
resampler as allocations that are not fallible.
This commit is contained in:
dotCooCoo committed 2026-11-13 19:52:40 -08:00
1 parent 261e7a4c5c
commit 316db97052
2 files changed
+27 -11

No files matched your search

+7 -4
View File
@@ -12,9 +12,11 @@ network I/O and has no `unsafe` code.
A plane is never larger than `isize::MAX` bytes (2 GiB minus one byte on `wasm32`), even with
raised limits: the `try_` forms return `ImageError::TooLarge` for a larger one, and `map`,
`to_color` and `to_kind` clamp each side to `1..=MAX_SIDE` and map the top-left part instead.
`try_map`, `try_to_kind`, `try_resize` and `try_to_rgba8_display` reserve their memory first
and return `ImageError::OutOfMemory` when that fails. The forms without `try_` allocate like
`Vec`, which ends the process when memory runs out.
`try_map`, `try_to_kind`, `try_resize` and `try_to_rgba8_display` reserve their pixel buffer
first and return `ImageError::OutOfMemory` when that reservation fails. They still make small
allocations that are not fallible: the `Arc` around a new image's buffer, and the boxed
resampler of `try_resize`. The forms without `try_` allocate like `Vec`. A failed infallible
allocation ends the process.
- **Sampling.** `sample` (bilinear) and `sample_nearest` take normalized `(u, v)` with `(0, 0)` at
the upper-left corner and `v` increasing downward, as glTF 2.0 section 3.8.3 defines them
(<https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html>). `tile` picks repeat (glTF
@@ -22,7 +24,8 @@ network I/O and has no `unsafe` code.
and the bilinear weights follow the Vulkan specification, sections 20.4.1, 20.6.1 and 20.6.3
(<https://registry.khronos.org/vulkan/specs/latest/html/vkspec.html#textures-texel-filtering>).
Repeat keeps its period at any finite coordinate. A NaN or infinite coordinate gives
`[0, 0, 0, 1]`.
`[0, 0, 0, 1]`. Bilinear sums are taken in `f64` and clamped to the `f32` range, so finite
texels give a finite sample.
- **Other operations.** `get` reads one texel, `map`, `to_gray`, `to_color` and `to_kind` convert,
`resize` box-averages whole-factor shrinks and samples bilinearly otherwise, and
`to_rgba8_display` encodes 8-bit sRGB RGBA. `linear_to_srgb`, `srgb_to_linear` and `luminance`
+20 -7
View File
@@ -19,10 +19,13 @@
//! 2 GiB minus one byte on 32-bit targets such as `wasm32`. The `try_` forms return
//! [`ImageError::TooLarge`] for a plane over that bound, also when the caller raised the limits
//! past it. The constructors without `try_` clamp each side to `1..=MAX_SIDE`, and [`Image::map`]
//! clamps the same way when its result would be over the bound. Every operation that allocates has
//! a `try_` form that reserves its memory first and returns [`ImageError::OutOfMemory`] when that
//! fails; the forms without `try_` allocate like `Vec`, which ends the process when memory runs
//! out. Rows are generated on several threads only with the `parallel` feature.
//! clamps the same way when its result would be over the bound. Every operation that allocates a
//! pixel buffer has a `try_` form that reserves that buffer first and returns
//! [`ImageError::OutOfMemory`] when the reservation fails. The `try_` forms still make small
//! allocations that are not fallible: the `Arc` that holds a new image's buffer, and the boxed
//! resampler of [`Image::try_resize`]. The forms without `try_` allocate like `Vec`. A failed
//! infallible allocation ends the process. Rows are generated on several threads only with the
//! `parallel` feature.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -343,17 +346,20 @@ impl Image {
let (tx, ty) = (fx - x0, fy - y0);
let (x0, y0) = (x0 as i64, y0 as i64);
let taps = [((1.0 - tx) * (1.0 - ty), x0, y0), (tx * (1.0 - ty), x0 + 1, y0), ((1.0 - tx) * ty, x0, y0 + 1), (tx * ty, x0 + 1, y0 + 1)];
let mut o = [0.0; 4];
// Summed in f64: the rounded weights can add up to slightly more than 1, which would
// overflow a finite HDR value near `f32::MAX` in f32. A finite sum is clamped to the f32
// range; an infinite texel still gives an infinite or NaN sample.
let mut o = [0.0f64; 4];
for (weight, x, y) in taps {
if weight == 0.0 {
continue;
}
let p = self.get(x, y, tile);
for (o, p) in o.iter_mut().zip(p) {
*o += weight * p;
*o += f64::from(weight) * f64::from(p);
}
}
o
o.map(|v| if v.is_finite() { v.clamp(f64::from(f32::MIN), f64::from(f32::MAX)) as f32 } else { v as f32 })
}
#[inline]
@@ -724,6 +730,13 @@ mod tests {
assert_eq!(img.sample(0.25, 0.5, false)[0], 1.0);
let Ok(hdr) = Image::from_data(2, 1, Kind::Gray, vec![f32::MAX, -f32::MAX], &l) else { panic!("valid 2 x 1 image") };
assert_eq!(hdr.sample(0.5, 0.5, false)[0], 0.0);
// Rounded weights can sum past 1; a constant image of the largest float stays finite.
let max = Image::filled(2, 2, Kind::Gray, [f32::MAX; 4]);
let t = 0.5 + 2.0_f32.powi(-24);
for tile in [false, true] {
assert!(max.sample(0.250_012_5, t, tile)[0].is_finite(), "tile = {tile}");
assert_eq!(max.sample(t, t, tile)[0], f32::MAX, "tile = {tile}");
}
}
#[test]