Files
solidcraft-upstream/crates/render/src/texture_input/normalization.rs
T
seluneand0xlillith 1e984c8e46 render: normalize high precision texture inputs (#1267)
Adds a bounded normalization path for PNG16 and ICC-bearing JPEG texture inputs, producing float RGBA with recorded color and orientation decisions.

Co-authored-by: 0xlillith <[email protected]>
2026-10-15 13:55:35 -04:00

308 lines
13 KiB
Rust

//! Explicit high-precision normalization. No sampler, persistent resource or capability.
mod icc;
mod decode;
use super::{MapRole, Result, admitted_format, guarded, invalid};
use image::{ImageFormat, metadata::Orientation};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::sync::Arc;
pub const MAX_WORKING_BYTES: usize = 256 * 1024 * 1024;
pub const MAX_NORMALIZED_BYTES: usize = 64 * 1024 * 1024;
const GAMUT_TOLERANCE: f64 = 3.0 / 65535.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UnprofiledColor {
Refuse,
/// Declare sRGB primaries; use stated PNG gamma, or sRGB transfer when absent.
DeclareSrgbPrimaries,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OrientationPolicy {
Refuse,
Apply,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GamutPolicy {
/// Refuse values beyond the documented finite-precision edge tolerance.
Refuse,
/// Explicit preview policy, with every clamped RGB channel counted in the receipt.
Clip,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct NormalizationOptions {
pub unprofiled_color: UnprofiledColor,
pub orientation: OrientationPolicy,
pub gamut: GamutPolicy,
/// Conservative buffer/workspace admission estimate, not a process-RSS ceiling.
pub max_working_bytes: usize,
}
impl Default for NormalizationOptions {
fn default() -> Self {
Self {
unprofiled_color: UnprofiledColor::Refuse,
orientation: OrientationPolicy::Refuse,
gamut: GamutPolicy::Refuse,
max_working_bytes: MAX_WORKING_BYTES,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ColorConversion {
RawData,
DeclaredSrgb,
PngSrgb,
PngGammaSrgbPrimaries { encoding_gamma: f64 },
IccMatrixTrc { profile_sha256: String },
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct NormalizationReceipt {
pub recipe_version: u32,
pub source_sha256: String,
pub normalized_sha256: String,
pub format: String,
pub encoded_bytes: usize,
pub retained_encoded_capacity: usize,
pub normalized_bytes: usize,
pub role: MapRole,
pub source_dimensions: [u32; 2],
pub source_channel_bits: u8,
pub output_dimensions: [u32; 2],
pub output_encoding: String,
pub output_precision: String,
pub source_exif_orientation: u8,
pub exif_metadata_present: bool,
pub exif_orientation_tag_present: bool,
pub orientation_applied: bool,
pub conversion: ColorConversion,
pub ignored_png_color_metadata: bool,
pub gamut_policy: GamutPolicy,
pub gamut_tolerance: f64,
pub clamped_rgb_channels: u64,
pub estimated_working_bytes: usize,
pub options: NormalizationOptions,
}
/// Encoded source only. Decoding, metadata admission and normalization happen in `decode`.
/// The caller must schedule whole-image codec/CMS work away from an interactive UI thread.
#[derive(Clone)]
pub struct NormalizationSource {
bytes: Arc<Vec<u8>>,
format: ImageFormat,
role: MapRole,
options: NormalizationOptions,
}
impl NormalizationSource {
pub fn new(bytes: Vec<u8>, role: MapRole, options: NormalizationOptions) -> Result<Self> {
let format = admitted_format(&bytes, bytes.capacity())?;
if options.max_working_bytes == 0 || options.max_working_bytes > MAX_WORKING_BYTES {
return Err(invalid("normalization working budget must be positive and at most 256 MiB"));
}
if bytes.capacity().checked_add(decode::CODEC_BYTES).is_none_or(|minimum| minimum > options.max_working_bytes) {
return Err(invalid("normalization working budget must cover encoded storage and the codec reservation"));
}
Ok(Self { bytes: Arc::new(bytes), format, role, options })
}
pub fn decode(&self, cancelled: impl Fn() -> bool) -> Result<NormalizedTexture> {
check_cancel(&cancelled)?;
let raw = guarded(|| decode::image(&self.bytes, self.format, self.options.max_working_bytes, self.bytes.capacity()))?;
check_cancel(&cancelled)?;
let orientation = raw.orientation;
if orientation != Orientation::NoTransforms {
if self.options.orientation == OrientationPolicy::Refuse {
return Err(invalid("normalization requires explicit orientation consent"));
}
if self.role == MapRole::NormalOpenGl {
return Err(invalid("oriented normal maps need an explicit vector/basis conversion policy"));
}
}
let conversion = raw.conversion(self.role, self.options.unprofiled_color)?;
let cms = match &conversion {
ColorConversion::IccMatrixTrc { .. } => {
let profile = raw.icc.as_ref().ok_or_else(|| invalid("ICC metadata disappeared"))?;
Some(icc::prepare(profile)?)
}
_ => None,
};
check_cancel(&cancelled)?;
let [width, height] = raw.dimensions;
let dimensions = match orientation {
Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH => [height, width],
_ => [width, height],
};
let sample_count = usize::try_from(u64::from(width) * u64::from(height) * 4).map_err(|_| invalid("normalized image size overflow"))?;
if sample_count.checked_mul(4).is_none_or(|n| n > MAX_NORMALIZED_BYTES) {
return Err(invalid("normalized float pixels exceed 64 MiB"));
}
let mut pixels = zeroed::<f32>(sample_count)?;
let row_channels = usize::try_from(u64::from(width) * 3).map_err(|_| invalid("normalization row overflow"))?;
let mut input = zeroed::<f64>(row_channels)?;
let mut converted = zeroed::<f64>(row_channels)?;
let mut clamped_rgb_channels = 0;
for y in 0..height {
check_cancel(&cancelled)?;
for (x, channels) in input.as_chunks_mut::<3>().0.iter_mut().enumerate() {
let [r, g, b, _] = raw.pixel(u32::try_from(x).map_err(|_| invalid("pixel coordinate overflow"))?, y)?;
channels.copy_from_slice(&[r, g, b]);
}
if let Some(cms) = &cms {
guarded(|| cms.transform(&input, &mut converted).map_err(|e| invalid(format!("ICC conversion: {e}"))))?;
} else {
converted.copy_from_slice(&input);
}
for (x, rgb) in converted.as_chunks::<3>().0.iter().enumerate() {
let x = u32::try_from(x).map_err(|_| invalid("pixel coordinate overflow"))?;
let [_, _, _, alpha] = raw.pixel(x, y)?;
let rgb = *rgb;
let mut rgba = [0.0_f32; 4];
for (destination, value) in rgba.iter_mut().take(3).zip(rgb) {
if !value.is_finite() {
return Err(invalid("color conversion produced nonfinite values"));
}
if self.options.gamut == GamutPolicy::Refuse && !(-GAMUT_TOLERANCE..=1.0 + GAMUT_TOLERANCE).contains(&value) {
return Err(invalid("color conversion is outside sRGB gamut; explicit preview clipping is required"));
}
let bounded = value.clamp(0.0, 1.0);
if bounded != value {
clamped_rgb_channels += 1;
}
let linear = match conversion {
ColorConversion::RawData => bounded,
ColorConversion::PngGammaSrgbPrimaries { encoding_gamma } => bounded.powf(1.0 / encoding_gamma),
_ => srgb_to_linear(bounded),
};
if !linear.is_finite() || !(0.0..=1.0).contains(&linear) {
return Err(invalid("normalization produced invalid linear/data channels"));
}
*destination = linear as f32;
}
if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) {
return Err(invalid("normalization produced invalid alpha"));
}
if let Some(destination) = rgba.get_mut(3) {
*destination = alpha as f32;
}
let index = destination_index(raw.dimensions, dimensions, orientation, x, y)?;
let end = index.checked_add(4).ok_or_else(|| invalid("oriented pixel offset overflow"))?;
pixels.get_mut(index..end).ok_or_else(|| invalid("oriented pixel lies outside its output"))?.copy_from_slice(&rgba);
}
}
let source_sha256 = digest_bytes(&self.bytes, &cancelled)?;
let mut pixel_hash = Sha256::new();
for chunk in pixels.chunks(4096) {
check_cancel(&cancelled)?;
for value in chunk {
pixel_hash.update(value.to_le_bytes());
}
}
check_cancel(&cancelled)?;
let receipt = NormalizationReceipt {
recipe_version: 1,
source_sha256,
normalized_sha256: format!("{:x}", pixel_hash.finalize()),
format: if self.format == ImageFormat::Png { "png" } else { "jpeg" }.into(),
encoded_bytes: self.bytes.len(),
retained_encoded_capacity: self.bytes.capacity(),
normalized_bytes: sample_count * 4,
role: self.role,
source_dimensions: raw.dimensions,
source_channel_bits: raw.source_bits,
output_dimensions: dimensions,
output_encoding: if self.role == MapRole::BaseColor { "linear_srgb" } else { "data" }.into(),
output_precision: "rgba_f32".into(),
source_exif_orientation: orientation.to_exif(),
exif_metadata_present: raw.exif_present,
exif_orientation_tag_present: raw.orientation_tag_present,
orientation_applied: orientation != Orientation::NoTransforms,
ignored_png_color_metadata: self.role != MapRole::BaseColor && raw.has_png_color_metadata(),
conversion,
gamut_policy: self.options.gamut,
gamut_tolerance: GAMUT_TOLERANCE,
clamped_rgb_channels,
estimated_working_bytes: raw.estimated_working_bytes,
options: self.options,
};
Ok(NormalizedTexture { receipt, pixels })
}
}
pub struct NormalizedTexture {
receipt: NormalizationReceipt,
pixels: Vec<f32>,
}
impl std::fmt::Debug for NormalizedTexture {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NormalizedTexture").field("receipt", &self.receipt).field("sample_count", &self.pixels.len()).finish()
}
}
impl NormalizedTexture {
pub fn receipt(&self) -> &NormalizationReceipt {
&self.receipt
}
pub fn rgba(&self) -> &[f32] {
&self.pixels
}
}
fn zeroed<T: Default + Clone>(length: usize) -> Result<Vec<T>> {
let mut values = Vec::new();
values.try_reserve_exact(length).map_err(|_| invalid("cannot allocate normalization buffer"))?;
values.resize(length, T::default());
Ok(values)
}
fn check_cancel(cancelled: &impl Fn() -> bool) -> Result<()> {
if cancelled() { Err(invalid("texture normalization cancelled")) } else { Ok(()) }
}
fn digest_bytes(bytes: &[u8], cancelled: &impl Fn() -> bool) -> Result<String> {
let mut digest = Sha256::new();
for chunk in bytes.chunks(64 * 1024) {
check_cancel(cancelled)?;
digest.update(chunk);
}
Ok(format!("{:x}", digest.finalize()))
}
fn srgb_to_linear(value: f64) -> f64 {
if value <= 0.04045 { value / 12.92 } else { ((value + 0.055) / 1.055).powf(2.4) }
}
fn destination_index(source: [u32; 2], output: [u32; 2], orientation: Orientation, x: u32, y: u32) -> Result<usize> {
let [width, height] = source;
if x >= width || y >= height || width == 0 || height == 0 {
return Err(invalid("invalid orientation coordinate"));
}
let reverse_x = width - 1 - x;
let reverse_y = height - 1 - y;
let (dx, dy) = match orientation {
Orientation::NoTransforms => (x, y),
Orientation::Rotate90 => (reverse_y, x),
Orientation::Rotate180 => (reverse_x, reverse_y),
Orientation::Rotate270 => (y, reverse_x),
Orientation::FlipHorizontal => (reverse_x, y),
Orientation::FlipVertical => (x, reverse_y),
Orientation::Rotate90FlipH => (y, x),
Orientation::Rotate270FlipH => (reverse_y, reverse_x),
};
let [output_width, output_height] = output;
if dx >= output_width || dy >= output_height {
return Err(invalid("invalid oriented output dimensions"));
}
let index = u64::from(dy)
.checked_mul(u64::from(output_width))
.and_then(|value| value.checked_add(u64::from(dx)))
.and_then(|value| value.checked_mul(4))
.ok_or_else(|| invalid("orientation index overflow"))?;
usize::try_from(index).map_err(|_| invalid("orientation index overflow"))
}
#[cfg(test)]
mod tests;