craft-hdr: Radiance HDR (RGBE) picture reader (#925)

FilmCraft's clean-room Radiance HDR reader as a crate: flat, old and new
run-length scan lines, all eight axis orders, to linear float RGB. Scan lines
are validated before the float picture is allocated.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 18:41:32 -05:00
1 parent 64a8c7f74c
commit 49f5f5e83d
6 files changed
+408

No files matched your search

Generated
+7
View File
@@ -1060,6 +1060,13 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-hdr"
version = "0.1.0"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-heif"
version = "0.1.0"
+1
View File
@@ -44,6 +44,7 @@ craft-font-manager = { path = "crates/font-manager" }
craft-font-store = { path = "crates/font-store" }
craft-gainmap = { path = "crates/gainmap" }
craft-gcode = { path = "crates/gcode" }
craft-hdr = { path = "crates/hdr" }
craft-mesh = { path = "crates/mesh" }
craft-heif = { path = "crates/heif" }
craft-fig = { path = "crates/fig" }
+3
View File
@@ -109,6 +109,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
millimetres to `G0`/`G1` moves with laser power and feed, for cutting lasers and plotters, as a
commented file or as bare lines for streaming. Its `grbl` module has the real-time command bytes
and the status and acknowledgement parsers.
- [`craft-hdr`](crates/hdr/src/lib.rs): Radiance HDR (RGBE) picture reader, from FilmCraft: flat,
old and new run-length scan lines and all eight axis orders, to linear float RGB. Scan lines are
validated before the picture is allocated.
- [`craft-heif`](crates/heif/README.md): optional HEIF/HEIC still-image decoder, from LightCraft:
heic-rs reads the container and HEVC pictures, and this crate converts to 16-bit RGB to match
libheif (VUI range and matrix, chroma upsampling, `imir`, ICC on grid tiles). Also orientation,
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "craft-hdr"
description = "Clean-room Radiance HDR (RGBE) picture reader: flat, old and new run-length scan lines, all eight axis orders, to linear float RGB."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
thiserror = { workspace = true }
+239
View File
@@ -0,0 +1,239 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Radiance HDR (`.hdr`, `.pic`) pictures, read from Greg Ward's public RGBE format description
//! ("Real Pixels", Graphics Gems II): a text header (`#?RADIANCE` or `#?RGBE`,
//! `FORMAT=32-bit_rle_rgbe`), a blank line, a resolution line (`-Y h +X w` and the seven other
//! axis orders), then one RGBE pixel (shared exponent) per sample, flat, in the old run-length
//! form or in the "new" per-channel run-length form (scan lines of 8 to 32767 pixels).
//!
//! [`decode`] returns scene-linear float RGB, top row first, so values above 1.0 survive. The
//! `EXPOSURE` and `PRIMARIES` header lines are not applied, which is what other readers do too;
//! the XYZE colour format is refused.
//!
//! Input is treated as hostile: the header length and the picture size are capped by [`Limits`],
//! and every scan line is validated before the float picture is allocated, so a short file cannot
//! ask for a large allocation.
#[cfg(test)]
mod tests;
/// Why a file could not be read.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Error {
/// The bytes do not start with `#?RADIANCE` or `#?RGBE`.
#[error("not a Radiance HDR picture")]
NotHdr,
/// The header or the pixel data is damaged or cut short.
#[error("Radiance HDR: {0}")]
Malformed(&'static str),
/// A valid picture this reader does not cover (the XYZE colour format, an unknown `FORMAT`).
#[error("unsupported Radiance HDR: {0}")]
Unsupported(String),
/// The file declares more than [`Limits`] allow.
#[error("Radiance HDR exceeds a limit: {0}")]
LimitExceeded(&'static str),
}
/// Result of this crate's functions.
pub type Result<T> = std::result::Result<T, Error>;
/// Caps on what a file may ask the reader to allocate, 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 {
/// Most pixels (width × height) a picture may have; each takes 12 bytes decoded. Default 2^26.
pub max_pixels: u64,
/// Longest header read, in bytes (real ones are a few hundred). Default 64 KiB.
pub max_header: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_pixels: 1 << 26, max_header: 64 << 10 }
}
}
/// A decoded picture.
#[derive(Debug, Clone, PartialEq)]
pub struct Picture {
pub width: u32,
pub height: u32,
/// Scene-linear RGB, three floats a pixel, top row first.
pub rgb: Vec<f32>,
}
/// Whether `bytes` starts like a Radiance picture.
pub fn sniff(bytes: &[u8]) -> bool {
bytes.starts_with(b"#?RADIANCE") || bytes.starts_with(b"#?RGBE")
}
/// One resolution-line axis: `(is_x, positive)`.
fn axis(tok: &str) -> Option<(bool, bool)> {
match tok {
"+X" => Some((true, true)),
"-X" => Some((true, false)),
"+Y" => Some((false, true)),
"-Y" => Some((false, false)),
_ => None,
}
}
/// RGBE to linear floats: `mantissa × 2^(e − 136)`, zero for exponent 0. (Radiance's own reader
/// adds half a mantissa step; other common readers do not, which also makes 1.0 =
/// (128, 128, 128, 129) read back as exactly 1.0.)
pub fn rgbe_to_linear(p: [u8; 4]) -> [f32; 3] {
if p[3] == 0 {
return [0.0; 3];
}
let scale = 2f32.powi(i32::from(p[3]) - 136);
[f32::from(p[0]) * scale, f32::from(p[1]) * scale, f32::from(p[2]) * scale]
}
/// Reads the pixels of one scan line into `line`.
fn scanline(b: &[u8], pos: &mut usize, line: &mut [[u8; 4]]) -> Result<()> {
let n = line.len();
let take = |pos: &mut usize, k: usize| -> Result<&[u8]> {
let s = pos.checked_add(k).and_then(|end| b.get(*pos..end)).ok_or(Error::Malformed("truncated pixel data"))?;
*pos += k;
Ok(s)
};
let head = pos.checked_add(4).and_then(|end| b.get(*pos..end));
if (8..=32767).contains(&n)
&& let Some(&[2, 2, hi, lo]) = head
&& hi & 0x80 == 0
{
if usize::from(hi) << 8 | usize::from(lo) != n {
return Err(Error::Malformed("scan line length does not match the picture"));
}
*pos += 4;
for c in 0..4 {
let mut x = 0;
while x < n {
let code = take(pos, 1)?.first().copied().unwrap_or(0);
if code > 128 {
let run = usize::from(code - 128);
let v = take(pos, 1)?.first().copied().unwrap_or(0);
let dst = line.get_mut(x..x + run).ok_or(Error::Malformed("run past the end of the scan line"))?;
for p in dst {
p[c] = v;
}
x += run;
} else {
let run = usize::from(code);
if run == 0 {
return Err(Error::Malformed("empty literal run"));
}
let src = take(pos, run)?;
let dst = line.get_mut(x..x + run).ok_or(Error::Malformed("literal past the end of the scan line"))?;
for (p, &v) in dst.iter_mut().zip(src) {
p[c] = v;
}
x += run;
}
}
}
return Ok(());
}
// Flat pixels, with the old run-length form: (1, 1, 1, k) repeats the previous pixel k << shift times.
let (mut x, mut shift) = (0usize, 0u32);
while x < n {
let p: [u8; 4] = take(pos, 4)?.try_into().map_err(|_| Error::Malformed("truncated pixel data"))?;
if p[..3] == [1, 1, 1] {
let count = usize::from(p[3]).checked_shl(shift).unwrap_or(usize::MAX);
let prev = if x > 0 { line.get(x - 1).copied() } else { None }.ok_or(Error::Malformed("run with no previous pixel"))?;
let dst = line.get_mut(x..x.saturating_add(count).min(n)).ok_or(Error::Malformed("run past the end of the scan line"))?;
dst.fill(prev);
x += dst.len();
shift = shift.saturating_add(8).min(24);
} else {
if let Some(d) = line.get_mut(x) {
*d = p;
}
x += 1;
shift = 0;
}
}
Ok(())
}
/// Decodes a Radiance picture to linear float RGB, top row first.
pub fn decode(b: &[u8], limits: &Limits) -> Result<Picture> {
if !sniff(b) {
return Err(Error::NotHdr);
}
// Header: lines up to the first empty one, then the resolution line.
let mut pos = 0;
let line = |pos: &mut usize| -> Result<String> {
let rest = b.get(*pos..).ok_or(Error::Malformed("truncated header"))?;
let end = rest.iter().take(limits.max_header).position(|&c| c == b'\n').ok_or(Error::Malformed("truncated header"))?;
let s = String::from_utf8_lossy(rest.get(..end).unwrap_or_default()).trim_end_matches('\r').to_string();
*pos += end + 1;
Ok(s)
};
loop {
let l = line(&mut pos)?;
if l.is_empty() {
break;
}
if let Some(f) = l.strip_prefix("FORMAT=") {
match f.trim() {
"32-bit_rle_rgbe" => {}
"32-bit_rle_xyze" => return Err(Error::Unsupported("the XYZE colour format".into())),
other => return Err(Error::Unsupported(format!("format {other}"))),
}
}
if pos > limits.max_header {
return Err(Error::LimitExceeded("header length"));
}
}
let res = line(&mut pos)?;
let bad = Error::Malformed("bad resolution line");
let t: Vec<&str> = res.split_whitespace().collect();
let &[a1, n1, a2, n2] = t.as_slice().first_chunk::<4>().ok_or(bad.clone())?;
let ((x1, p1), (x2, p2)) = (axis(a1).ok_or(bad.clone())?, axis(a2).ok_or(bad.clone())?);
if x1 == x2 {
return Err(Error::Malformed("resolution line names one axis twice"));
}
let n1: usize = n1.parse().map_err(|_| bad.clone())?;
let n2: usize = n2.parse().map_err(|_| bad.clone())?;
let (w, h) = if x1 { (n1, n2) } else { (n2, n1) };
if w == 0 || h == 0 {
return Err(Error::Malformed("empty picture"));
}
let (Ok(width), Ok(height)) = (u32::try_from(w), u32::try_from(h)) else { return Err(Error::LimitExceeded("pixel count")) };
let pixels = u64::from(width) * u64::from(height);
if pixels > limits.max_pixels {
return Err(Error::LimitExceeded("pixel count"));
}
// A scan line takes at least a few bits of file, so a header cannot ask for more than the data has.
if (b.len().saturating_sub(pos) as u64).saturating_mul(4096) < pixels {
return Err(Error::Malformed("truncated pixel data"));
}
// Validate every encoded scan line before allocating the float picture: a short file could
// otherwise ask for the whole limit of zeroed pixels and only then fail on its first line.
let pixel_data = pos;
let mut scan = vec![[0u8; 4]; n2];
for _ in 0..n1 {
scanline(b, &mut pos, &mut scan)?;
}
let total = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or(Error::LimitExceeded("pixel count"))?;
let mut rgb = vec![0f32; total];
pos = pixel_data;
for i in 0..n1 {
scanline(b, &mut pos, &mut scan)?;
for (j, &p) in scan.iter().enumerate() {
// The first axis counts scan lines, the second runs along one.
let (x, y) = if x1 {
(if p1 { i } else { w - 1 - i }, if p2 { h - 1 - j } else { j })
} else {
(if p2 { j } else { w - 1 - j }, if p1 { h - 1 - i } else { i })
};
let at = (y * w + x) * 3;
if let Some(o) = rgb.get_mut(at..at + 3) {
o.copy_from_slice(&rgbe_to_linear(p));
}
}
}
Ok(Picture { width, height, rgb })
}
+144
View File
@@ -0,0 +1,144 @@
//! Ported from FilmCraft's Radiance HDR reader tests.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn file(res: &str, body: &[u8]) -> Vec<u8> {
let mut v = format!("#?RADIANCE\n# test\nFORMAT=32-bit_rle_rgbe\nEXPOSURE=1.0\n\n{res}\n").into_bytes();
v.extend_from_slice(body);
v
}
fn dec(b: &[u8]) -> Result<Picture> {
decode(b, &Limits::default())
}
/// New-style RLE of one scan line, as Radiance's own writer does: per channel, runs of 3+ equal bytes.
fn rle_line(line: &[[u8; 4]]) -> Vec<u8> {
let n = line.len();
let mut out = vec![2, 2, (n >> 8) as u8, n as u8];
for c in 0..4 {
let ch: Vec<u8> = line.iter().map(|p| p[c]).collect();
let mut i = 0;
while i < n {
let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count();
if run >= 3 {
out.extend([128 + run as u8, ch[i]]);
i += run;
} else {
let mut j = i + 1;
while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) {
j += 1;
}
out.push((j - i) as u8);
out.extend_from_slice(&ch[i..j]);
i = j;
}
}
}
out
}
#[test]
fn rgbe_values() {
assert_eq!(rgbe_to_linear([0, 0, 0, 0]), [0.0; 3]);
assert_eq!(rgbe_to_linear([128, 128, 128, 129]), [1.0; 3]);
assert_eq!(rgbe_to_linear([128, 64, 0, 136]), [128.0, 64.0, 0.0]);
assert_eq!(rgbe_to_linear([128, 128, 128, 128])[0], 0.5);
assert!(rgbe_to_linear([255, 255, 255, 255])[0].is_finite());
}
#[test]
fn flat_top_to_bottom() {
let body = [[10u8, 20, 30, 136], [1, 2, 3, 136], [4, 5, 6, 137], [7, 8, 9, 135]].concat();
let f = dec(&file("-Y 2 +X 2", &body)).unwrap();
assert_eq!((f.width, f.height), (2, 2));
// Exponent 137 scales by 2, 135 by 1/2.
assert_eq!(f.rgb, [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 8.0, 10.0, 12.0, 3.5, 4.0, 4.5]);
// The `#?RGBE` signature and CRLF header lines read the same.
let mut crlf = b"#?RGBE\r\nFORMAT=32-bit_rle_rgbe\r\n\r\n-Y 2 +X 2\r\n".to_vec();
crlf.extend_from_slice(&body);
assert_eq!(dec(&crlf).unwrap(), f);
}
#[test]
fn orientations() {
// 3 wide, 2 high, pixel value = its index in the file.
let body: Vec<u8> = (0..6u8).flat_map(|i| [i, 0, 0, 136]).collect();
let red = |res: &str| -> Vec<f32> { dec(&file(res, &body)).unwrap().rgb.chunks(3).map(|p| p[0]).collect() };
assert_eq!(red("-Y 2 +X 3"), [0., 1., 2., 3., 4., 5.]);
assert_eq!(red("+Y 2 +X 3"), [3., 4., 5., 0., 1., 2.]);
assert_eq!(red("-Y 2 -X 3"), [2., 1., 0., 5., 4., 3.]);
// Scan lines are columns: file index = x * 2 + (row, top first for -Y).
assert_eq!(red("+X 3 -Y 2"), [0., 2., 4., 1., 3., 5.]);
assert_eq!(red("-X 3 +Y 2"), [5., 3., 1., 4., 2., 0.]);
let f = dec(&file("+X 3 -Y 2", &body)).unwrap();
assert_eq!((f.width, f.height), (3, 2));
}
#[test]
fn run_length_lines() {
let w = 40usize;
let rows: Vec<Vec<[u8; 4]>> =
(0..3).map(|y| (0..w).map(|x| if x < 20 { [100, 50, 25, 130] } else { [(x * 5 + y) as u8, 7, 9, 131] }).collect()).collect();
let body: Vec<u8> = rows.iter().flat_map(|r| rle_line(r)).collect();
let f = dec(&file(&format!("-Y 3 +X {w}"), &body)).unwrap();
for (y, r) in rows.iter().enumerate() {
for (x, q) in r.iter().enumerate() {
assert_eq!(&f.rgb[(y * w + x) * 3..(y * w + x) * 3 + 3], &rgbe_to_linear(*q), "pixel {x},{y}");
}
}
}
#[test]
fn old_style_runs() {
// One pixel, then (1,1,1,3) repeats it 3 times, then a new pixel.
let body = [[5u8, 6, 7, 136], [1, 1, 1, 3], [9, 9, 9, 136]].concat();
let f = dec(&file("-Y 1 +X 5", &body)).unwrap();
assert_eq!(f.rgb.chunks(3).map(|p| p[0]).collect::<Vec<_>>(), [5.0, 5.0, 5.0, 5.0, 9.0]);
}
#[test]
fn hostile_input_is_an_error_not_a_panic() {
let ok = file("-Y 2 +X 2", &[0; 16]);
assert!(dec(&ok).is_ok());
for cut in 0..ok.len() - 1 {
// A cut anywhere is an error (the header or the pixel data is incomplete), never a panic.
assert!(dec(&ok[..cut]).is_err(), "{cut}");
}
assert_eq!(dec(b"P6 1 1 255 abc"), Err(Error::NotHdr));
assert_eq!(dec(&file("-Y 2 +X 2", &[0; 8])), Err(Error::Malformed("truncated pixel data")));
assert_eq!(dec(&file("-Y 0 +X 4", &[])), Err(Error::Malformed("empty picture")));
assert_eq!(dec(&file("-Y 2 -Y 2", &[0; 16])), Err(Error::Malformed("resolution line names one axis twice")));
assert_eq!(dec(&file("junk", &[0; 16])), Err(Error::Malformed("bad resolution line")));
assert!(matches!(dec(b"#?RADIANCE\nFORMAT=32-bit_rle_xyze\n\n-Y 1 +X 1\n\0\0\0\0"), Err(Error::Unsupported(_))));
assert!(matches!(dec(b"#?RADIANCE\nFORMAT=other\n\n-Y 1 +X 1\n\0\0\0\0"), Err(Error::Unsupported(_))));
// An RLE line announcing the wrong width, a run past the end, a run at the start of a flat line.
assert_eq!(dec(&file("-Y 1 +X 10", &[2, 2, 0, 11, 0])), Err(Error::Malformed("scan line length does not match the picture")));
assert_eq!(dec(&file("-Y 1 +X 10", &[2, 2, 0, 10, 200, 1])), Err(Error::Malformed("run past the end of the scan line")));
assert_eq!(dec(&file("-Y 1 +X 10", &[2, 2, 0, 10, 0])), Err(Error::Malformed("empty literal run")));
assert_eq!(dec(&file("-Y 1 +X 3", &[1, 1, 1, 9, 0, 0, 0, 0])), Err(Error::Malformed("run with no previous pixel")));
assert!(dec(b"#?RADIANCE").is_err());
}
#[test]
fn limits_are_checked_before_allocating() {
assert_eq!(dec(&file("-Y 99999999 +X 99999999", &[0; 16])), Err(Error::LimitExceeded("pixel count")));
assert_eq!(dec(&file("-Y 99999999999 +X 1", &[0; 16])), Err(Error::LimitExceeded("pixel count")));
// Within the pixel limit and past the coarse size check, but the scan lines are not there:
// refused by validation, before the float picture is allocated.
assert_eq!(dec(&file("-Y 67108864 +X 1", &[0; 16_384])), Err(Error::Malformed("truncated pixel data")));
let ok = file("-Y 2 +X 2", &[0; 16]);
assert!(decode(&ok, &Limits { max_pixels: 4, ..Limits::default() }).is_ok());
assert_eq!(decode(&ok, &Limits { max_pixels: 3, ..Limits::default() }), Err(Error::LimitExceeded("pixel count")));
// A header without its blank line inside the header limit.
let mut long = b"#?RADIANCE\n".to_vec();
for _ in 0..8 {
long.extend_from_slice(b"# 0123456789\n");
}
long.extend_from_slice(b"\n-Y 1 +X 1\n\0\0\0\0");
assert!(dec(&long).is_ok());
assert_eq!(decode(&long, &Limits { max_header: 64, ..Limits::default() }), Err(Error::LimitExceeded("header length")));
assert_eq!(decode(&long, &Limits { max_header: 8, ..Limits::default() }), Err(Error::Malformed("truncated header")));
assert_eq!((Limits::default().max_pixels, Limits::default().max_header), (1 << 26, 64 << 10));
}