craft-texture: add bounded float image planes and texture sampling

Adds crates/texture (craft-texture), the first step of RFC-0032
(storytold/craft-contrib#50). The code comes from MaterialCraft
crates/image/src/lib.rs at 31330629, with its six tests unchanged. No app
uses the crate yet.

Changes from the source:
- Image fields are private; from_data checks the size and the buffer
  length, so a zero side can no longer reach a panic and a short buffer
  is rejected instead of read with defaults.
- Limits caps the side, pixels and bytes; the try_ constructors and
  try_map, try_to_kind, try_resize and try_to_rgba8_display reserve
  memory first and return an error.
- Repeat sampling reduces the coordinate to its fractional part, so the
  period holds at large coordinates.
- Bilinear sampling sums the four weighted texels and skips zero weights;
  box shrinking sums in f64.
- generate_uv clamps the size before computing pixel centers.
- linear_to_srgb(NaN) returns NaN.
- rayon is behind the parallel feature.
This commit is contained in:
dotCooCoo committed 2026-11-13 13:14:55 -08:00
1 parent b5b8a7750d
commit 3c3a83151d
6 files changed
+895

No files matched your search

Generated
+8
View File
@@ -2380,6 +2380,14 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "craft-texture"
version = "0.1.0"
dependencies = [
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "craft-tga"
version = "0.1.0"
+1
View File
@@ -128,6 +128,7 @@ craft-stencilcraft-document = { path = "crates/stencilcraft-document" }
craft-svg-paths = { path = "crates/svg-paths" }
craft-stream-codecs = { path = "crates/stream-codecs" }
craft-text = { path = "crates/text" }
craft-texture = { path = "crates/texture" }
craft-tga = { path = "crates/tga" }
craft-timeline = { path = "crates/timeline" }
craft-tiff = { path = "crates/tiff" }
+3
View File
@@ -344,6 +344,9 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit
- [`craft-text`](crates/text/README.md): text processing below the composer. Its `hyphen` module finds
hyphenation points, with a bundled US English dictionary and patterns and optional hypher patterns
for 47 more languages (one cargo feature each).
- [`craft-texture`](crates/texture/README.md): linear float image planes, gray or RGBA, with
nearest and bilinear sampling that repeats or clamps, resizing and display conversion. Sizes are
bounded by `Limits`. From MaterialCraft.
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking, print settings and theme helpers for egui frontends;
assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later.
- [`craft-vcard`](crates/vcard/README.md): vCard contacts, from MailCraft: vCard 2.1, 3.0 and 4.0 read
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "craft-texture"
description = "Bounded linear float image planes and texture sampling"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[features]
default = []
parallel = ["dep:rayon"]
[lints]
workspace = true
[dependencies]
rayon = { workspace = true, optional = true }
thiserror = { workspace = true }
+71
View File
@@ -0,0 +1,71 @@
# craft-texture
Linear float image planes and texture sampling. An `Image` is a gray (1 channel) or RGBA plane of
`f32`, row-major with the top row first, shared copy-on-write through an `Arc`. Values are stored
as given, so HDR values above 1 and negative values are kept. The crate has no UI, does no file or
network I/O and has no `unsafe` code.
- **Construction.** `try_filled`, `try_generate`, `try_generate_uv` and `from_data` check the size
against `Limits` (longest side, pixel count, bytes) before allocating and return an
`ImageError`. `from_data` also checks that the buffer holds width × height × channels floats.
`filled`, `generate`, `generate_uv` and `gray` clamp each side to `1..=MAX_SIDE` (8192).
`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.
- **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
`REPEAT`) or clamp to edge (`CLAMP_TO_EDGE`) for both axes. The texel coordinate, the wrapping
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]`.
- **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`
(Rec. 709) are scalar helpers. `resource_key` and `split_resource_key` tag a bitmap name with
its color space.
Mirrored repeat, mip chains, height to normal, channel packing, decoding and texture-set
recognition are not in the crate yet.
## Cargo features
No default features.
| Feature | Provides | Pulls in |
|---|---|---|
| `parallel` | generated images and display conversion fill rows on several threads; the results are the same | `rayon` |
Without `parallel` the crate builds for `wasm32-unknown-unknown`.
## Limits
| Field | Default | Checked by |
|---|---|---|
| `max_side` | 8192 | every `try_` constructor and `from_data` |
| `max_pixels` | 2^26 (8192 × 8192) | the same |
| `max_bytes` | 1 GiB (an 8192 × 8192 RGBA plane) | the same |
## Origin
| Part | Source |
|---|---|
| `Image`, `Kind`, sampling, resizing, conversions, sRGB helpers, resource keys and the six tests `sample_survives_huge_coordinates`, `resource_keys_round_trip`, `sample_wraps_and_clamps`, `resize_box_and_bilinear`, `hostile_sizes_are_clamped`, `conversions` | MaterialCraft `crates/image/src/lib.rs` at [3133062](https://github.com/storytold/materialcraft/commit/3133062968b8a1566eda12a357cb06062db16687) |
| `Limits`, the `try_` constructors, `from_data`, private fields with getters, the `parallel` feature | written here |
## Migration
| App | Status |
|---|---|
| MaterialCraft | `materialcraft-image` is the source. The app switches to this crate in its own pull request. |
| EffectCraft | Its texture sampling moves into this crate later, with mirrored repeat. |
For MaterialCraft:
| `materialcraft-image` | craft-texture |
| --- | --- |
| `image.w`, `image.h`, `image.kind`, `image.data` | `width()`, `height()`, `kind()`, `data()` and `shared_data()` |
| `Image { w, h, kind, data }` built by hand | `Image::from_data(w, h, kind, data, &Limits::default())` |
| `rayon` always on | the `parallel` feature |
| `linear_to_srgb(NaN)` returned −0.055 | it returns NaN |
+793
View File
@@ -0,0 +1,793 @@
//! # craft-texture
//!
//! Linear float image planes and texture sampling. An [`Image`] is a grayscale (1 channel) or
//! color (RGBA) plane of `f32`, row-major with the top row first, shared copy-on-write through an
//! `Arc` so a copy costs a reference count. Values are stored as given: HDR values above 1,
//! negative values and non-finite values are kept.
//!
//! Sampling takes normalized `(u, v)` with `(0, 0)` at the upper-left corner and `v` increasing
//! downward, as glTF 2.0 section 3.8.3 defines texture coordinates
//! (<https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html>). The `tile` argument picks the
//! wrap mode of both axes: repeat (glTF `REPEAT`, 10497) or clamp to edge (`CLAMP_TO_EDGE`, 33071).
//! [`Image::sample_nearest`] and [`Image::sample`] (bilinear) use the texel coordinates, wrapping
//! and weights of 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>).
//!
//! Sizes are bounded. [`Limits`] caps the side, the pixel count and the bytes, and the `try_`
//! constructors return an [`ImageError`] before allocating anything over them. The constructors
//! without `try_` clamp each side to `1..=MAX_SIDE`. 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.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::sync::Arc;
#[cfg(feature = "parallel")]
use rayon::prelude::*;
/// Largest side the clamping constructors make, and the default [`Limits::max_side`].
pub const MAX_SIDE: u32 = 8192;
/// Whether an image holds one channel or four.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Kind {
Gray,
Color,
}
impl Kind {
pub fn channels(self) -> usize {
match self {
Kind::Gray => 1,
Kind::Color => 4,
}
}
}
/// Why an image could not be made.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ImageError {
/// A side is zero or longer than [`Limits::max_side`].
#[error("image size {0}x{1} has a zero side or a side over the limit")]
BadSize(u32, u32),
/// The pixel count or the byte count is over [`Limits`].
#[error("image size {0}x{1} needs more pixels or bytes than the limits allow")]
TooLarge(u32, u32),
/// The float buffer does not hold width × height × channels values.
#[error("image data holds {found} floats where {expected} are needed")]
BadDataLength { expected: usize, found: usize },
/// Memory for the pixels could not be reserved.
#[error("not enough memory for a {0}x{1} image")]
OutOfMemory(u32, u32),
}
/// Caps on the size of an image, checked before anything is allocated.
/// `#[non_exhaustive]`: start from [`Limits::default`] and set the fields you change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Limits {
/// Longest side in pixels. Default [`MAX_SIDE`].
pub max_side: u32,
/// Most pixels (width × height). Default 2^26, which is 8192 × 8192.
pub max_pixels: u64,
/// Most bytes of float data (pixels × channels × 4). Default 1 GiB, an 8192 × 8192 color plane.
pub max_bytes: u64,
}
impl Default for Limits {
fn default() -> Self {
Self { max_side: MAX_SIDE, max_pixels: 1 << 26, max_bytes: 1 << 30 }
}
}
impl Limits {
/// The number of floats a `w × h` image of `kind` holds, or the error the limits give for that
/// size. Nothing is allocated.
pub fn check(&self, w: u32, h: u32, kind: Kind) -> Result<usize, ImageError> {
if w == 0 || h == 0 || w > self.max_side || h > self.max_side {
return Err(ImageError::BadSize(w, h));
}
let too_large = ImageError::TooLarge(w, h);
let pixels = u64::from(w).checked_mul(u64::from(h)).ok_or_else(|| too_large.clone())?;
let floats = pixels.checked_mul(kind.channels() as u64).ok_or_else(|| too_large.clone())?;
let bytes = floats.checked_mul(size_of::<f32>() as u64).ok_or_else(|| too_large.clone())?;
if pixels > self.max_pixels || bytes > self.max_bytes {
return Err(too_large);
}
usize::try_from(floats).map_err(|_| too_large)
}
}
/// A gray or RGBA plane of linear `f32`. Width and height are at least 1, and the data holds
/// exactly width × height × channels floats.
#[derive(Clone, Debug)]
pub struct Image {
w: u32,
h: u32,
kind: Kind,
/// Row-major, `kind.channels()` floats per pixel.
data: Arc<Vec<f32>>,
}
impl PartialEq for Image {
fn eq(&self, o: &Image) -> bool {
self.w == o.w && self.h == o.h && self.kind == o.kind && (Arc::ptr_eq(&self.data, &o.data) || self.data == o.data)
}
}
/// Clamp a requested size into `1..=MAX_SIDE` on each side.
pub fn clamp_size(w: u32, h: u32) -> (u32, u32) {
(w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE))
}
/// Integer texel index `i` on an axis of `n >= 1` texels: `i mod n` when repeating, otherwise
/// clamped to `0..=n - 1`.
#[inline]
fn wrap(i: i64, n: u32, tile: bool) -> usize {
let n = i64::from(n.max(1));
if tile { i.rem_euclid(n) as usize } else { i.clamp(0, n - 1) as usize }
}
/// The largest `f32` below 1.
const BELOW_ONE: f32 = 1.0 - f32::EPSILON / 2.0;
/// A normalized coordinate brought into a range whose texel indices fit in `i64`: its fractional
/// part, below 1, when repeating, since the repeat period is 1, and `-1..=2` when clamping, where
/// every value outside `0..=1` already selects an edge texel. `None` for NaN and infinity.
#[inline]
fn reduce(t: f32, tile: bool) -> Option<f32> {
if !t.is_finite() {
return None;
}
Some(if tile { t.rem_euclid(1.0).min(BELOW_ONE) } else { t.clamp(-1.0, 2.0) })
}
/// Fill `data` row by row with `f(x, y)`, keeping the first `c` channels of each result.
fn fill<F: Fn(u32, u32) -> [f32; 4] + Sync>(data: &mut [f32], w: u32, c: usize, f: &F) {
let row_len = (w as usize).max(1) * c;
let fill_row = |(y, row): (usize, &mut [f32])| {
for (x, px) in row.chunks_exact_mut(c).enumerate() {
let p = f(x as u32, y as u32);
px.copy_from_slice(&p[..c]);
}
};
#[cfg(feature = "parallel")]
data.par_chunks_mut(row_len).enumerate().for_each(fill_row);
#[cfg(not(feature = "parallel"))]
data.chunks_mut(row_len).enumerate().for_each(fill_row);
}
impl Image {
/// An image of a size already inside `1..=MAX_SIDE` or taken from an existing image.
fn build<F: Fn(u32, u32) -> [f32; 4] + Sync>(w: u32, h: u32, kind: Kind, f: F) -> Image {
let len = (w as usize).saturating_mul(h as usize).saturating_mul(kind.channels());
let mut data = vec![0.0f32; len];
fill(&mut data, w, kind.channels(), &f);
Image { w, h, kind, data: Arc::new(data) }
}
/// An image checked against `limits`, with its memory reserved before any pixel is generated.
fn try_build<F: Fn(u32, u32) -> [f32; 4] + Sync>(w: u32, h: u32, kind: Kind, limits: &Limits, f: F) -> Result<Image, ImageError> {
let len = limits.check(w, h, kind)?;
let mut data = Vec::new();
data.try_reserve_exact(len).map_err(|_| ImageError::OutOfMemory(w, h))?;
data.resize(len, 0.0);
fill(&mut data, w, kind.channels(), &f);
Ok(Image { w, h, kind, data: Arc::new(data) })
}
/// A constant image, each side clamped to `1..=MAX_SIDE`.
pub fn filled(w: u32, h: u32, kind: Kind, v: [f32; 4]) -> Image {
let (w, h) = clamp_size(w, h);
Image::build(w, h, kind, |_, _| v)
}
/// A constant image, or an error when the size is outside `limits` or memory runs out.
pub fn try_filled(w: u32, h: u32, kind: Kind, v: [f32; 4], limits: &Limits) -> Result<Image, ImageError> {
Image::try_build(w, h, kind, limits, |_, _| v)
}
/// Generate every pixel with `f(x, y)` (pixel indices), each side clamped to `1..=MAX_SIDE`.
pub fn generate(w: u32, h: u32, kind: Kind, f: impl Fn(u32, u32) -> [f32; 4] + Sync) -> Image {
let (w, h) = clamp_size(w, h);
Image::build(w, h, kind, f)
}
/// Generate every pixel with `f(x, y)`, or return an error when the size is outside `limits`
/// or memory runs out.
pub fn try_generate(w: u32, h: u32, kind: Kind, limits: &Limits, f: impl Fn(u32, u32) -> [f32; 4] + Sync) -> Result<Image, ImageError> {
Image::try_build(w, h, kind, limits, f)
}
/// Generate from normalized coordinates at the pixel centers, `(x + 0.5) / w` and
/// `(y + 0.5) / h`, each side clamped to `1..=MAX_SIDE` first.
pub fn generate_uv(w: u32, h: u32, kind: Kind, f: impl Fn(f32, f32) -> [f32; 4] + Sync) -> Image {
let (w, h) = clamp_size(w, h);
let (iw, ih) = (1.0 / w as f32, 1.0 / h as f32);
Image::build(w, h, kind, |x, y| f((x as f32 + 0.5) * iw, (y as f32 + 0.5) * ih))
}
/// [`Image::generate_uv`] for a size checked against `limits`.
pub fn try_generate_uv(w: u32, h: u32, kind: Kind, limits: &Limits, f: impl Fn(f32, f32) -> [f32; 4] + Sync) -> Result<Image, ImageError> {
let (iw, ih) = (1.0 / w.max(1) as f32, 1.0 / h.max(1) as f32);
Image::try_build(w, h, kind, limits, |x, y| f((x as f32 + 0.5) * iw, (y as f32 + 0.5) * ih))
}
pub fn gray(w: u32, h: u32, f: impl Fn(u32, u32) -> f32 + Sync) -> Image {
Image::generate(w, h, Kind::Gray, |x, y| [f(x, y), 0.0, 0.0, 0.0])
}
/// An image over existing row-major data, checked against `limits` and against the length
/// width × height × channels. The data is not copied.
pub fn from_data(w: u32, h: u32, kind: Kind, data: impl Into<Arc<Vec<f32>>>, limits: &Limits) -> Result<Image, ImageError> {
let expected = limits.check(w, h, kind)?;
let data = data.into();
if data.len() != expected {
return Err(ImageError::BadDataLength { expected, found: data.len() });
}
Ok(Image { w, h, kind, data })
}
pub fn width(&self) -> u32 {
self.w
}
pub fn height(&self) -> u32 {
self.h
}
pub fn kind(&self) -> Kind {
self.kind
}
/// The row-major floats, `channels()` per pixel.
pub fn data(&self) -> &[f32] {
self.data.as_slice()
}
/// The shared buffer behind [`Image::data`].
pub fn shared_data(&self) -> &Arc<Vec<f32>> {
&self.data
}
pub fn channels(&self) -> usize {
self.kind.channels()
}
pub fn is_gray(&self) -> bool {
self.kind == Kind::Gray
}
/// The pixel at integer coordinates (wrapped or clamped), as RGBA (gray → `[v, v, v, 1]`).
#[inline]
pub fn get(&self, x: i64, y: i64, tile: bool) -> [f32; 4] {
let (x, y) = (wrap(x, self.w, tile), wrap(y, self.h, tile));
let i = y * self.w as usize + x;
match self.kind {
Kind::Gray => {
let v = self.data.get(i).copied().unwrap_or(0.0);
[v, v, v, 1.0]
}
Kind::Color => {
let s = self.data.get(i * 4..i * 4 + 4).unwrap_or(&[0.0, 0.0, 0.0, 1.0]);
[s[0], s[1], s[2], s[3]]
}
}
}
/// The gray value, or the Rec. 709 luminance of a color image.
#[inline]
pub fn get_gray(&self, x: i64, y: i64, tile: bool) -> f32 {
let p = self.get(x, y, tile);
match self.kind {
Kind::Gray => p[0],
Kind::Color => luminance(p),
}
}
/// Bilinear sample at normalized `(u, v)`; pixel centers sit at `(i + 0.5) / w`. The texel
/// coordinate is `u × w − 0.5`; its floor and the next index are wrapped or clamped, and the
/// four texels are weighted `(1 − α)(1 − β)`, `α(1 − β)`, `(1 − α)β` and `αβ` by the fractional
/// parts `α` and `β`. A texel with weight 0 does not enter the sum, so an infinite neighbor
/// leaves a sample at a texel center unchanged. NaN or infinite coordinates give `[0, 0, 0, 1]`.
#[inline]
pub fn sample(&self, u: f32, v: f32, tile: bool) -> [f32; 4] {
let (Some(u), Some(v)) = (reduce(u, tile), reduce(v, tile)) else {
return [0.0, 0.0, 0.0, 1.0];
};
let fx = u * self.w as f32 - 0.5;
let fy = v * self.h as f32 - 0.5;
let (x0, y0) = (fx.floor(), fy.floor());
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];
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
}
#[inline]
pub fn sample_gray(&self, u: f32, v: f32, tile: bool) -> f32 {
let p = self.sample(u, v, tile);
match self.kind {
Kind::Gray => p[0],
Kind::Color => luminance(p),
}
}
/// Nearest sample at normalized `(u, v)`: the texel `floor(u × w)`, wrapped or clamped. NaN or
/// infinite coordinates give `[0, 0, 0, 1]`.
#[inline]
pub fn sample_nearest(&self, u: f32, v: f32, tile: bool) -> [f32; 4] {
let (Some(u), Some(v)) = (reduce(u, tile), reduce(v, tile)) else {
return [0.0, 0.0, 0.0, 1.0];
};
let (x, y) = ((u * self.w as f32).floor() as i64, (v * self.h as f32).floor() as i64);
// A repeated coordinate is below 1, so its texel is below the size even where `u × w`
// rounds up to it.
let (x, y) = if tile { (x.min(i64::from(self.w) - 1), y.min(i64::from(self.h) - 1)) } else { (x, y) };
self.get(x, y, tile)
}
/// Map every pixel (as RGBA) to a new image of `kind`, same size.
pub fn map(&self, kind: Kind, f: impl Fn([f32; 4]) -> [f32; 4] + Sync) -> Image {
Image::build(self.w, self.h, kind, |x, y| f(self.get(i64::from(x), i64::from(y), false)))
}
/// [`Image::map`] with the new image checked against `limits` and its memory reserved first.
pub fn try_map(&self, kind: Kind, limits: &Limits, f: impl Fn([f32; 4]) -> [f32; 4] + Sync) -> Result<Image, ImageError> {
Image::try_build(self.w, self.h, kind, limits, |x, y| f(self.get(i64::from(x), i64::from(y), false)))
}
/// [`Image::to_kind`] with the result checked against `limits`, also when the kind is
/// unchanged, and the memory of a new image reserved first.
pub fn try_to_kind(&self, kind: Kind, limits: &Limits) -> Result<Image, ImageError> {
limits.check(self.w, self.h, kind)?;
match (self.kind, kind) {
(Kind::Gray, Kind::Gray) | (Kind::Color, Kind::Color) => Ok(self.clone()),
(Kind::Color, Kind::Gray) => self.try_map(Kind::Gray, limits, |p| [luminance(p), 0.0, 0.0, 0.0]),
(Kind::Gray, Kind::Color) => self.try_map(Kind::Color, limits, |p| p),
}
}
/// This image as grayscale (luminance of a color image).
pub fn to_gray(&self) -> Image {
match self.kind {
Kind::Gray => self.clone(),
Kind::Color => self.map(Kind::Gray, |p| [luminance(p), 0.0, 0.0, 0.0]),
}
}
/// This image as RGBA (gray copied to RGB, alpha 1).
pub fn to_color(&self) -> Image {
match self.kind {
Kind::Color => self.clone(),
Kind::Gray => self.map(Kind::Color, |p| p),
}
}
pub fn to_kind(&self, kind: Kind) -> Image {
match kind {
Kind::Gray => self.to_gray(),
Kind::Color => self.to_color(),
}
}
/// Resample to `w × h`, each side clamped to `1..=MAX_SIDE`: a box average when both sides
/// shrink by whole factors, otherwise bilinear samples at the new pixel centers.
pub fn resize(&self, w: u32, h: u32, tile: bool) -> Image {
let (w, h) = clamp_size(w, h);
if w == self.w && h == self.h {
return self.clone();
}
Image::build(w, h, self.kind, self.resampler(w, h, tile))
}
/// [`Image::resize`] without the clamp: the new size is checked against `limits`, also when it
/// equals the current size, and its memory reserved first.
pub fn try_resize(&self, w: u32, h: u32, tile: bool, limits: &Limits) -> Result<Image, ImageError> {
limits.check(w, h, self.kind)?;
if w == self.w && h == self.h {
return Ok(self.clone());
}
Image::try_build(w, h, self.kind, limits, self.resampler(w, h, tile))
}
/// The pixel function of a resample to `w × h`, both at least 1.
fn resampler(&self, w: u32, h: u32, tile: bool) -> Box<dyn Fn(u32, u32) -> [f32; 4] + Sync + '_> {
let (w, h) = (w.max(1), h.max(1));
if w <= self.w && h <= self.h && self.w.is_multiple_of(w) && self.h.is_multiple_of(h) {
let (fx, fy) = (i64::from(self.w / w), i64::from(self.h / h));
let inv = 1.0 / (fx * fy) as f64;
return Box::new(move |x, y| {
// Summed in f64, so finite HDR values near `f32::MAX` average without overflow.
let mut s = [0.0f64; 4];
for j in 0..fy {
for i in 0..fx {
let p = self.get(i64::from(x) * fx + i, i64::from(y) * fy + j, tile);
for (s, p) in s.iter_mut().zip(p) {
*s += f64::from(p);
}
}
}
s.map(|v| (v * inv) as f32)
});
}
let (iw, ih) = (1.0 / w as f32, 1.0 / h as f32);
Box::new(move |x, y| self.sample((x as f32 + 0.5) * iw, (y as f32 + 0.5) * ih, tile))
}
/// Raw float access for gray images (`None` for color).
pub fn gray_data(&self) -> Option<&[f32]> {
(self.kind == Kind::Gray).then_some(self.data.as_slice())
}
/// 8-bit sRGB-encoded RGBA for display (gray shown as is; color through the sRGB curve).
/// Values are clamped to `0..=1` first; NaN becomes 0.
pub fn to_rgba8_display(&self) -> Vec<u8> {
let mut out = vec![0u8; self.display_len()];
self.fill_display(&mut out);
out
}
/// [`Image::to_rgba8_display`] with its memory reserved first.
pub fn try_to_rgba8_display(&self) -> Result<Vec<u8>, ImageError> {
let len = self.display_len();
let mut out = Vec::new();
out.try_reserve_exact(len).map_err(|_| ImageError::OutOfMemory(self.w, self.h))?;
out.resize(len, 0);
self.fill_display(&mut out);
Ok(out)
}
/// Bytes of the display copy: 4 per pixel, never more than the float data's own bytes.
fn display_len(&self) -> usize {
self.data.len() / self.channels() * 4
}
fn fill_display(&self, out: &mut [u8]) {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
let pixel = |(i, px): (usize, &mut [u8])| match self.kind {
Kind::Gray => {
let v = q(self.data.get(i).copied().unwrap_or(0.0));
px.copy_from_slice(&[v, v, v, 255]);
}
Kind::Color => {
let s = self.data.get(i * 4..i * 4 + 4).unwrap_or(&[0.0, 0.0, 0.0, 1.0]);
px.copy_from_slice(&[q(linear_to_srgb(s[0])), q(linear_to_srgb(s[1])), q(linear_to_srgb(s[2])), q(s[3])]);
}
};
#[cfg(feature = "parallel")]
out.par_chunks_mut(4).enumerate().for_each(pixel);
#[cfg(not(feature = "parallel"))]
out.chunks_mut(4).enumerate().for_each(pixel);
}
/// Mean of each channel (as RGBA), e.g. for tests and auto levels. A NaN or infinite pixel
/// makes its channel's mean non-finite.
pub fn mean(&self) -> [f32; 4] {
let c = self.channels();
let n = f64::from(self.w) * f64::from(self.h);
let mut s = [0f64; 4];
for px in self.data.chunks(c) {
for (k, v) in px.iter().enumerate() {
s[k] += f64::from(*v);
}
}
if c == 1 {
let m = (s[0] / n) as f32;
return [m, m, m, 1.0];
}
s.map(|v| (v / n) as f32)
}
/// Minimum and maximum over all channels (alpha excluded). NaN values are skipped; `(0, 0)`
/// when every value is NaN.
pub fn range(&self) -> (f32, f32) {
let c = self.channels();
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for px in self.data.chunks(c) {
for v in px.iter().take(c.min(3)) {
lo = lo.min(*v);
hi = hi.max(*v);
}
}
if lo > hi { (0.0, 0.0) } else { (lo, hi) }
}
}
/// Rec. 709 luminance of a linear RGB(A) pixel.
#[inline]
pub fn luminance(p: [f32; 4]) -> f32 {
0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]
}
/// The sRGB encoding of a linear value. NaN stays NaN.
#[inline]
pub fn linear_to_srgb(v: f32) -> f32 {
if v.is_nan() || v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
/// The linear value of an sRGB-encoded value. NaN stays NaN.
#[inline]
pub fn srgb_to_linear(v: f32) -> f32 {
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
/// Encode the bitmap name and its requested color space without ambiguity.
/// Plain sRGB names remain readable by simple resource callbacks.
pub fn resource_key(name: &str, linear: bool) -> String {
if linear {
format!("\u{1}L{name}")
} else if name.starts_with('\u{1}') {
format!("\u{1}S{name}")
} else {
name.to_string()
}
}
/// Split a key made by [`resource_key`] into its name and data-map flag.
pub fn split_resource_key(key: &str) -> (&str, bool) {
if let Some(name) = key.strip_prefix("\u{1}L") {
(name, true)
} else if let Some(name) = key.strip_prefix("\u{1}S") {
(name, false)
} else {
(key, false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sample_survives_huge_coordinates() {
let img = Image::gray(8, 8, |x, _| x as f32);
for u in [1.0e30_f32, -1.0e30, f32::MAX, f32::MIN] {
for tile in [true, false] {
let p = img.sample(u, u, tile);
assert!(p.iter().all(|c| c.is_finite()));
}
}
}
#[test]
fn resource_keys_round_trip() {
for name in ["n", "", "\u{1}Lnormal", "\u{1}Sn", "n\u{1}linear"] {
for linear in [false, true] {
assert_eq!(split_resource_key(&resource_key(name, linear)), (name, linear));
}
}
}
#[test]
fn sample_wraps_and_clamps() {
let img = Image::gray(4, 1, |x, _| x as f32);
assert_eq!(img.get(-1, 0, true)[0], 3.0);
assert_eq!(img.get(-1, 0, false)[0], 0.0);
// Halfway between the last and the (wrapped) first pixel.
assert!((img.sample(1.0, 0.5, true)[0] - 1.5).abs() < 1e-5);
}
#[test]
fn resize_box_and_bilinear() {
let img = Image::gray(4, 4, |x, _| if x < 2 { 0.0 } else { 1.0 });
let small = img.resize(2, 2, true);
assert_eq!(small.data.as_slice(), &[0.0, 1.0, 0.0, 1.0]);
let big = small.resize(8, 8, false);
assert_eq!(big.w, 8);
assert!(big.data[0] < big.data[7]);
}
#[test]
fn hostile_sizes_are_clamped() {
let img = Image::filled(0, u32::MAX, Kind::Gray, [0.5; 4]);
assert_eq!((img.w, img.h), (1, MAX_SIDE));
assert_eq!(img.sample(f32::NAN, 0.0, true), [0.0, 0.0, 0.0, 1.0]);
}
#[test]
fn conversions() {
let c = Image::filled(2, 2, Kind::Color, [1.0, 1.0, 1.0, 1.0]);
assert!((c.to_gray().data[0] - 1.0).abs() < 1e-5);
assert_eq!(c.to_rgba8_display()[0], 255);
}
/// Texel values 0, 5 and 1: the blend of the last and first texels (0.5) differs from the
/// middle texel (5), so a wrong repeat period shows.
fn stripe() -> Image {
Image::gray(3, 1, |x, _| [0.0, 5.0, 1.0][x as usize])
}
#[test]
fn repeat_keeps_its_period_at_large_coordinates() {
let img = stripe();
// Every whole number repeats u = 0, which falls halfway between the last and first texels.
let at_zero = img.sample(0.0, 0.5, true)[0];
assert!((at_zero - 0.5).abs() < 1e-6);
for u in [1.0e9_f32, -1.0e9, 4.0e9, 3.0e12] {
assert_eq!(img.sample(u, 0.5, true)[0], at_zero, "bilinear u = {u}");
assert_eq!(img.sample_nearest(u, 0.5, true)[0], 0.0, "nearest u = {u}");
}
assert_eq!(img.sample_nearest(1.0e19, 0.5, true)[0], 0.0);
assert_eq!(img.sample_nearest(-1.0e19, 0.5, true)[0], 0.0);
}
#[test]
fn clamp_selects_the_edges_at_large_coordinates() {
let img = stripe();
for u in [2.0_f32, 1.0e9, 1.0e19, f32::MAX] {
assert_eq!(img.sample(u, 0.5, false)[0], 1.0);
assert_eq!(img.sample_nearest(u, 0.5, false)[0], 1.0);
}
for u in [-1.0_f32, -1.0e9, -1.0e19, f32::MIN] {
assert_eq!(img.sample(u, 0.5, false)[0], 0.0);
assert_eq!(img.sample_nearest(u, 0.5, false)[0], 0.0);
}
}
#[test]
fn nearest_follows_the_texel_floor() {
let img = Image::gray(4, 1, |x, _| x as f32);
// floor(u × 4): u = 1.0 repeats to texel 0 and clamps to texel 3.
assert_eq!(img.sample_nearest(0.24, 0.5, true)[0], 0.0);
assert_eq!(img.sample_nearest(0.25, 0.5, true)[0], 1.0);
assert_eq!(img.sample_nearest(1.0, 0.5, true)[0], 0.0);
assert_eq!(img.sample_nearest(1.0, 0.5, false)[0], 3.0);
assert_eq!(img.sample_nearest(-0.01, 0.5, true)[0], 3.0);
}
#[test]
fn bilinear_weights_follow_the_fractional_texel_coordinate() {
// u × 4 − 0.5 = 1.25: texel 1 weighted 0.75 and texel 2 weighted 0.25.
let img = Image::gray(4, 1, |x, _| [0.0, 2.0, 6.0, 0.0][x as usize]);
assert!((img.sample(1.75 / 4.0, 0.5, false)[0] - 3.0).abs() < 1e-6);
// Two axes: the four texels of a 2 × 2 image at the image center weigh a quarter each.
let sq = Image::gray(2, 2, |x, y| [1.0, 3.0, 5.0, 11.0][(y * 2 + x) as usize]);
assert!((sq.sample(0.5, 0.5, false)[0] - 5.0).abs() < 1e-6);
}
#[test]
fn small_negative_coordinates_repeat_to_the_last_texel() {
let img = Image::gray(4, 1, |x, _| x as f32);
for u in [-1.0e-8_f32, -1.0e-30, -f32::MIN_POSITIVE] {
assert_eq!(img.sample_nearest(u, 0.5, true)[0], 3.0, "u = {u}");
assert!((img.sample(u, 0.5, true)[0] - 1.5).abs() < 1e-5, "u = {u}");
}
let wide = Image::gray(MAX_SIDE, 1, |x, _| x as f32);
assert_eq!(wide.sample_nearest(-1.0e-8, 0.5, true)[0], (MAX_SIDE - 1) as f32);
}
#[test]
fn bilinear_ignores_zero_weight_texels_and_does_not_overflow() {
let l = Limits::default();
let Ok(img) = Image::from_data(2, 1, Kind::Gray, vec![1.0, f32::INFINITY], &l) else { panic!("valid 2 x 1 image") };
// The center of texel 0 gives texel 0, though its neighbor is infinite.
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);
}
#[test]
fn box_shrink_averages_large_hdr_values() {
let img = Image::filled(2, 2, Kind::Color, [f32::MAX, -f32::MAX, 1.0, 1.0]);
assert_eq!(img.resize(1, 1, false).data(), &[f32::MAX, -f32::MAX, 1.0, 1.0]);
}
#[test]
fn fallible_operations_check_the_limits() {
let img = Image::gray(4, 4, |x, y| (x + 4 * y) as f32);
let l = Limits::default();
assert_eq!(img.try_resize(2, 2, true, &l), Ok(img.resize(2, 2, true)));
assert_eq!(img.try_resize(7, 3, false, &l), Ok(img.resize(7, 3, false)));
assert_eq!(img.try_resize(0, 2, false, &l), Err(ImageError::BadSize(0, 2)));
let small = Limits { max_bytes: 100, ..Limits::default() };
assert_eq!(img.try_resize(8, 8, false, &small), Err(ImageError::TooLarge(8, 8)));
assert_eq!(img.try_to_kind(Kind::Color, &small), Err(ImageError::TooLarge(4, 4)));
assert_eq!(img.try_to_kind(Kind::Color, &l), Ok(img.to_color()));
assert_eq!(img.try_to_kind(Kind::Gray, &small), Ok(img.clone()));
assert_eq!(img.try_map(Kind::Gray, &l, |p| [p[0] * 2.0; 4]), Ok(img.map(Kind::Gray, |p| [p[0] * 2.0; 4])));
assert_eq!(img.try_to_rgba8_display(), Ok(img.to_rgba8_display()));
}
#[test]
fn unchanged_results_are_checked_against_the_limits() {
let img = Image::gray(4, 4, |x, y| (x + 4 * y) as f32);
let tiny = Limits { max_pixels: 8, ..Limits::default() };
assert_eq!(img.try_resize(4, 4, false, &tiny), Err(ImageError::TooLarge(4, 4)));
assert_eq!(img.try_to_kind(Kind::Gray, &tiny), Err(ImageError::TooLarge(4, 4)));
let narrow = Limits { max_side: 3, ..Limits::default() };
assert_eq!(img.try_resize(4, 4, false, &narrow), Err(ImageError::BadSize(4, 4)));
}
#[test]
fn generate_uv_spans_the_clamped_size() {
let img = Image::generate_uv(MAX_SIDE + 100, 1, Kind::Gray, |u, _| [u, 0.0, 0.0, 0.0]);
assert_eq!(img.w, MAX_SIDE);
let last = img.data[MAX_SIDE as usize - 1];
assert!((last - (MAX_SIDE as f32 - 0.5) / MAX_SIDE as f32).abs() < 1e-6, "{last}");
}
#[test]
fn checked_sizes_reject_zero_and_long_sides() {
let l = Limits::default();
assert_eq!(Image::try_filled(0, 4, Kind::Gray, [0.0; 4], &l), Err(ImageError::BadSize(0, 4)));
assert_eq!(Image::try_filled(4, 0, Kind::Color, [0.0; 4], &l), Err(ImageError::BadSize(4, 0)));
assert_eq!(Image::from_data(0, 1, Kind::Gray, Vec::new(), &l), Err(ImageError::BadSize(0, 1)));
assert_eq!(Image::try_generate(MAX_SIDE + 1, 1, Kind::Gray, &l, |_, _| [0.0; 4]), Err(ImageError::BadSize(MAX_SIDE + 1, 1)));
assert!(Image::try_filled(3, 2, Kind::Color, [0.25; 4], &l).is_ok_and(|i| i.data().len() == 24 && i.width() == 3));
}
#[test]
fn checked_sizes_respect_the_pixel_and_byte_limits() {
let l = Limits { max_bytes: 1000, ..Limits::default() };
assert_eq!(l.check(15, 16, Kind::Gray), Ok(240));
assert_eq!(l.check(16, 16, Kind::Gray), Err(ImageError::TooLarge(16, 16)));
assert_eq!(Image::try_filled(8, 8, Kind::Color, [0.0; 4], &l), Err(ImageError::TooLarge(8, 8)));
let l = Limits { max_pixels: 100, ..Limits::default() };
assert_eq!(l.check(10, 10, Kind::Color), Ok(400));
assert_eq!(l.check(10, 11, Kind::Gray), Err(ImageError::TooLarge(10, 11)));
// The default limits accept a full 8192 × 8192 color plane and nothing longer.
assert_eq!(Limits::default().check(MAX_SIDE, MAX_SIDE, Kind::Color), Ok(1 << 28));
}
#[test]
fn checked_sizes_do_not_overflow() {
let open = Limits { max_side: u32::MAX, max_pixels: u64::MAX, max_bytes: u64::MAX };
assert_eq!(open.check(u32::MAX, u32::MAX, Kind::Color), Err(ImageError::TooLarge(u32::MAX, u32::MAX)));
assert_eq!(open.check(u32::MAX, u32::MAX, Kind::Gray), Err(ImageError::TooLarge(u32::MAX, u32::MAX)));
}
#[test]
fn from_data_checks_the_length() {
let l = Limits::default();
assert_eq!(Image::from_data(2, 2, Kind::Color, vec![0.0], &l), Err(ImageError::BadDataLength { expected: 16, found: 1 }));
assert_eq!(Image::from_data(2, 2, Kind::Color, vec![0.0; 17], &l), Err(ImageError::BadDataLength { expected: 16, found: 17 }));
let img = Image::from_data(2, 1, Kind::Gray, vec![-2.0, 3.5], &l);
assert!(img.is_ok_and(|i| i.get(0, 0, false)[0] == -2.0 && i.get(1, 0, false)[0] == 3.5));
}
#[test]
fn non_finite_pixels_have_defined_results() {
let l = Limits::default();
let img = Image::from_data(1, 1, Kind::Color, vec![f32::NAN, f32::INFINITY, -1.0, 2.0], &l);
let Ok(img) = img else { panic!("valid 1 x 1 image") };
assert_eq!(img.to_rgba8_display(), vec![0, 255, 0, 255]);
let g = Image::from_data(3, 1, Kind::Gray, vec![f32::NAN, -4.0, 8.0], &l);
let Ok(g) = g else { panic!("valid 3 x 1 image") };
assert_eq!(g.range(), (-4.0, 8.0));
assert!(g.mean()[0].is_nan());
}
#[test]
fn srgb_encoding_keeps_nan() {
assert!(linear_to_srgb(f32::NAN).is_nan());
assert!(srgb_to_linear(f32::NAN).is_nan());
assert_eq!(linear_to_srgb(0.0), 0.0);
assert!((linear_to_srgb(1.0) - 1.0).abs() < 1e-6);
assert!((srgb_to_linear(linear_to_srgb(0.5)) - 0.5).abs() < 1e-6);
}
#[test]
fn whole_factor_shrink_stays_exact_with_custom_limits() {
let l = Limits { max_side: 16, ..Limits::default() };
let img = Image::try_generate(4, 2, Kind::Color, &l, |x, y| [x as f32, y as f32, 0.0, 1.0]);
let Ok(img) = img else { panic!("valid 4 x 2 image") };
let half = img.resize(2, 1, false);
assert_eq!(half.data(), &[0.5, 0.5, 0.0, 1.0, 2.5, 0.5, 0.0, 1.0]);
assert_eq!(img.to_gray().width(), 4);
}
}