render: bounded cooperative HDR input foundation (#837)
A cancellable, bounded Radiance HDR decoder for render lighting input; reference images still accept PNG and JPEG only.
This commit is contained in:
1 parent
8319c35a32
commit
e99b3bd4e8
11 files changed
+697
-2
No files matched your search
@@ -86,6 +86,16 @@ Generated-in-code assets are original and have no file to list:
|
|||||||
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
|
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
|
||||||
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
|
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
|
||||||
|
|
||||||
|
## Incremental HDR codec adapter
|
||||||
|
|
||||||
|
`crates/render/src/lighting/hdr.rs` adapts the scanline/component/old-RLE decoding
|
||||||
|
state machines from the Radiance decoder in `image` 0.25.10
|
||||||
|
([source](https://docs.rs/crate/image/0.25.10/source/src/codecs/hdr/decoder.rs)),
|
||||||
|
MIT OR Apache-2.0. Metadata parsing still uses that library. The adapter makes row
|
||||||
|
work cooperative and adds checked run lengths, zero-run rejection, width validation,
|
||||||
|
header/input/radiance bounds and cancellation. Original licence texts are retained
|
||||||
|
in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
|
||||||
|
|
||||||
## Recovered contributions
|
## Recovered contributions
|
||||||
|
|
||||||
| SolidCraft file | Original contribution | Licence |
|
| SolidCraft file | Original contribution | Licence |
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ quick-xml = "0.41"
|
|||||||
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||||
|
|
||||||
# images
|
# images
|
||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
|
||||||
|
|
||||||
# ui
|
# ui
|
||||||
egui = "0.36"
|
egui = "0.36"
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
|
|||||||
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
|
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
|
||||||
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
|
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
|
||||||
|
|
||||||
|
The incremental Radiance RGBE row adapter in crates/render/src/lighting/hdr.rs adapts
|
||||||
|
decoding state machines from image 0.25.10 (https://github.com/image-rs/image),
|
||||||
|
MIT OR Apache-2.0. ATTRIBUTION.md describes the bounded/cancellable changes; the
|
||||||
|
original licence texts are included in THIRD-PARTY-LICENSES.txt.
|
||||||
|
|
||||||
Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans
|
Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans
|
||||||
for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. The
|
for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. The
|
||||||
per-file list is in ATTRIBUTION.md.
|
per-file list is in ATTRIBUTION.md.
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
|||||||
|
|
||||||
## Render foundation
|
## Render foundation
|
||||||
|
|
||||||
|
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
|
||||||
|
it does not yet enable HDRI/PBR rendering or a document import command. See
|
||||||
|
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
|
||||||
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
|
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
|
||||||
bounded image/scratch admission checks, exposed by the read-only `render.backends`
|
bounded image/scratch admission checks, exposed by the read-only `render.backends`
|
||||||
command. It describes the current CPU raster snapshot path without evaluating the model.
|
command. It describes the current CPU raster snapshot path without evaluating the model.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
mod backend;
|
mod backend;
|
||||||
mod camera;
|
mod camera;
|
||||||
mod curve_display;
|
mod curve_display;
|
||||||
|
pub mod lighting;
|
||||||
mod raster;
|
mod raster;
|
||||||
|
|
||||||
pub use backend::{
|
pub use backend::{
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
//! Incremental RGBE scanline adapter for image 0.25.10's Radiance codec.
|
||||||
|
//! Row/RLE handling is adapted from its `read_scanline`, `decode_component` and
|
||||||
|
//! `decode_old_rle` (MIT OR Apache-2.0; see ATTRIBUTION.md and NOTICE). Metadata still
|
||||||
|
//! uses the library. Changes add cooperative steps, checked runs, width validation
|
||||||
|
//! and bounded radiance; no third-party asserts or input indexing are retained.
|
||||||
|
|
||||||
|
use super::{HdrError, HdrErrorCode as ErrorCode, Result, check_cancelled};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::{io::Cursor, sync::Arc};
|
||||||
|
|
||||||
|
pub const MAX_HDR_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
pub const MAX_HDR_WIDTH: u32 = 2048;
|
||||||
|
pub const MAX_HDR_HEIGHT: u32 = 1024;
|
||||||
|
pub const MAX_HDR_PIXELS: u64 = 2 * 1024 * 1024;
|
||||||
|
const MAX_HEADER_BYTES: usize = 64 * 1024;
|
||||||
|
const MAX_RADIANCE: f64 = 1e8;
|
||||||
|
|
||||||
|
fn invalid(message: impl Into<String>) -> HdrError {
|
||||||
|
HdrError::new(ErrorCode::InvalidInput, message)
|
||||||
|
}
|
||||||
|
fn limit(message: impl Into<String>) -> HdrError {
|
||||||
|
HdrError::new(ErrorCode::LimitExceeded, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct HdrInfo {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub encoded_bytes: u64,
|
||||||
|
pub decoded_rgb_bytes: u64,
|
||||||
|
/// Stored RGBE values are divided by these declared header corrections.
|
||||||
|
pub exposure: f64,
|
||||||
|
pub color_correction: [f64; 3],
|
||||||
|
/// Source chromaticities (Rx Ry Gx Gy Bx By Wx Wy); absent means unspecified.
|
||||||
|
pub primaries: Option<[f64; 8]>,
|
||||||
|
#[serde(skip)]
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable input. Construction only inspects a bounded header; `validate` also checks
|
||||||
|
/// every encoded row and corrected radiance before an import commits it to a document.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HdrSource {
|
||||||
|
bytes: Arc<Vec<u8>>,
|
||||||
|
info: HdrInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HdrSource {
|
||||||
|
pub fn new(bytes: Vec<u8>) -> Result<Self> {
|
||||||
|
if bytes.is_empty() || bytes.len() > MAX_HDR_BYTES || bytes.capacity() > MAX_HDR_BYTES {
|
||||||
|
return Err(limit("HDR input must contain at most 16 MiB"));
|
||||||
|
}
|
||||||
|
let mut cursor = Cursor::new(bytes.get(..bytes.len().min(MAX_HEADER_BYTES)).ok_or_else(|| invalid("invalid HDR header range"))?);
|
||||||
|
let metadata = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
image::codecs::hdr::HdrDecoder::new(&mut cursor).map(|decoder| decoder.metadata())
|
||||||
|
}))
|
||||||
|
.map_err(|_| invalid("HDR metadata decoder failed"))?
|
||||||
|
.map_err(|e| invalid(format!("invalid or oversized Radiance header: {e}")))?;
|
||||||
|
let pixels = u64::from(metadata.width).checked_mul(u64::from(metadata.height)).ok_or_else(|| limit("HDR dimensions overflow"))?;
|
||||||
|
if metadata.width == 0
|
||||||
|
|| metadata.height == 0
|
||||||
|
|| metadata.width > MAX_HDR_WIDTH
|
||||||
|
|| metadata.height > MAX_HDR_HEIGHT
|
||||||
|
|| pixels > MAX_HDR_PIXELS
|
||||||
|
{
|
||||||
|
return Err(limit("HDR preview input exceeds 2048x1024 / 2 million pixels"));
|
||||||
|
}
|
||||||
|
if metadata.width != metadata.height.saturating_mul(2) || metadata.orientation != ((1, 0), (0, 1)) {
|
||||||
|
return Err(invalid("HDR preview requires a 2:1 equirectangular image with standard -Y +X orientation"));
|
||||||
|
}
|
||||||
|
let exposure = f64::from(metadata.exposure.unwrap_or(1.0));
|
||||||
|
let (r, g, b) = metadata.color_correction.unwrap_or((1.0, 1.0, 1.0));
|
||||||
|
let correction = [f64::from(r), f64::from(g), f64::from(b)];
|
||||||
|
if [exposure].into_iter().chain(correction).any(|x| !x.is_finite() || x <= 0.0)
|
||||||
|
|| metadata.pixel_aspect_ratio.is_some_and(|x| !x.is_finite() || (x - 1.0).abs() > 1e-6)
|
||||||
|
{
|
||||||
|
return Err(invalid("HDR exposure, colour correction and square-pixel metadata must be finite and positive"));
|
||||||
|
}
|
||||||
|
let offset = usize::try_from(cursor.position()).map_err(|_| invalid("HDR data offset overflow"))?;
|
||||||
|
if offset.checked_add(4).is_none_or(|end| end > bytes.len()) {
|
||||||
|
return Err(invalid("HDR input has no pixel data"));
|
||||||
|
}
|
||||||
|
let mut primaries = None;
|
||||||
|
for (key, value) in &metadata.custom_attributes {
|
||||||
|
if key == "PRIMARIES" {
|
||||||
|
if primaries.is_some() {
|
||||||
|
return Err(invalid("duplicate HDR primaries"));
|
||||||
|
}
|
||||||
|
let mut parsed = [0.0; 8];
|
||||||
|
let mut values = value.split_whitespace();
|
||||||
|
for out in &mut parsed {
|
||||||
|
*out = values
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| invalid("missing HDR chromaticity"))?
|
||||||
|
.parse::<f64>()
|
||||||
|
.map_err(|_| invalid("invalid HDR chromaticity"))?;
|
||||||
|
if !out.is_finite() || !(0.0..=1.0).contains(out) {
|
||||||
|
return Err(invalid("invalid HDR chromaticity"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if values.next().is_some() {
|
||||||
|
return Err(invalid("extra HDR chromaticity"));
|
||||||
|
}
|
||||||
|
primaries = Some(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let info = HdrInfo {
|
||||||
|
width: metadata.width,
|
||||||
|
height: metadata.height,
|
||||||
|
encoded_bytes: bytes.len() as u64,
|
||||||
|
decoded_rgb_bytes: pixels.checked_mul(12).ok_or_else(|| limit("HDR decoded size overflow"))?,
|
||||||
|
exposure,
|
||||||
|
color_correction: correction,
|
||||||
|
primaries,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
Ok(Self { bytes: Arc::new(bytes), info })
|
||||||
|
}
|
||||||
|
pub fn info(&self) -> &HdrInfo {
|
||||||
|
&self.info
|
||||||
|
}
|
||||||
|
pub fn encoded_bytes(&self) -> &[u8] {
|
||||||
|
&self.bytes
|
||||||
|
}
|
||||||
|
pub fn validate(&self, cancelled: impl Fn() -> bool) -> Result<()> {
|
||||||
|
check_cancelled(&cancelled)?;
|
||||||
|
let mut rows = Rows::new(self.clone())?;
|
||||||
|
let mut steps = 0u64;
|
||||||
|
while !rows.done() {
|
||||||
|
check_cancelled(&cancelled)?;
|
||||||
|
if rows.ready() {
|
||||||
|
for x in 0..self.info.width as usize {
|
||||||
|
check_cancelled(&cancelled)?;
|
||||||
|
rows.linear(x)?;
|
||||||
|
}
|
||||||
|
rows.next_row()?;
|
||||||
|
} else {
|
||||||
|
rows.step()?;
|
||||||
|
}
|
||||||
|
steps = steps.checked_add(1).ok_or_else(|| limit("HDR decode work overflow"))?;
|
||||||
|
if steps > MAX_HDR_PIXELS * 6 + 4096 {
|
||||||
|
return Err(limit("HDR decode work budget exceeded"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "hdr_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
enum Stage {
|
||||||
|
Start,
|
||||||
|
Channel { channel: usize, x: usize },
|
||||||
|
Old { x: usize, previous: [u8; 4], shift: u32, repeat: usize },
|
||||||
|
Ready,
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct Rows {
|
||||||
|
source: HdrSource,
|
||||||
|
offset: usize,
|
||||||
|
row: u32,
|
||||||
|
pixels: Vec<[u8; 4]>,
|
||||||
|
scales: [f64; 256],
|
||||||
|
stage: Stage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rows {
|
||||||
|
pub(super) fn new(source: HdrSource) -> Result<Self> {
|
||||||
|
let mut pixels = Vec::new();
|
||||||
|
pixels
|
||||||
|
.try_reserve_exact(source.info.width as usize)
|
||||||
|
.map_err(|_| HdrError::new(ErrorCode::AllocationFailed, "could not reserve HDR scanline"))?;
|
||||||
|
pixels.resize(source.info.width as usize, [0; 4]);
|
||||||
|
Ok(Self {
|
||||||
|
offset: source.info.offset,
|
||||||
|
source,
|
||||||
|
row: 0,
|
||||||
|
pixels,
|
||||||
|
scales: std::array::from_fn(|e| 2f64.powi(e as i32 - 136)),
|
||||||
|
stage: Stage::Start,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(super) fn info(&self) -> &HdrInfo {
|
||||||
|
&self.source.info
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn row(&self) -> u32 {
|
||||||
|
self.row
|
||||||
|
}
|
||||||
|
pub(super) fn ready(&self) -> bool {
|
||||||
|
matches!(self.stage, Stage::Ready)
|
||||||
|
}
|
||||||
|
pub(super) fn done(&self) -> bool {
|
||||||
|
matches!(self.stage, Stage::Done)
|
||||||
|
}
|
||||||
|
fn byte(&mut self) -> Result<u8> {
|
||||||
|
let b = self.source.bytes.get(self.offset).copied().ok_or_else(|| invalid("truncated HDR scanline"))?;
|
||||||
|
self.offset = self.offset.checked_add(1).ok_or_else(|| invalid("HDR offset overflow"))?;
|
||||||
|
Ok(b)
|
||||||
|
}
|
||||||
|
fn pixel(&mut self) -> Result<[u8; 4]> {
|
||||||
|
Ok([self.byte()?, self.byte()?, self.byte()?, self.byte()?])
|
||||||
|
}
|
||||||
|
fn put(&mut self, x: usize, pixel: [u8; 4]) -> Result<()> {
|
||||||
|
*self.pixels.get_mut(x).ok_or_else(|| invalid("HDR run exceeds its row"))? = pixel;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
/// One packet (at most 128 component writes), or one old-style repeated pixel.
|
||||||
|
pub(super) fn step(&mut self) -> Result<()> {
|
||||||
|
let width = self.pixels.len();
|
||||||
|
let stage = std::mem::replace(&mut self.stage, Stage::Done);
|
||||||
|
self.stage = match stage {
|
||||||
|
Stage::Start => {
|
||||||
|
let first = self.pixel()?;
|
||||||
|
if first.get(..2) == Some(&[2, 2]) && first.get(2).is_some_and(|v| *v < 128) {
|
||||||
|
let declared = usize::from(*first.get(2).ok_or_else(|| invalid("missing HDR width"))?) * 256
|
||||||
|
+ usize::from(*first.get(3).ok_or_else(|| invalid("missing HDR width"))?);
|
||||||
|
if declared != width {
|
||||||
|
return Err(invalid("HDR scanline width differs from its header"));
|
||||||
|
}
|
||||||
|
Stage::Channel { channel: 0, x: 0 }
|
||||||
|
} else {
|
||||||
|
if first.get(..3) == Some(&[1, 1, 1]) {
|
||||||
|
return Err(invalid("HDR row starts with an old run marker"));
|
||||||
|
}
|
||||||
|
self.put(0, first)?;
|
||||||
|
if width == 1 { Stage::Ready } else { Stage::Old { x: 1, previous: first, shift: 0, repeat: 0 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stage::Channel { channel, x } => {
|
||||||
|
let code = self.byte()?;
|
||||||
|
if code == 0 {
|
||||||
|
return Err(invalid("zero-length HDR RLE packet"));
|
||||||
|
}
|
||||||
|
let run = code > 128;
|
||||||
|
let count = usize::from(if run { code - 128 } else { code });
|
||||||
|
let end = x.checked_add(count).filter(|end| *end <= width).ok_or_else(|| invalid("HDR RLE packet exceeds its row"))?;
|
||||||
|
let repeated = if run { self.byte()? } else { 0 };
|
||||||
|
for at in x..end {
|
||||||
|
let value = if run { repeated } else { self.byte()? };
|
||||||
|
*self.pixels.get_mut(at).and_then(|p| p.get_mut(channel)).ok_or_else(|| invalid("invalid HDR component address"))? = value;
|
||||||
|
}
|
||||||
|
if end == width {
|
||||||
|
if channel == 3 { Stage::Ready } else { Stage::Channel { channel: channel + 1, x: 0 } }
|
||||||
|
} else {
|
||||||
|
Stage::Channel { channel, x: end }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stage::Old { x, previous, shift, repeat } => {
|
||||||
|
if repeat > 0 {
|
||||||
|
self.put(x, previous)?;
|
||||||
|
if x + 1 == width { Stage::Ready } else { Stage::Old { x: x + 1, previous, shift, repeat: repeat - 1 } }
|
||||||
|
} else {
|
||||||
|
let pixel = self.pixel()?;
|
||||||
|
if pixel.get(..3) == Some(&[1, 1, 1]) {
|
||||||
|
let count = u64::from(*pixel.get(3).ok_or_else(|| invalid("missing HDR repeat count"))?)
|
||||||
|
.checked_shl(shift)
|
||||||
|
.filter(|n| *n <= (width - x) as u64)
|
||||||
|
.ok_or_else(|| invalid("old HDR run exceeds its row"))?;
|
||||||
|
let next_shift = shift.checked_add(8).filter(|s| *s <= 32).ok_or_else(|| invalid("old HDR run multiplier overflow"))?;
|
||||||
|
Stage::Old { x, previous, shift: next_shift, repeat: count as usize }
|
||||||
|
} else {
|
||||||
|
self.put(x, pixel)?;
|
||||||
|
if x + 1 == width { Stage::Ready } else { Stage::Old { x: x + 1, previous: pixel, shift: 0, repeat: 0 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stage::Ready => return Err(invalid("HDR row must be consumed before decoding another")),
|
||||||
|
Stage::Done => return Err(invalid("HDR input already consumed")),
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub(super) fn linear(&self, x: usize) -> Result<[f32; 3]> {
|
||||||
|
if !self.ready() {
|
||||||
|
return Err(invalid("HDR row is not ready"));
|
||||||
|
}
|
||||||
|
let pixel = self.pixels.get(x).ok_or_else(|| invalid("invalid HDR pixel address"))?;
|
||||||
|
let exponent = *pixel.get(3).ok_or_else(|| invalid("missing RGBE exponent"))?;
|
||||||
|
if exponent == 0 {
|
||||||
|
return Ok([0.0; 3]);
|
||||||
|
}
|
||||||
|
let scale = self.scales.get(usize::from(exponent)).copied().ok_or_else(|| invalid("invalid RGBE exponent"))? / self.source.info.exposure;
|
||||||
|
let mut rgb = [0.0; 3];
|
||||||
|
for (channel, out) in rgb.iter_mut().enumerate() {
|
||||||
|
let value = f64::from(*pixel.get(channel).ok_or_else(|| invalid("invalid RGBE channel"))?) * scale
|
||||||
|
/ self.source.info.color_correction.get(channel).copied().ok_or_else(|| invalid("invalid HDR correction"))?;
|
||||||
|
if !value.is_finite() || !(0.0..=MAX_RADIANCE).contains(&value) {
|
||||||
|
return Err(limit("corrected HDR radiance exceeds 100 million"));
|
||||||
|
}
|
||||||
|
*out = value as f32;
|
||||||
|
}
|
||||||
|
Ok(rgb)
|
||||||
|
}
|
||||||
|
pub(super) fn next_row(&mut self) -> Result<()> {
|
||||||
|
if !self.ready() {
|
||||||
|
return Err(invalid("HDR row is not ready"));
|
||||||
|
}
|
||||||
|
self.row = self.row.checked_add(1).ok_or_else(|| invalid("HDR row overflow"))?;
|
||||||
|
self.stage = if self.row == self.source.info.height { Stage::Done } else { Stage::Start };
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::lighting::{HdrDecodeTask, HdrTaskPoll};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cooperative_task_matches_codec_and_is_consumed_once() {
|
||||||
|
for bytes in [uncompressed(""), rle(), literal_rle(), old_rle()] {
|
||||||
|
let expected = image::load_from_memory_with_format(&bytes, image::ImageFormat::Hdr).unwrap().into_rgb32f();
|
||||||
|
let mut task = HdrDecodeTask::new(HdrSource::new(bytes).unwrap()).unwrap();
|
||||||
|
let mut calls = 0;
|
||||||
|
loop {
|
||||||
|
calls += 1;
|
||||||
|
assert!(calls < 1000);
|
||||||
|
match task.step(1, || false).unwrap() {
|
||||||
|
HdrTaskPoll::Pending(progress) => assert!(progress.decoded_pixels < progress.total_pixels),
|
||||||
|
HdrTaskPoll::Complete(result) => {
|
||||||
|
assert_eq!(result.pixels().len(), expected.pixels().len());
|
||||||
|
for (actual, expected) in result.pixels().iter().zip(expected.pixels()) {
|
||||||
|
assert_eq!(actual, &expected.0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(calls > 8);
|
||||||
|
assert_eq!(task.step(1, || false).err().unwrap().code, ErrorCode::Consumed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancellation_or_bad_input_cannot_publish_partial_pixels_later() {
|
||||||
|
let mut task = HdrDecodeTask::new(HdrSource::new(rle()).unwrap()).unwrap();
|
||||||
|
assert!(matches!(task.step(7, || false).unwrap(), HdrTaskPoll::Pending(_)));
|
||||||
|
assert_eq!(task.step(1, || true).err().unwrap().code, ErrorCode::Cancelled);
|
||||||
|
assert_eq!(task.step(16384, || false).err().unwrap().code, ErrorCode::Consumed);
|
||||||
|
let mut bytes = rle();
|
||||||
|
bytes.pop();
|
||||||
|
let mut task = HdrDecodeTask::new(HdrSource::new(bytes).unwrap()).unwrap();
|
||||||
|
assert_eq!(task.step(16384, || false).err().unwrap().code, ErrorCode::InvalidInput);
|
||||||
|
assert_eq!(task.step(1, || false).err().unwrap().code, ErrorCode::Consumed);
|
||||||
|
|
||||||
|
let checks = std::cell::Cell::new(0);
|
||||||
|
let mut task = HdrDecodeTask::new(HdrSource::new(rle()).unwrap()).unwrap();
|
||||||
|
let result = task.step(16384, || {
|
||||||
|
checks.set(checks.get() + 1);
|
||||||
|
checks.get() == 10
|
||||||
|
});
|
||||||
|
assert_eq!(result.err().unwrap().code, ErrorCode::Cancelled);
|
||||||
|
assert_eq!(checks.get(), 10);
|
||||||
|
assert_eq!(task.step(1, || false).err().unwrap().code, ErrorCode::Consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_primaries_are_preserved_without_assuming_srgb() {
|
||||||
|
let source = HdrSource::new(uncompressed("PRIMARIES=0.64 0.33 0.3 0.6 0.15 0.06 0.3127 0.329\n")).unwrap();
|
||||||
|
assert_eq!(source.info().primaries, Some([0.64, 0.33, 0.3, 0.6, 0.15, 0.06, 0.3127, 0.329]));
|
||||||
|
assert!(HdrSource::new(uncompressed("PRIMARIES=NaN 0 0 0 0 0 0 0\n")).is_err());
|
||||||
|
let mut excessive_capacity = Vec::with_capacity(MAX_HDR_BYTES + 1);
|
||||||
|
excessive_capacity.extend_from_slice(&rle());
|
||||||
|
assert_eq!(HdrSource::new(excessive_capacity).err().unwrap().code, ErrorCode::LimitExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header(width: u32, height: u32, extra: &str) -> Vec<u8> {
|
||||||
|
format!("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n{extra}\n-Y {height} +X {width}\n").into_bytes()
|
||||||
|
}
|
||||||
|
fn uncompressed(extra: &str) -> Vec<u8> {
|
||||||
|
let mut bytes = header(4, 2, extra);
|
||||||
|
for _ in 0..8 {
|
||||||
|
bytes.extend_from_slice(&[128, 64, 32, 131]);
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
fn rle() -> Vec<u8> {
|
||||||
|
let mut bytes = header(8, 4, "");
|
||||||
|
for _ in 0..4 {
|
||||||
|
bytes.extend_from_slice(&[2, 2, 0, 8]);
|
||||||
|
for value in [128, 64, 32, 129] {
|
||||||
|
bytes.extend_from_slice(&[136, value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn literal_rle() -> Vec<u8> {
|
||||||
|
let mut bytes = header(8, 4, "");
|
||||||
|
for _ in 0..4 {
|
||||||
|
bytes.extend_from_slice(&[2, 2, 0, 8]);
|
||||||
|
for value in [128, 64, 32, 129] {
|
||||||
|
bytes.push(8);
|
||||||
|
bytes.extend_from_slice(&[value; 8]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn old_rle() -> Vec<u8> {
|
||||||
|
let mut bytes = header(8, 4, "");
|
||||||
|
for _ in 0..4 {
|
||||||
|
bytes.extend_from_slice(&[128, 64, 32, 129, 1, 1, 1, 7]);
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incremental_rows_match_the_original_codec_without_clipping_hdr_values() {
|
||||||
|
for bytes in [uncompressed(""), rle(), literal_rle(), old_rle()] {
|
||||||
|
let expected = image::load_from_memory_with_format(&bytes, image::ImageFormat::Hdr).unwrap().into_rgb32f();
|
||||||
|
let source = HdrSource::new(bytes).unwrap();
|
||||||
|
source.validate(|| false).unwrap();
|
||||||
|
let mut rows = Rows::new(source).unwrap();
|
||||||
|
while !rows.done() {
|
||||||
|
if rows.ready() {
|
||||||
|
for x in 0..rows.info().width as usize {
|
||||||
|
assert_eq!(rows.linear(x).unwrap(), expected.get_pixel(x as u32, rows.row()).0);
|
||||||
|
}
|
||||||
|
rows.next_row().unwrap();
|
||||||
|
} else {
|
||||||
|
rows.step().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_exposure_and_colour_correction_restore_linear_radiance() {
|
||||||
|
let source = HdrSource::new(uncompressed("EXPOSURE=2\nCOLORCORR=1 2 4\n")).unwrap();
|
||||||
|
let mut rows = Rows::new(source).unwrap();
|
||||||
|
while !rows.ready() {
|
||||||
|
rows.step().unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(rows.linear(0).unwrap(), [2.0, 0.5, 0.125]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_runs_truncation_nonfinite_metadata_and_hostile_sizes_are_refused() {
|
||||||
|
let mut truncated = rle();
|
||||||
|
truncated.pop();
|
||||||
|
let mut wrong_width = rle();
|
||||||
|
let offset = HdrSource::new(wrong_width.clone()).unwrap().info.offset;
|
||||||
|
wrong_width[offset + 3] = 9;
|
||||||
|
let mut zero_run = rle();
|
||||||
|
zero_run[offset + 4] = 0;
|
||||||
|
let mut overflow = header(8, 4, "");
|
||||||
|
overflow.extend_from_slice(&[128, 64, 32, 129]);
|
||||||
|
for _ in 0..6 {
|
||||||
|
overflow.extend_from_slice(&[1, 1, 1, 0]);
|
||||||
|
}
|
||||||
|
for bytes in [truncated, wrong_width, zero_run, overflow] {
|
||||||
|
assert!(HdrSource::new(bytes).unwrap().validate(|| false).is_err());
|
||||||
|
}
|
||||||
|
for bytes in [header(u32::MAX, 1, ""), uncompressed("EXPOSURE=NaN\n"), uncompressed("PIXASPECT=0\n")] {
|
||||||
|
assert!(HdrSource::new(bytes).is_err());
|
||||||
|
}
|
||||||
|
let mut too_large = rle();
|
||||||
|
too_large.resize(MAX_HDR_BYTES + 1, 0);
|
||||||
|
assert_eq!(HdrSource::new(too_large).err().unwrap().code, ErrorCode::LimitExceeded);
|
||||||
|
let mut too_bright = uncompressed("");
|
||||||
|
let offset = HdrSource::new(too_bright.clone()).unwrap().info.offset;
|
||||||
|
too_bright[offset + 3] = 255;
|
||||||
|
assert_eq!(HdrSource::new(too_bright).unwrap().validate(|| false).unwrap_err().code, ErrorCode::LimitExceeded);
|
||||||
|
let source = HdrSource::new(rle()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(source.validate(|| true).unwrap_err().code, ErrorCode::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_work_is_bounded_before_accepting_metadata() {
|
||||||
|
let mut bytes = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n".to_vec();
|
||||||
|
bytes.extend(std::iter::repeat_n(b'x', MAX_HEADER_BYTES + 1));
|
||||||
|
bytes.extend_from_slice(b"\n\n-Y 2 +X 4\n");
|
||||||
|
bytes.extend_from_slice(&[128, 64, 32, 129]);
|
||||||
|
assert!(HdrSource::new(bytes).is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! Device-independent, bounded linear HDR input preparation.
|
||||||
|
|
||||||
|
mod hdr;
|
||||||
|
mod task;
|
||||||
|
pub use hdr::{HdrInfo, HdrSource, MAX_HDR_BYTES, MAX_HDR_HEIGHT, MAX_HDR_PIXELS, MAX_HDR_WIDTH};
|
||||||
|
pub use task::{DecodedHdr, HdrDecodeTask, HdrProgress, HdrTaskPoll};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum HdrErrorCode {
|
||||||
|
InvalidInput,
|
||||||
|
LimitExceeded,
|
||||||
|
AllocationFailed,
|
||||||
|
Cancelled,
|
||||||
|
Consumed,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct HdrError {
|
||||||
|
pub code: HdrErrorCode,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HdrError {
|
||||||
|
pub fn new(code: HdrErrorCode, message: impl Into<String>) -> Self {
|
||||||
|
Self { code, message: message.into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl fmt::Display for HdrError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::error::Error for HdrError {}
|
||||||
|
pub type Result<T> = std::result::Result<T, HdrError>;
|
||||||
|
|
||||||
|
fn check_cancelled(cancelled: &impl Fn() -> bool) -> Result<()> {
|
||||||
|
if cancelled() { Err(HdrError::new(HdrErrorCode::Cancelled, "HDR decoding cancelled")) } else { Ok(()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use super::{HdrError, HdrErrorCode, HdrInfo, HdrSource, MAX_HDR_PIXELS, Result, check_cancelled, hdr::Rows};
|
||||||
|
|
||||||
|
/// Linear RGB in the source's colour space, without gamut conversion or tone mapping.
|
||||||
|
pub struct DecodedHdr {
|
||||||
|
info: HdrInfo,
|
||||||
|
pixels: Vec<[f32; 3]>,
|
||||||
|
}
|
||||||
|
impl DecodedHdr {
|
||||||
|
pub fn info(&self) -> &HdrInfo {
|
||||||
|
&self.info
|
||||||
|
}
|
||||||
|
pub fn pixels(&self) -> &[[f32; 3]] {
|
||||||
|
&self.pixels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct HdrProgress {
|
||||||
|
pub decoded_pixels: u64,
|
||||||
|
pub total_pixels: u64,
|
||||||
|
}
|
||||||
|
pub enum HdrTaskPoll {
|
||||||
|
Pending(HdrProgress),
|
||||||
|
Complete(DecodedHdr),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller-owned cooperative preparation; no scheduler, GPU or document dependency.
|
||||||
|
pub struct HdrDecodeTask {
|
||||||
|
rows: Option<Rows>,
|
||||||
|
pixels: Vec<[f32; 3]>,
|
||||||
|
x: usize,
|
||||||
|
work: u64,
|
||||||
|
}
|
||||||
|
impl HdrDecodeTask {
|
||||||
|
pub fn new(source: HdrSource) -> Result<Self> {
|
||||||
|
let count = u64::from(source.info().width) * u64::from(source.info().height);
|
||||||
|
let count = usize::try_from(count).map_err(|_| HdrError::new(HdrErrorCode::LimitExceeded, "HDR pixel count overflow"))?;
|
||||||
|
let mut pixels = Vec::new();
|
||||||
|
pixels.try_reserve_exact(count).map_err(|_| HdrError::new(HdrErrorCode::AllocationFailed, "could not reserve HDR pixels"))?;
|
||||||
|
Ok(Self { rows: Some(Rows::new(source)?), pixels, x: 0, work: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each unit consumes one RLE packet (at most 128 bytes) or one RGB pixel.
|
||||||
|
/// Error/cancellation consumes the task and releases its partial buffers.
|
||||||
|
pub fn step(&mut self, units: usize, cancelled: impl Fn() -> bool) -> Result<HdrTaskPoll> {
|
||||||
|
let result = self.advance(units, cancelled);
|
||||||
|
if result.is_err() {
|
||||||
|
self.rows = None;
|
||||||
|
self.pixels = Vec::new();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
fn advance(&mut self, units: usize, cancelled: impl Fn() -> bool) -> Result<HdrTaskPoll> {
|
||||||
|
let rows = self.rows.as_mut().ok_or_else(|| HdrError::new(HdrErrorCode::Consumed, "HDR task already consumed"))?;
|
||||||
|
for _ in 0..units.clamp(1, 16384) {
|
||||||
|
check_cancelled(&cancelled)?;
|
||||||
|
self.work = self
|
||||||
|
.work
|
||||||
|
.checked_add(1)
|
||||||
|
.filter(|n| *n <= MAX_HDR_PIXELS * 6 + 4096)
|
||||||
|
.ok_or_else(|| HdrError::new(HdrErrorCode::LimitExceeded, "HDR decode work budget exceeded"))?;
|
||||||
|
if rows.ready() {
|
||||||
|
self.pixels.push(rows.linear(self.x)?);
|
||||||
|
self.x += 1;
|
||||||
|
if self.x == rows.info().width as usize {
|
||||||
|
rows.next_row()?;
|
||||||
|
self.x = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows.step()?;
|
||||||
|
}
|
||||||
|
if rows.done() {
|
||||||
|
check_cancelled(&cancelled)?;
|
||||||
|
let info = rows.info().clone();
|
||||||
|
self.rows = None;
|
||||||
|
return Ok(HdrTaskPoll::Complete(DecodedHdr { info, pixels: std::mem::take(&mut self.pixels) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(HdrTaskPoll::Pending(HdrProgress {
|
||||||
|
decoded_pixels: self.pixels.len() as u64,
|
||||||
|
total_pixels: u64::from(rows.info().width) * u64::from(rows.info().height),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,15 @@ thread_local! {
|
|||||||
static VERSION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
static VERSION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Codec features are unified across crates. Enabling bounded HDR preparation must
|
||||||
|
// not route HDR through this synchronous whole-image canvas decoder.
|
||||||
|
fn decode_reference(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||||
|
match image::guess_format(bytes).ok()? {
|
||||||
|
image::ImageFormat::Png | image::ImageFormat::Jpeg => image::load_from_memory(bytes).ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn pixels(c: &Canvas) -> Option<Decoded> {
|
fn pixels(c: &Canvas) -> Option<Decoded> {
|
||||||
PIXELS.with(|m| {
|
PIXELS.with(|m| {
|
||||||
let mut m = m.borrow_mut();
|
let mut m = m.borrow_mut();
|
||||||
@@ -36,7 +45,7 @@ fn pixels(c: &Canvas) -> Option<Decoded> {
|
|||||||
{
|
{
|
||||||
return d.clone();
|
return d.clone();
|
||||||
}
|
}
|
||||||
let d = c.bytes().and_then(|b| image::load_from_memory(&b).ok()).map(|i| {
|
let d = c.bytes().and_then(|b| decode_reference(&b)).map(|i| {
|
||||||
let i = if i.width() > MAX_SIDE || i.height() > MAX_SIDE { i.thumbnail(MAX_SIDE, MAX_SIDE) } else { i };
|
let i = if i.width() > MAX_SIDE || i.height() > MAX_SIDE { i.thumbnail(MAX_SIDE, MAX_SIDE) } else { i };
|
||||||
let rgba = i.to_rgba8();
|
let rgba = i.to_rgba8();
|
||||||
let v = VERSION.with(|v| {
|
let v = VERSION.with(|v| {
|
||||||
@@ -138,3 +147,19 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj)
|
|||||||
painter.add(Shape::mesh(mesh));
|
painter.add(Shape::mesh(mesh));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod codec_tests {
|
||||||
|
use super::decode_reference;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hdr_feature_does_not_enable_synchronous_canvas_hdr_decoding() {
|
||||||
|
let mut hdr = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 1\n".to_vec();
|
||||||
|
hdr.extend_from_slice(&[128, 64, 32, 129]);
|
||||||
|
assert!(image::load_from_memory(&hdr).is_ok());
|
||||||
|
assert!(decode_reference(&hdr).is_none());
|
||||||
|
let mut png = std::io::Cursor::new(Vec::new());
|
||||||
|
image::DynamicImage::new_rgba8(1, 1).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||||
|
assert!(decode_reference(png.get_ref()).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Bounded Radiance input
|
||||||
|
|
||||||
|
`solidcraft_render::lighting` provides a caller-owned cooperative RGBE decoder.
|
||||||
|
This is input preparation only: it enables no environment import command, document
|
||||||
|
format, PBR material, lighting capability, GPU upload or image-render backend.
|
||||||
|
The existing canvas decoder explicitly accepts PNG/JPEG only, so Cargo feature
|
||||||
|
unification cannot send HDR through its synchronous whole-image loading path.
|
||||||
|
It is the independent input foundation for #813 / #620, coordinated with the
|
||||||
|
maintainer's renderer work described in #792.
|
||||||
|
|
||||||
|
`HdrSource::new(Vec<u8>)` checks a bounded header using the existing `image`
|
||||||
|
metadata codec. Success does **not** mean the pixel stream is valid. Use
|
||||||
|
`validate(cancelled)` for a synchronous full-stream check, or construct
|
||||||
|
`HdrDecodeTask` and repeatedly call `step(units, cancelled)` for cooperative
|
||||||
|
preparation. Only `Complete(DecodedHdr)` contains publishable pixels. Cancellation
|
||||||
|
and errors release partial buffers; subsequent calls return `Consumed`, including
|
||||||
|
after successful completion. The cancellation callback must itself be bounded.
|
||||||
|
The consumer owns scheduling and must not perform synchronous `validate` on a
|
||||||
|
latency-sensitive UI thread.
|
||||||
|
|
||||||
|
Input limits are 16 MiB of encoded Vec capacity, a 64 KiB header, 2048x1024 and
|
||||||
|
2,097,152 pixels. Only 2:1, square-pixel, standard `-Y +X` RGBE is accepted.
|
||||||
|
EXR, XYZE and other orientations are unsupported. A call accepts 1–16,384 work
|
||||||
|
units; each unit decodes one packet (at most 128 component writes) or converts one
|
||||||
|
pixel. An overall work cap bounds malformed old-style runs. Corrected channels
|
||||||
|
must be finite, nonnegative and at most 100,000,000. Limits are intentional preview
|
||||||
|
policy, not a statement about all valid Radiance files.
|
||||||
|
|
||||||
|
The float pixel allocation is at most 24 MiB, with one RGBE scanline at most 8 KiB
|
||||||
|
and a shared encoded source at most 16 MiB. Construction reserves float storage
|
||||||
|
fallibly; initialization happens incrementally. This is a per-task estimate,
|
||||||
|
not a process memory quota. The consumer must admit concurrent jobs, source
|
||||||
|
ownership, decoded copies, colour conversion, GPU textures and IBL maps within
|
||||||
|
its own total budget before starting.
|
||||||
|
|
||||||
|
RGBE conversion follows `image` 0.25.10's mantissa/exponent convention, with no
|
||||||
|
tone mapping or clipping to [0,1]. Stored values are divided by header EXPOSURE
|
||||||
|
and COLORCORR. Physical units are not guaranteed by arbitrary input files.
|
||||||
|
`HdrInfo::primaries` preserves explicit PRIMARIES chromaticities; absence is
|
||||||
|
unspecified. Output is linear RGB in the source colour space, **not implicitly
|
||||||
|
linear sRGB**. Consumers must choose and validate a working-space transform;
|
||||||
|
basic finite/range validation of chromaticities here does not prove an invertible
|
||||||
|
or physically meaningful gamut. No gamut conversion is performed by this utility.
|
||||||
|
|
||||||
|
The scanline adapter is derived from the permissively licensed `image` codec;
|
||||||
|
see ATTRIBUTION.md, NOTICE and THIRD-PARTY-LICENSES.txt. Test images are original
|
||||||
|
inline byte fixtures. No commercial-product images or materials are included.
|
||||||
Reference in new issue
Block a user