New node: 'Color Lookup' (#4552)

* New node: 'Color Lookup' for applying .cube, .3dl, .look, .csp, and .icc LUT files

* Add LUT ingest to the UI

* Add caching to the parsed LUT

* More code review fixes
This commit is contained in:
Keavon Chambers
2026-09-19 02:56:14 -07:00
committed by GitHub
parent ab32a60476
commit 97d8452a1d
12 changed files with 1370 additions and 20 deletions

View File

@@ -1470,6 +1470,13 @@ fn static_input_properties() -> InputProperties {
Ok(vec![LayoutGroup::row(widgets)])
}),
);
map.insert(
"lut_file".to_string(),
Box::new(|node_id, index, context| {
let widgets = node_properties::resource_widget(ParameterWidgetsInfo::at_index(node_id, index, true, context), vec![TypeFilter::lut()]);
Ok(vec![LayoutGroup::row(widgets)])
}),
);
map.insert(
"artboard_background".to_string(),
Box::new(|node_id, index, context| {

View File

@@ -1314,8 +1314,8 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, filters: Ve
} = parameter_widgets_info;
let use_counts = network_interface.collect_resources_use_counts();
// This is a heuristic to filter for image resources and will break once other data types are loaded.
// TODO: Add a proper way to filter for image resources.
// Fonts are the only resources the registry tells apart, so a picker also lists the files of other types.
// TODO: Record each resource's data type so a picker lists only the files its input accepts.
let mut files: Vec<(ResourceId, String, String)> = resources
.registry
.resolved()

View File

@@ -4,6 +4,7 @@ use crate::messages::prelude::*;
use glam::IVec2;
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
use graphene_std::raster_nodes::color_lookup_table::{Lut, LutParseError};
#[derive(ExtractField)]
pub struct IngestMessageContext {
@@ -54,10 +55,10 @@ impl MessageHandler<IngestMessage, IngestMessageContext> for IngestMessageHandle
input_index,
accepted_types,
} => {
if !is_accepted(&data, data_type, &accepted_types) {
if let Some(description) = rejection(&data, data_type, &accepted_types) {
responses.add(DialogMessage::DisplayDialogError {
title: "Unsupported file".into(),
description: "This file is not a type that this input accepts.".into(),
description: description.into(),
});
return;
}
@@ -134,7 +135,8 @@ impl MessageHandler<IngestMessage, IngestMessageContext> for IngestMessageHandle
};
(insert, None)
}
DataType::Unknown => return unsupported(responses),
// A LUT is only ever the file of a node input
DataType::Lut | DataType::Unknown => return unsupported(responses),
};
if !place_at_origin {
@@ -171,13 +173,32 @@ fn unsupported(responses: &mut VecDeque<Message>) {
});
}
/// Whether a node input takes the file, where an image must also fully decode.
fn is_accepted(data: &[u8], data_type: DataType, accepted_types: &[DataType]) -> bool {
/// The reason a node input refuses the file, or `None` if it accepts it. An input that lists its accepted types takes an image or LUT only if it fully parses.
fn rejection(data: &[u8], data_type: DataType, accepted_types: &[DataType]) -> Option<&'static str> {
const WRONG_TYPE: &str = "This input does not accept the format of the chosen file.";
if accepted_types.is_empty() {
return true;
return None;
}
if !accepted_types.contains(&data_type) {
return Some(WRONG_TYPE);
}
accepted_types.contains(&data_type) && (!matches!(data_type, DataType::Raster(_)) || decoded_image_size(data).is_some())
match data_type {
DataType::Raster(_) => decoded_image_size(data).is_none().then_some("This file could not be read as an image."),
DataType::Lut => Lut::parse(data).err().map(|error| match error {
LutParseError::IccProfileClass => {
"This ICC profile describes the colors of a device (like a monitor or printer) instead\n\
of remapping colors. Only \"abstract\" and \"device link\" profiles work as LUTs."
}
LutParseError::IccColorSpaces => {
"This ICC profile remaps within color spaces that are currently unsupported, such as CMYK.\n\
A \"device link\" profile must map RGB to RGB. An \"abstract\" profile must map Lab to Lab."
}
LutParseError::Unreadable => "This file could not be read as a LUT. It may be corrupted or an unsupported format variant.",
}),
_ => None,
}
}
// The viewBox preserves the full canvas rather than the tighter bounding box of the rendered content
@@ -208,14 +229,19 @@ mod tests {
use graphene_std::raster::Image;
const REQUESTING_DOCUMENT: DocumentId = DocumentId(3);
const IDENTITY_CUBE: &[u8] = b"LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
fn ingest(data: &[u8], action: IngestAction, document_open: bool) -> VecDeque<Message> {
ingest_named(data, None, action, document_open)
}
fn ingest_named(data: &[u8], file_name: Option<&str>, action: IngestAction, document_open: bool) -> VecDeque<Message> {
let mut responses = VecDeque::new();
let message = IngestMessage::Ingest {
data: data.into(),
action,
mime_type: String::new(),
path: None,
path: file_name.map(Into::into),
};
IngestMessageHandler::default().process_message(message, &mut responses, IngestMessageContext { document_open });
responses
@@ -291,6 +317,64 @@ mod tests {
assert!(matches!(&responses[0], Message::Portfolio(PortfolioMessage::Ingest(IngestMessage::Browse { action, .. })) if *action == dropped));
}
#[test]
fn resource_input_says_why_an_icc_profile_of_the_wrong_kind_is_refused() {
let mut monitor_profile = vec![0; 128];
monitor_profile[12..16].copy_from_slice(b"mntr");
monitor_profile[36..40].copy_from_slice(b"acsp");
let refusal = |data: &[u8], file_name: &str| {
let responses = ingest_named(data, Some(file_name), resource_input(TypeFilter::lut().types), true);
assert_eq!(responses.len(), 1, "a refused file should only show a dialog");
match &responses[0] {
Message::Dialog(DialogMessage::DisplayDialogError { description, .. }) => description.clone(),
_ => panic!("the user should be told why"),
}
};
assert!(refusal(&monitor_profile, "display.icc").contains("monitor"));
assert!(refusal(b"not a table", "grade.cube").contains("could not be read"));
assert!(refusal(IDENTITY_CUBE, "grade.png").contains("does not accept"));
}
#[test]
fn resource_input_tells_a_corrupt_image_apart_from_a_wrong_type() {
let png = Image::new(8, 8, Color::WHITE).to_png();
let refusal = |data: &[u8]| {
let responses = ingest(data, resource_input(TypeFilter::raster().types), true);
match &responses[0] {
Message::Dialog(DialogMessage::DisplayDialogError { description, .. }) => description.clone(),
_ => panic!("the user should be told why"),
}
};
assert!(refusal(&png[..40]).contains("could not be read as an image"));
assert!(refusal(b"not an image").contains("does not accept"));
}
#[test]
fn resource_input_takes_a_lookup_table_only_when_it_parses() {
let png = Image::new(8, 8, Color::WHITE).to_png();
let stores = |data: &[u8], filter: TypeFilter| {
let responses = ingest_named(data, Some("grade.cube"), resource_input(filter.types), true);
responses.iter().any(|message| stored_resource(message).is_some())
};
assert!(stores(IDENTITY_CUBE, TypeFilter::lut()));
// A table cut off partway, an image named as a table, and a table offered to an image input
assert!(!stores(&IDENTITY_CUBE[..30], TypeFilter::lut()));
assert!(!stores(&png, TypeFilter::lut()));
assert!(!stores(IDENTITY_CUBE, TypeFilter::raster()));
}
#[test]
fn lookup_table_outside_a_node_input_only_shows_a_dialog() {
let responses = ingest_named(IDENTITY_CUBE, Some("grade.cube"), IngestAction::Import, true);
assert_eq!(responses.len(), 1);
assert!(matches!(responses[0], Message::Dialog(DialogMessage::DisplayDialogError { .. })));
}
#[test]
fn truncated_image_only_shows_a_dialog() {
let png = Image::new(8, 8, Color::WHITE).to_png();

View File

@@ -4,6 +4,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye
use crate::messages::prelude::DocumentId;
use document_container::archive::ArchiveFormat;
use graph_craft::document::NodeId;
use graphene_std::raster_nodes::color_lookup_table::LUT_FILE_EXTENSIONS;
use image::ImageFormat;
use std::ffi::OsStr;
use std::path::Path;
@@ -57,6 +58,7 @@ pub enum DataType {
Gdd,
Svg,
Raster(ImageFormat),
Lut,
#[default]
Unknown,
}
@@ -87,6 +89,7 @@ impl DataType {
FILE_EXTENSION => Self::GraphiteLegacy,
GDD_FILE_EXTENSION => Self::Gdd,
"svg" => Self::Svg,
extension if LUT_FILE_EXTENSIONS.contains(&extension) => Self::Lut,
extension => ImageFormat::from_extension(extension).map_or(Self::Unknown, Self::Raster),
}
}
@@ -127,7 +130,8 @@ impl DataType {
Self::Gdd => "application/vnd.graphite.document",
Self::Svg => "image/svg+xml",
Self::Raster(format) => format.to_mime_type(),
Self::Unknown => return None,
// The LUT formats share no MIME type
Self::Lut | Self::Unknown => return None,
})
}
@@ -137,6 +141,7 @@ impl DataType {
Self::Gdd => &[GDD_FILE_EXTENSION],
Self::Svg => &["svg"],
Self::Raster(format) => format.extensions_str(),
Self::Lut => LUT_FILE_EXTENSIONS,
Self::Unknown => &[],
}
}
@@ -168,6 +173,13 @@ impl TypeFilter {
images.types.push(DataType::Svg);
images
}
pub fn lut() -> Self {
Self {
name: "LUT".into(),
types: vec![DataType::Lut],
}
}
}
impl From<TypeFilter> for FileFilter {
@@ -216,6 +228,8 @@ mod tests {
assert_eq!(detect("image/svg+xml", ""), DataType::Svg);
assert_eq!(detect("application/graphite+json", ""), DataType::GraphiteLegacy);
assert_eq!(detect("image/png", "document.gdd"), DataType::Raster(ImageFormat::Png));
assert_eq!(detect("", "grade.CUBE"), DataType::Lut);
assert_eq!(detect("application/vnd.iccprofile", "profile.icm"), DataType::Lut);
assert_eq!(detect("text/csv", "table.csv"), DataType::Unknown);
assert_eq!(DataType::detect(&[], "", None), DataType::Unknown);
@@ -229,5 +243,9 @@ mod tests {
assert!(filter.extensions.iter().any(|extension| extension == "jpeg") && filter.extensions.iter().any(|extension| extension == "png"));
assert!(filter.extensions.last().is_some_and(|extension| extension == "svg"));
assert!(filter.mime_types.contains(&"image/jpeg".to_string()) && filter.mime_types.contains(&"image/svg+xml".to_string()));
// LUTs are picked by extension alone
let filter = FileFilter::from(TypeFilter::lut());
assert!(filter.extensions.iter().any(|extension| extension == "cube") && filter.mime_types.is_empty());
}
}

View File

@@ -1,6 +1,8 @@
#![allow(clippy::too_many_arguments)]
use crate::adjust::Adjust;
#[cfg(feature = "std")]
use crate::color_lookup_table::LutCache;
use core::fmt::Debug;
#[cfg(feature = "std")]
use core_types::list::{Item, List};
@@ -9,6 +11,8 @@ use core_types::transfer_curve::{TransferCurve, TransferCurveEvaluator};
#[cfg(feature = "std")]
use glam::DVec2;
use glam::Vec3;
#[cfg(feature = "std")]
use graphene_resource::Resource;
use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear};
use no_std_types::context::Ctx;
#[cfg(not(feature = "std"))]
@@ -23,11 +27,6 @@ use raster_types::{CPU, Raster};
#[cfg(feature = "std")]
use vector_types::Gradient;
// TODO: Implement 'Color Lookup':
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27clrL%27%20%3D%20Color%20Lookup
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Color%20Lookup%20(Photoshop%20CS6
/// Conversion from a color to grayscale.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
@@ -1947,11 +1946,11 @@ fn photo_filter<T: Adjust<Color>>(
}
// sRGB colorants adapted to D50 as in the sRGB IEC61966-2.1 ICC profile, row major, and their inverse
const SRGB_TO_XYZ_D50: [[f32; 3]; 3] = [[0.43607, 0.38515, 0.14307], [0.22249, 0.71687, 0.06061], [0.01392, 0.09708, 0.71410]];
const XYZ_D50_TO_SRGB: [[f32; 3]; 3] = [[3.134096, -1.6174, -0.490638], [-0.978793, 1.916295, 0.033454], [0.071971, -0.228987, 1.40538]];
const WHITE_XYZ_D50: [f32; 3] = [0.96420, 1., 0.82491];
pub(crate) const SRGB_TO_XYZ_D50: [[f32; 3]; 3] = [[0.43607, 0.38515, 0.14307], [0.22249, 0.71687, 0.06061], [0.01392, 0.09708, 0.71410]];
pub(crate) const XYZ_D50_TO_SRGB: [[f32; 3]; 3] = [[3.134096, -1.6174, -0.490638], [-0.978793, 1.916295, 0.033454], [0.071971, -0.228987, 1.40538]];
pub(crate) const WHITE_XYZ_D50: [f32; 3] = [0.96420, 1., 0.82491];
fn multiply_matrix(matrix: &[[f32; 3]; 3], vector: [f32; 3]) -> [f32; 3] {
pub(crate) fn multiply_matrix(matrix: &[[f32; 3]; 3], vector: [f32; 3]) -> [f32; 3] {
[
matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2],
matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2],
@@ -1990,6 +1989,39 @@ fn set_luminosity(r: f32, g: f32, b: f32, luma: f32, luminosity: f32) -> [f32; 3
[channels[0].clamp(0., 1.), channels[1].clamp(0., 1.), channels[2].clamp(0., 1.)]
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27clrL%27%20%3D%20Color%20Lookup
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Color%20Lookup%20(Photoshop%20CS6
//
// TODO: Support dither, which needs the pixel position that a per-color adjustment never sees.
// TODO: Verify the tetrahedral interpolation, which is unconfirmed against other implementations.
#[cfg(feature = "std")]
#[node_macro::node(category("Raster: Adjustment"))]
async fn color_lookup<T: Adjust<Color> + Send>(
_: impl Ctx,
/// The image whose colors are remapped by the LUT (lookup table).
#[implementations(Raster<CPU>, Color, Gradient)]
image: Item<T>,
/// A LUT (lookup table) file in the `.cube`, `.3dl`, `.look`, `.csp`, or `.icc` (*abstract* or *device link* ICC profile) format.
#[name("LUT File")]
#[widget(ParsedWidgetOverride::Custom = "lut_file")]
lut_file: Item<Resource>,
#[data] lut_cache: LutCache,
) -> Item<T> {
let mut image = image;
let Ok(lut) = lut_cache.parse(lut_file.element()) else { return image };
image.element_mut().adjust(|color| {
// Lookup tables address the gamma-encoded channels
let [r, g, b, a] = color.to_gamma_srgb_channels();
let [r, g, b] = lut.apply([r, g, b]);
Color::from_gamma_srgb_channels(r, g, b, a)
});
image
}
#[cfg(feature = "std")]
mod _graphene_hash_impls {
use super::{

View File

@@ -0,0 +1,442 @@
//! Lookup tables for the Color Lookup adjustment: parsing CUBE, 3DL, CSP, and LOOK files and ICC profiles, and evaluating them.
mod format_3dl;
mod format_csp;
mod format_cube;
mod format_icc;
mod format_look;
use crate::adjustments::{SRGB_TO_XYZ_D50, WHITE_XYZ_D50, XYZ_D50_TO_SRGB, multiply_matrix};
use graphene_resource::{Resource, ResourceHash};
use no_std_types::color::{linear_to_srgb, srgb_to_linear};
use std::sync::{Arc, Mutex, PoisonError};
pub const LUT_FILE_EXTENSIONS: &[&str] = &["cube", "3dl", "look", "csp", "icc", "icm"];
/// A file's parse result, in the form the cache shares out.
type ParsedLut = Result<Arc<Lut>, LutParseError>;
/// The last file a node parsed, kept as the node's own data so the file is not parsed anew each time the node runs again.
#[derive(Debug, Clone, Default)]
pub struct LutCache(Arc<Mutex<Option<(ResourceHash, ParsedLut)>>>);
impl LutCache {
/// [`Lut::parse`] for a resource, reusing the last result while the file's content hash stays the same.
pub fn parse(&self, resource: &Resource) -> ParsedLut {
// A lock poisoned by a panic elsewhere still guards a usable cache
let mut cached = self.0.lock().unwrap_or_else(PoisonError::into_inner);
let hash = resource.hash();
if let Some((cached_hash, result)) = cached.as_ref()
&& *cached_hash == hash
{
return result.clone();
}
let result = Lut::parse(resource).map(Arc::new);
*cached = Some((hash, result.clone()));
result
}
}
/// Why a file could not be read as a lookup table.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LutParseError {
/// An ICC profile that describes a device's colors, not an "abstract" or "device link" profile that remaps them.
IccProfileClass,
/// An ICC "abstract" profile that does not map Lab to Lab, or a "device link" profile that does not map RGB to RGB.
IccColorSpaces,
/// Not a lookup table in any of the formats, or one that is damaged.
Unreadable,
}
/// A per-channel mapping applied around the table.
#[derive(Debug, Clone, PartialEq)]
pub enum Curve {
/// Output samples at evenly spaced inputs across 0..1.
Sampled(Vec<f32>),
/// Output samples at the given inputs (the CSP format's pre-LUT).
Piecewise {
inputs: Vec<f32>,
outputs: Vec<f32>,
},
Power(f32),
/// One of the ICC parametric curve functions, with its parameters in the specification's order.
Parametric {
function: u16,
parameters: Vec<f32>,
},
}
impl Curve {
fn apply(&self, value: f32) -> f32 {
match self {
Curve::Sampled(samples) => {
let last = samples.len().saturating_sub(1);
if last == 0 {
return samples.first().copied().unwrap_or(value);
}
let scaled = value.clamp(0., 1.) * last as f32;
let lower = (scaled.floor() as usize).min(last);
let upper = (lower + 1).min(last);
samples[lower] + (scaled - lower as f32) * (samples[upper] - samples[lower])
}
Curve::Piecewise { inputs, outputs } => {
let count = inputs.len();
if count == 0 {
return value;
}
if value <= inputs[0] {
return outputs[0];
}
if value >= inputs[count - 1] {
return outputs[count - 1];
}
let upper = inputs.partition_point(|&input| input <= value).min(count - 1);
let lower = upper - 1;
let t = (value - inputs[lower]) / (inputs[upper] - inputs[lower]).max(f32::EPSILON);
outputs[lower] + t * (outputs[upper] - outputs[lower])
}
Curve::Power(gamma) => value.max(0.).powf(*gamma),
Curve::Parametric { function, parameters } => parametric_curve(*function, parameters, value),
}
}
}
/// The ICC `parametricCurveType` functions 0 through 4, which share the form `(a * x + b)^g` above a breakpoint.
fn parametric_curve(function: u16, parameters: &[f32], x: f32) -> f32 {
let at = |index: usize| parameters.get(index).copied().unwrap_or(0.);
let (g, a, b, c, d, e, f) = (at(0), at(1), at(2), at(3), at(4), at(5), at(6));
let power = |x: f32| (a * x + b).max(0.).powf(g);
let result = match function {
0 => x.max(0.).powf(g),
1 => {
if x >= -b / a {
power(x)
} else {
0.
}
}
2 => {
if x >= -b / a {
power(x) + c
} else {
c
}
}
3 => {
if x >= d {
power(x)
} else {
c * x
}
}
4 => {
if x >= d {
power(x) + e
} else {
c * x + f
}
}
_ => x,
};
// The specification clips every function to the unit range
result.clamp(0., 1.)
}
/// A step applied to the table's output.
#[derive(Debug, Clone, PartialEq)]
pub enum Stage {
Curves([Curve; 3]),
/// The 3x4 matrix of an ICC `lutAToBType`.
Matrix {
matrix: [[f32; 3]; 3],
offset: [f32; 3],
},
}
impl Stage {
fn apply(&self, values: [f32; 3]) -> [f32; 3] {
match self {
Stage::Curves(curves) => [curves[0].apply(values[0]), curves[1].apply(values[1]), curves[2].apply(values[2])],
Stage::Matrix { matrix, offset } => {
let product = multiply_matrix(matrix, values);
[product[0] + offset[0], product[1] + offset[1], product[2] + offset[2]]
}
}
}
}
/// How an abstract profile's table encodes CIELAB: the standard scale, or the legacy one of 16-bit tables.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LabEncoding {
Standard,
Legacy,
}
impl LabEncoding {
fn encode(self, [l, a, b]: [f32; 3]) -> [f32; 3] {
match self {
LabEncoding::Standard => [l / 100., (a + 128.) / 255., (b + 128.) / 255.],
LabEncoding::Legacy => [l / 100. * (65280. / 65535.), (a + 128.) * (256. / 65535.), (b + 128.) * (256. / 65535.)],
}
}
fn decode(self, [l, a, b]: [f32; 3]) -> [f32; 3] {
match self {
LabEncoding::Standard => [l * 100., a * 255. - 128., b * 255. - 128.],
LabEncoding::Legacy => [l * 100. * (65535. / 65280.), a * (65535. / 256.) - 128., b * (65535. / 256.) - 128.],
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum LutTable {
/// One table per channel, each channel looked up by its own value.
OneDimensional { entries: Vec<[f32; 3]> },
/// A grid of `size` samples per axis addressed by all three channels, stored with either red or blue varying fastest.
ThreeDimensional { size: [usize; 3], red_fastest: bool, entries: Vec<[f32; 3]> },
}
/// A color lookup table read from a CUBE, 3DL, CSP, or LOOK file or an ICC profile, mapping gamma-encoded RGB to gamma-encoded RGB.
#[derive(Debug, Clone, PartialEq)]
pub struct Lut {
domain_min: [f32; 3],
domain_max: [f32; 3],
input_curves: Option<[Curve; 3]>,
/// Absent when a profile applies only curves and a matrix.
table: Option<LutTable>,
output_stages: Vec<Stage>,
/// Set when the table maps CIELAB rather than RGB, as an abstract profile's does.
lab_encoding: Option<LabEncoding>,
}
impl Lut {
fn new(table: Option<LutTable>) -> Self {
Lut {
domain_min: [0.; 3],
domain_max: [1.; 3],
input_curves: None,
table,
output_stages: Vec::new(),
lab_encoding: None,
}
}
/// Reads a CUBE, 3DL, CSP, or LOOK text file or an ICC abstract or device link profile.
pub fn parse(bytes: &[u8]) -> Result<Self, LutParseError> {
if bytes.get(36..40).is_some_and(|signature| signature == b"acsp") {
return format_icc::parse(bytes);
}
Self::parse_text(bytes).filter(Self::is_finite).ok_or(LutParseError::Unreadable)
}
/// O(n): whether every number in the table is finite.
fn is_finite(&self) -> bool {
let all_finite = |values: &[f32]| values.iter().all(|value| value.is_finite());
let curve_is_finite = |curve: &Curve| match curve {
Curve::Sampled(samples) => all_finite(samples),
Curve::Piecewise { inputs, outputs } => all_finite(inputs) && all_finite(outputs),
Curve::Power(gamma) => gamma.is_finite(),
Curve::Parametric { parameters, .. } => all_finite(parameters),
};
let stage_is_finite = |stage: &Stage| match stage {
Stage::Curves(curves) => curves.iter().all(curve_is_finite),
Stage::Matrix { matrix, offset } => matrix.iter().all(|row| all_finite(row)) && all_finite(offset),
};
let table_is_finite = match &self.table {
Some(LutTable::OneDimensional { entries } | LutTable::ThreeDimensional { entries, .. }) => entries.iter().all(|entry| all_finite(entry)),
None => true,
};
all_finite(&self.domain_min) && all_finite(&self.domain_max) && self.input_curves.iter().flatten().all(curve_is_finite) && table_is_finite && self.output_stages.iter().all(stage_is_finite)
}
fn parse_text(bytes: &[u8]) -> Option<Self> {
let text = String::from_utf8_lossy(bytes);
let text = text.trim_start_matches('\u{feff}').trim_start();
if text.starts_with("CSPLUTV100") {
return format_csp::parse(text);
}
if text.starts_with('<') {
return format_look::parse(text);
}
if text.contains("LUT_3D_SIZE") || text.contains("LUT_1D_SIZE") {
return format_cube::parse(text);
}
format_3dl::parse(text)
}
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
let input = match self.lab_encoding {
Some(encoding) => encoding.encode(srgb_to_lab(rgb)),
None => rgb,
};
let mut normalized = [0.; 3];
for channel in 0..3 {
let value = match &self.input_curves {
Some(curves) => curves[channel].apply(input[channel]),
None => input[channel],
};
let span = (self.domain_max[channel] - self.domain_min[channel]).max(f32::EPSILON);
normalized[channel] = ((value - self.domain_min[channel]) / span).clamp(0., 1.);
}
let mut result = match &self.table {
None => normalized,
Some(LutTable::OneDimensional { entries }) => {
let last = entries.len() - 1;
let mut result = [0.; 3];
for channel in 0..3 {
let scaled = normalized[channel] * last as f32;
let lower = (scaled.floor() as usize).min(last);
let upper = (lower + 1).min(last);
let t = scaled - lower as f32;
result[channel] = entries[lower][channel] + t * (entries[upper][channel] - entries[lower][channel]);
}
result
}
Some(LutTable::ThreeDimensional { size, red_fastest, entries }) => {
let index = |r: usize, g: usize, b: usize| if *red_fastest { (b * size[1] + g) * size[0] + r } else { (r * size[1] + g) * size[2] + b };
let sample = |r: usize, g: usize, b: usize| entries[index(r, g, b)];
let coordinate = |axis: usize| {
let scaled = normalized[axis] * (size[axis] - 1) as f32;
let lower = (scaled.floor() as usize).min(size[axis] - 1);
(lower, (lower + 1).min(size[axis] - 1), scaled - lower as f32)
};
let ((r0, r1, fr), (g0, g1, fg), (b0, b1, fb)) = (coordinate(0), coordinate(1), coordinate(2));
// Tetrahedral interpolation: walk from the cell's origin to its far corner along the axes in decreasing fraction order
let c000 = sample(r0, g0, b0);
let c111 = sample(r1, g1, b1);
let walk = |first: [f32; 3], first_t: f32, second: [f32; 3], second_t: f32, third_t: f32| {
let mut result = c000;
for channel in 0..3 {
result[channel] += first_t * (first[channel] - c000[channel]) + second_t * (second[channel] - first[channel]) + third_t * (c111[channel] - second[channel]);
}
result
};
if fr >= fg && fg >= fb {
walk(sample(r1, g0, b0), fr, sample(r1, g1, b0), fg, fb)
} else if fr >= fb && fb >= fg {
walk(sample(r1, g0, b0), fr, sample(r1, g0, b1), fb, fg)
} else if fb >= fr && fr >= fg {
walk(sample(r0, g0, b1), fb, sample(r1, g0, b1), fr, fg)
} else if fg >= fr && fr >= fb {
walk(sample(r0, g1, b0), fg, sample(r1, g1, b0), fr, fb)
} else if fg >= fb && fb >= fr {
walk(sample(r0, g1, b0), fg, sample(r0, g1, b1), fb, fr)
} else {
walk(sample(r0, g0, b1), fb, sample(r0, g1, b1), fg, fr)
}
}
};
for stage in &self.output_stages {
result = stage.apply(result);
}
match self.lab_encoding {
Some(encoding) => lab_to_srgb(encoding.decode(result)),
None => result,
}
}
}
/// Gamma-encoded sRGB to CIELAB against the D50 white of the ICC profile connection space.
fn srgb_to_lab(rgb: [f32; 3]) -> [f32; 3] {
let xyz = multiply_matrix(&SRGB_TO_XYZ_D50, rgb.map(srgb_to_linear));
let f = |t: f32| if t > 216. / 24389. { t.cbrt() } else { (24389. / 27. * t + 16.) / 116. };
let (fx, fy, fz) = (f(xyz[0] / WHITE_XYZ_D50[0]), f(xyz[1] / WHITE_XYZ_D50[1]), f(xyz[2] / WHITE_XYZ_D50[2]));
[116. * fy - 16., 500. * (fx - fy), 200. * (fy - fz)]
}
fn lab_to_srgb([l, a, b]: [f32; 3]) -> [f32; 3] {
let fy = (l + 16.) / 116.;
let (fx, fz) = (fy + a / 500., fy - b / 200.);
let inverse = |t: f32| if t > 6. / 29. { t * t * t } else { 3. * (6_f32 / 29.).powi(2) * (t - 4. / 29.) };
let xyz = [inverse(fx) * WHITE_XYZ_D50[0], inverse(fy) * WHITE_XYZ_D50[1], inverse(fz) * WHITE_XYZ_D50[2]];
multiply_matrix(&XYZ_D50_TO_SRGB, xyz).map(|channel| linear_to_srgb(channel.clamp(0., 1.)))
}
/// Every whitespace-separated token of the line as a number, or `None` if any token is not one.
fn parse_numbers(line: &str) -> Option<Vec<f32>> {
line.split_whitespace().map(|token| token.parse::<f32>().ok()).collect()
}
fn parse_triple(values: &[f32]) -> Option<[f32; 3]> {
(values.len() == 3).then(|| [values[0], values[1], values[2]])
}
#[cfg(test)]
mod tests {
use super::*;
pub(super) fn assert_close(actual: [f32; 3], expected: [f32; 3]) {
assert_close_within(actual, expected, 1e-5);
}
pub(super) fn assert_close_within(actual: [f32; 3], expected: [f32; 3], tolerance: f32) {
for channel in 0..3 {
assert!((actual[channel] - expected[channel]).abs() < tolerance, "{actual:?} vs {expected:?}");
}
}
#[test]
fn text_outside_utf_8_does_not_reject_a_file() {
let mut bytes = b"TITLE \"Cr\xe9\xe9 par\"\nLUT_1D_SIZE 2\n".to_vec();
bytes.extend_from_slice(b"0 0 0\n1 1 1\n");
let lut = Lut::parse(&bytes).unwrap();
assert_close(lut.apply([0.25, 0.5, 0.75]), [0.25, 0.5, 0.75]);
}
#[test]
fn a_byte_order_mark_is_skipped() {
let lut = Lut::parse("\u{feff}LUT_1D_SIZE 2\n0 0 0\n1 1 1\n".as_bytes()).unwrap();
assert_close(lut.apply([0.25, 0.5, 0.75]), [0.25, 0.5, 0.75]);
}
#[test]
fn rejects_garbage() {
assert!(Lut::parse(b"").is_err());
assert!(Lut::parse(b"hello world").is_err());
assert!(Lut::parse(b"LUT_3D_SIZE 2\n0 0 0\n").is_err());
}
#[test]
fn rejects_a_number_that_is_not_finite() {
// An entry and a domain bound of a text file
assert!(Lut::parse(b"LUT_1D_SIZE 2\n0 0 0\nnan 1 1\n").is_err());
assert!(Lut::parse(b"LUT_1D_SIZE 2\nDOMAIN_MAX inf inf inf\n0 0 0\n1 1 1\n").is_err());
// Infinity among the hex floats of a look, which otherwise reads fine
let look = |data: String| format!("<LUT><size>2</size><data>{data}</data></LUT>");
assert!(Lut::parse(look("00000000".repeat(24)).as_bytes()).is_ok());
assert!(Lut::parse(look("0000807F".to_string() + &"00000000".repeat(23)).as_bytes()).is_err());
}
#[test]
fn a_cache_parses_a_file_once_until_the_file_changes() {
let file = Resource::new(b"LUT_1D_SIZE 2\n0 0 0\n1 1 1\n".to_vec());
let cache = LutCache::default();
let first = cache.parse(&file).unwrap();
assert!(Arc::ptr_eq(&first, &cache.parse(&file).unwrap()));
// A cloned node shares its cache
assert!(Arc::ptr_eq(&first, &cache.clone().parse(&file).unwrap()));
// Another file takes its place, a failed one included
assert!(cache.parse(&Resource::new(b"hello world".to_vec())).is_err());
assert!(!Arc::ptr_eq(&first, &cache.parse(&file).unwrap()));
}
#[test]
fn rejects_a_size_whose_entry_count_overflows() {
assert!(Lut::parse(b"LUT_3D_SIZE 4194304\n").is_err());
assert!(Lut::parse(b"<LUT><size>4194304</size><data></data></LUT>").is_err());
assert!(Lut::parse(b"CSPLUTV100\n3D\n0\n0\n0\n4194304 4194304 4194304\n").is_err());
}
}

View File

@@ -0,0 +1,174 @@
//! The Flame/Lustre 3DL format: integer entries with blue varying fastest, an input sample line that may double as a shaper, and tokens to ignore.
//!
//! <https://download.autodesk.com/us/systemdocs/help/2009/flame/files/WScba3ee2b36d8cb6f6dc202441162be3fa98-7ff8.htm>
use super::{Curve, Lut, LutTable, parse_numbers};
pub(super) fn parse(text: &str) -> Option<Lut> {
let mut samples: Option<Vec<f32>> = None;
let mut output_bits = None;
let mut entries: Vec<[f32; 3]> = Vec::new();
for raw_line in text.lines() {
let line = raw_line.split('#').next().unwrap_or("").trim();
if let Some(rest) = line.to_ascii_uppercase().strip_prefix("MESH") {
output_bits = parse_numbers(rest).and_then(|bits| bits.get(1).map(|&bits| bits as u32));
continue;
}
// Any other line that is not numbers is a token some writer needed
let Some(values) = parse_numbers(line) else { continue };
if values.len() == 3 {
entries.push([values[0], values[1], values[2]]);
} else if values.len() > 3 && samples.is_none() {
samples = Some(values);
} else if !values.is_empty() {
return None;
}
}
let size = (entries.len() as f64).cbrt().round() as usize;
if size < 2 || size * size * size != entries.len() {
return None;
}
// Entries are integers on the output bit depth's scale, inferred from the largest value when no header names it
let largest = entries.iter().flatten().fold(0_f32, |largest, &value| largest.max(value));
let scale = match output_bits {
Some(bits @ 1..=32) => ((1_u64 << bits) - 1) as f32,
Some(_) => return None,
None => likely_bit_depth_scale(largest)?,
};
for entry in &mut entries {
for value in entry {
*value /= scale;
}
}
// A sample line that is not a uniform ramp is a shaper applied before the cube
let mut input_curves = None;
if let Some(samples) = samples {
let scale = likely_bit_depth_scale(samples.iter().fold(0_f32, |largest, &value| largest.max(value)))?;
let step = scale / (samples.len() - 1) as f32;
if samples.iter().enumerate().any(|(index, &value)| (index as f32 * step - value).abs() >= 2.) {
let curve = Curve::Sampled(samples.iter().map(|&value| value / scale).collect());
input_curves = Some([curve.clone(), curve.clone(), curve]);
}
}
Some(Lut {
input_curves,
..Lut::new(Some(LutTable::ThreeDimensional {
size: [size; 3],
red_fastest: false,
entries,
}))
})
}
/// The scale of a 3DL file's integers from their largest value: the first of 8, 10, and 12 bits they overshoot by less than twice, else 16, and none below 128.
fn likely_bit_depth_scale(largest: f32) -> Option<f32> {
if largest < 128. {
return None;
}
let bits = [8, 10, 12].into_iter().find(|&bits| largest <= (2_u32.pow(bits) * 2 - 1) as f32).unwrap_or(16);
Some((2_u32.pow(bits) - 1) as f32)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color_lookup_table::tests::assert_close;
#[test]
fn orders_blue_fastest() {
let mut text = String::from("0 341 682 1023\n");
for r in 0..4 {
for g in 0..4 {
for b in 0..4 {
// Keeps red and green, inverts blue
text += &format!("{} {} {}\n", r * 4095 / 3, g * 4095 / 3, (3 - b) * 4095 / 3);
}
}
}
let lut = Lut::parse(text.as_bytes()).unwrap();
assert_close(lut.apply([0.5, 1., 0.]), [0.5, 1., 1.]);
}
#[test]
fn flame_dialect_skips_its_tokens() {
let mut text = String::from("#Tokens required by applications - do not edit\r\n\r\n3DMESH\r\nMesh 2 12\r\n0 256 512 768 1023\r\n\r\n");
for r in 0..2 {
for g in 0..2 {
for b in 0..2 {
text += &format!("{} {} {}\r\n", r * 4095, g * 4095, b * 4095);
}
}
}
text += "\r\nLUT8\r\ngamma 1.0\r\n";
let lut = Lut::parse(text.as_bytes()).unwrap();
assert_close(lut.apply([1., 0., 0.5]), [1., 0., 0.5]);
}
#[test]
fn sizes_the_cube_from_its_entries() {
let mut text = String::from("0 256 512 768 1023\n");
for r in 0..2 {
for g in 0..2 {
for b in 0..2 {
text += &format!("{} {} {}\n", r * 4095, g * 4095, b * 4095);
}
}
}
let lut = Lut::parse(text.as_bytes()).unwrap();
assert_close(lut.apply([0.25, 0.5, 0.75]), [0.25, 0.5, 0.75]);
}
#[test]
fn applies_a_non_uniform_sample_line_as_a_shaper() {
let mut text = String::from("0 0 0 1023\n");
for r in 0..2 {
for g in 0..2 {
for b in 0..2 {
text += &format!("{} {} {}\n", r * 4095, g * 4095, b * 4095);
}
}
}
let lut = Lut::parse(text.as_bytes()).unwrap();
assert_close(lut.apply([0.5, 0.5, 0.5]), [0., 0., 0.]);
assert_close(lut.apply([1., 1., 1.]), [1., 1., 1.]);
// A byte order mark must not make the sample line pass for an ignored token
let marked = Lut::parse(format!("\u{feff}{text}").as_bytes()).unwrap();
assert_close(marked.apply([0.5, 0.5, 0.5]), [0., 0., 0.]);
// Nor may a sample that is not finite, which refuses the file
assert!(Lut::parse(text.replacen("0 0 0 1023", "0 nan 0 1023", 1).as_bytes()).is_err());
}
#[test]
fn rejects_a_header_bit_depth_no_sample_could_have() {
let entries = "0 0 0\n0 0 1023\n0 1023 0\n0 1023 1023\n1023 0 0\n1023 0 1023\n1023 1023 0\n1023 1023 1023\n";
assert!(Lut::parse(format!("Mesh 4 10\n{entries}").as_bytes()).is_ok());
assert!(Lut::parse(format!("Mesh 4 0\n{entries}").as_bytes()).is_err());
assert!(Lut::parse(format!("Mesh 4 64\n{entries}").as_bytes()).is_err());
}
#[test]
fn infers_the_output_depth_allowing_overshoot() {
let mut text = String::new();
for r in 0..2 {
for g in 0..2 {
for b in 0..2 {
text += &format!("{} {} {}\n", r * 1100, g * 1100, b * 1100);
}
}
}
let lut = Lut::parse(text.as_bytes()).unwrap();
assert_close(lut.apply([1., 1., 1.]), [1100. / 1023.; 3]);
}
#[test]
fn rejects_implausibly_small_values() {
assert!(Lut::parse(b"0 0 0\n0 0 1\n0 1 0\n0 1 1\n1 0 0\n1 0 1\n1 1 0\n1 1 1\n").is_err());
}
}

View File

@@ -0,0 +1,90 @@
//! The cineSpace CSP format: a version line, the dimensionality, optional metadata, three pre-LUTs, the size line, then the entries with red varying fastest.
//!
//! <https://aswf-openrv.readthedocs.io/en/latest/rv-manuals/rv-user-manual/rv-user-manual-chapter-g.html>
use super::{Curve, Lut, LutTable, parse_numbers, parse_triple};
pub(super) fn parse(text: &str) -> Option<Lut> {
let mut lines = text.lines().map(str::trim).filter(|line| !line.is_empty());
lines.next().filter(|line| line.starts_with("CSPLUTV100"))?;
let three_dimensional = match lines.next()?.to_ascii_uppercase().as_str() {
"3D" => true,
"1D" => false,
_ => return None,
};
let mut line = lines.next()?;
if line.eq_ignore_ascii_case("BEGIN METADATA") {
loop {
line = lines.next()?;
if line.eq_ignore_ascii_case("END METADATA") {
line = lines.next()?;
break;
}
}
}
let mut curves = Vec::new();
for channel in 0..3 {
let count_line = if channel == 0 { line } else { lines.next()? };
let count: usize = count_line.parse().ok()?;
// Fewer than two points is an identity pre-LUT with no data lines
if count < 2 {
curves.push(Curve::Piecewise {
inputs: vec![0., 1.],
outputs: vec![0., 1.],
});
continue;
}
let inputs = parse_numbers(lines.next()?)?;
let outputs = parse_numbers(lines.next()?)?;
if inputs.len() != count || outputs.len() != count {
return None;
}
curves.push(Curve::Piecewise { inputs, outputs });
}
let curves: [Curve; 3] = curves.try_into().ok()?;
let sizes = parse_numbers(lines.next()?)?;
let table = if three_dimensional {
let size = [*sizes.first()? as usize, *sizes.get(1)? as usize, *sizes.get(2)? as usize];
let count = size.iter().try_fold(1_usize, |count, &axis| count.checked_mul(axis))?;
let entries: Vec<[f32; 3]> = lines.take(count).map(|line| parse_triple(&parse_numbers(line)?)).collect::<Option<_>>()?;
if size.iter().any(|&axis| axis < 2) || entries.len() != count {
return None;
}
LutTable::ThreeDimensional { size, red_fastest: true, entries }
} else {
let size = *sizes.first()? as usize;
let entries: Vec<[f32; 3]> = lines.take(size).map(|line| parse_triple(&parse_numbers(line)?)).collect::<Option<_>>()?;
if size < 2 || entries.len() != size {
return None;
}
LutTable::OneDimensional { entries }
};
Some(Lut {
input_curves: Some(curves),
..Lut::new(Some(table))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color_lookup_table::tests::assert_close;
#[test]
fn pre_lut_scales_inputs() {
let text = "CSPLUTV100\n1D\nBEGIN METADATA\nsomething\nEND METADATA\n2\n0 2\n0 1\n2\n0 2\n0 1\n2\n0 2\n0 1\n2\n0 0 0\n1 1 1\n";
let lut = Lut::parse(text.as_bytes()).unwrap();
assert_close(lut.apply([1., 0.5, 2.]), [0.5, 0.25, 1.]);
}
#[test]
fn pre_lut_under_two_points_is_identity() {
let text = "CSPLUTV100\n1D\n0\n1\n0\n2\n0 0 0\n1 1 1\n";
let lut = Lut::parse(text.as_bytes()).unwrap();
assert_close(lut.apply([0.25, 0.5, 0.75]), [0.25, 0.5, 0.75]);
}
}

View File

@@ -0,0 +1,77 @@
//! The Iridas/Adobe CUBE format: keyword lines, then one entry per line with red varying fastest.
//!
//! <https://web.archive.org/web/20220215173646/https://wwwimages2.adobe.com/content/dam/acom/en/products/speedgrade/cc/pdfs/cube-lut-specification-1.0.pdf>
use super::{Lut, LutTable, parse_numbers, parse_triple};
pub(super) fn parse(text: &str) -> Option<Lut> {
let mut size_1d = None;
let mut size_3d = None;
let mut domain_min = [0.; 3];
let mut domain_max = [1.; 3];
let mut entries = Vec::new();
for raw_line in text.lines() {
let line = raw_line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let (keyword, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
match keyword.to_ascii_uppercase().as_str() {
"TITLE" => {}
"LUT_1D_SIZE" => size_1d = Some(rest.trim().parse::<usize>().ok()?),
"LUT_3D_SIZE" => size_3d = Some(rest.trim().parse::<usize>().ok()?),
"DOMAIN_MIN" => domain_min = parse_triple(&parse_numbers(rest)?)?,
"DOMAIN_MAX" => domain_max = parse_triple(&parse_numbers(rest)?)?,
"LUT_1D_INPUT_RANGE" | "LUT_3D_INPUT_RANGE" => {
let range = parse_numbers(rest)?;
if range.len() != 2 {
return None;
}
domain_min = [range[0]; 3];
domain_max = [range[1]; 3];
}
_ => entries.push(parse_triple(&parse_numbers(line)?)?),
}
}
let table = match (size_3d, size_1d) {
(Some(size), _) if size >= 2 && size.checked_pow(3) == Some(entries.len()) => LutTable::ThreeDimensional {
size: [size; 3],
red_fastest: true,
entries,
},
(None, Some(size)) if size >= 2 && entries.len() == size => LutTable::OneDimensional { entries },
_ => return None,
};
Some(Lut {
domain_min,
domain_max,
..Lut::new(Some(table))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color_lookup_table::tests::assert_close;
#[test]
fn identity_and_swap() {
let mut text = String::from("TITLE \"swap\"\nLUT_3D_SIZE 2\n");
for b in 0..2 {
for g in 0..2 {
for r in 0..2 {
// Swaps red and blue
text += &format!("{b} {g} {r}\n");
}
}
}
let lut = Lut::parse(text.as_bytes()).unwrap();
assert_close(lut.apply([0.25, 0.5, 0.75]), [0.75, 0.5, 0.25]);
let one_dimensional = "LUT_1D_SIZE 3\n0 0 0\n0.25 0.5 0.75\n1 1 1\n";
let lut = Lut::parse(one_dimensional.as_bytes()).unwrap();
assert_close(lut.apply([0.25, 0.25, 0.25]), [0.125, 0.25, 0.375]);
}
}

View File

@@ -0,0 +1,362 @@
//! ICC "abstract" and "device link" profiles, evaluated through their `A2B0` tag.
//!
//! <https://www.color.org/specification/ICC.1-2022-05.pdf>
use super::{Curve, LabEncoding, Lut, LutParseError, LutTable, Stage, parse_triple};
pub(super) fn parse(bytes: &[u8]) -> Result<Lut, LutParseError> {
let header = bytes.get(..128).ok_or(LutParseError::Unreadable)?;
let lab = match (&header[12..16], &header[16..20], &header[20..24]) {
(b"link", b"RGB ", b"RGB ") => false,
(b"abst", b"Lab ", b"Lab ") => true,
(b"link" | b"abst", ..) => return Err(LutParseError::IccColorSpaces),
_ => return Err(LutParseError::IccProfileClass),
};
parse_transform(bytes, lab).ok_or(LutParseError::Unreadable)
}
/// The `A2B0` tag's transform, where `lab` is set for an abstract profile.
fn parse_transform(bytes: &[u8], lab: bool) -> Option<Lut> {
let mut tag = None;
for index in 0..big_endian_u32(bytes, 128)? as usize {
let entry = 132 + index * 12;
if bytes.get(entry..entry + 4)? == b"A2B0" {
let offset = big_endian_u32(bytes, entry + 4)? as usize;
let size = big_endian_u32(bytes, entry + 8)? as usize;
tag = bytes.get(offset..offset.checked_add(size)?);
}
}
let tag = tag?;
match tag.get(0..4)? {
b"mft1" => parse_lut(tag, 1, lab.then_some(LabEncoding::Standard)),
b"mft2" => parse_lut(tag, 2, lab.then_some(LabEncoding::Legacy)),
b"mAB " => parse_lut_a_to_b(tag, lab.then_some(LabEncoding::Standard)),
_ => None,
}
}
/// The `lut8Type` and `lut16Type` layouts: input tables, a CLUT with the first channel varying slowest, then output tables.
fn parse_lut(tag: &[u8], width: usize, lab_encoding: Option<LabEncoding>) -> Option<Lut> {
let (inputs, outputs, grid) = (*tag.get(8)? as usize, *tag.get(9)? as usize, *tag.get(10)? as usize);
if inputs != 3 || outputs != 3 || grid < 2 {
return None;
}
let (input_entries, output_entries, mut offset) = match width {
1 => (256, 256, 48),
_ => (big_endian_u16(tag, 48)? as usize, big_endian_u16(tag, 50)? as usize, 52),
};
let input_curves = read_curve_set(tag, &mut offset, input_entries, width)?;
let samples = read_samples(tag, offset, grid * grid * grid * 3, width)?;
offset += grid * grid * grid * 3 * width;
let output_curves = read_curve_set(tag, &mut offset, output_entries, width)?;
let entries: Vec<[f32; 3]> = samples.chunks(3).map(parse_triple).collect::<Option<_>>()?;
Some(Lut {
input_curves: Some(input_curves),
output_stages: vec![Stage::Curves(output_curves)],
lab_encoding,
..Lut::new(Some(LutTable::ThreeDimensional {
size: [grid; 3],
red_fastest: false,
entries,
}))
})
}
/// The `lutAToBType` layout: A curves, a CLUT with the first channel varying slowest, M curves, a matrix, then B curves, located by offsets that are zero when absent.
fn parse_lut_a_to_b(tag: &[u8], lab_encoding: Option<LabEncoding>) -> Option<Lut> {
if *tag.get(8)? != 3 || *tag.get(9)? != 3 {
return None;
}
let offset_at = |at: usize| big_endian_u32(tag, at).map(|offset| offset as usize);
let (b_curves, matrix, m_curves, clut, a_curves) = (offset_at(12)?, offset_at(16)?, offset_at(20)?, offset_at(24)?, offset_at(28)?);
// The A curves and the CLUT come together or not at all
if b_curves == 0 || (clut == 0) != (a_curves == 0) {
return None;
}
// Offsets within the tag cannot overflow when stepped through
if [b_curves, matrix, m_curves, clut, a_curves].iter().any(|&offset| offset > tag.len()) {
return None;
}
let mut input_curves = None;
let mut table = None;
if clut != 0 {
let grid = [*tag.get(clut)? as usize, *tag.get(clut + 1)? as usize, *tag.get(clut + 2)? as usize];
let width = *tag.get(clut + 16)? as usize;
if grid.iter().any(|&axis| axis < 2) || !matches!(width, 1 | 2) {
return None;
}
let samples = read_samples(tag, clut + 20, grid[0] * grid[1] * grid[2] * 3, width)?;
let entries: Vec<[f32; 3]> = samples.chunks(3).map(parse_triple).collect::<Option<_>>()?;
input_curves = Some(read_curve_elements(tag, a_curves)?);
table = Some(LutTable::ThreeDimensional {
size: grid,
red_fastest: false,
entries,
});
}
let mut output_stages = Vec::new();
if m_curves != 0 {
output_stages.push(Stage::Curves(read_curve_elements(tag, m_curves)?));
}
if matrix != 0 {
let values: Vec<f32> = (0..12).map(|index| fixed_16(tag, matrix + index * 4)).collect::<Option<_>>()?;
output_stages.push(Stage::Matrix {
matrix: [[values[0], values[1], values[2]], [values[3], values[4], values[5]], [values[6], values[7], values[8]]],
offset: [values[9], values[10], values[11]],
});
}
output_stages.push(Stage::Curves(read_curve_elements(tag, b_curves)?));
Some(Lut {
input_curves,
output_stages,
lab_encoding,
..Lut::new(table)
})
}
/// Three consecutive `curveType` or `parametricCurveType` elements, each padded to a four byte boundary.
fn read_curve_elements(tag: &[u8], start: usize) -> Option<[Curve; 3]> {
let mut offset = start;
let mut curves = Vec::new();
for _ in 0..3 {
let (curve, length) = match tag.get(offset..offset + 4)? {
b"curv" => {
let count = big_endian_u32(tag, offset + 8)? as usize;
let curve = match count {
0 => Curve::Power(1.),
1 => Curve::Power(big_endian_u16(tag, offset + 12)? as f32 / 256.),
_ => Curve::Sampled(read_samples(tag, offset + 12, count, 2)?),
};
(curve, 12 + count * 2)
}
b"para" => {
let function = big_endian_u16(tag, offset + 8)?;
let count = *[1, 3, 4, 5, 7].get(function as usize)?;
let parameters = (0..count).map(|index| fixed_16(tag, offset + 12 + index * 4)).collect::<Option<_>>()?;
(Curve::Parametric { function, parameters }, 12 + count * 4)
}
_ => return None,
};
curves.push(curve);
offset += length.next_multiple_of(4);
}
curves.try_into().ok()
}
/// Three consecutive sampled curves of `entries` samples each, advancing `offset` past them.
fn read_curve_set(bytes: &[u8], offset: &mut usize, entries: usize, width: usize) -> Option<[Curve; 3]> {
let mut curves = Vec::new();
for _ in 0..3 {
curves.push(Curve::Sampled(read_samples(bytes, *offset, entries, width)?));
*offset += entries * width;
}
curves.try_into().ok()
}
/// `count` unsigned samples of `width` bytes each, scaled to 0..1.
fn read_samples(bytes: &[u8], offset: usize, count: usize, width: usize) -> Option<Vec<f32>> {
let data = bytes.get(offset..offset.checked_add(count.checked_mul(width)?)?)?;
Some(match width {
1 => data.iter().map(|&byte| byte as f32 / 255.).collect(),
_ => data.chunks(2).map(|pair| u16::from_be_bytes([pair[0], pair[1]]) as f32 / 65535.).collect(),
})
}
fn big_endian_u32(bytes: &[u8], offset: usize) -> Option<u32> {
Some(u32::from_be_bytes(bytes.get(offset..offset + 4)?.try_into().ok()?))
}
fn big_endian_u16(bytes: &[u8], offset: usize) -> Option<u16> {
Some(u16::from_be_bytes(bytes.get(offset..offset + 2)?.try_into().ok()?))
}
/// An ICC `s15Fixed16Number`.
fn fixed_16(bytes: &[u8], offset: usize) -> Option<f32> {
Some(big_endian_u32(bytes, offset)? as i32 as f32 / 65536.)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color_lookup_table::tests::{assert_close, assert_close_within};
/// A profile of the given classes holding one `A2B0` tag.
fn icc_profile(class: &[u8; 4], space: &[u8; 4], pcs: &[u8; 4], tag: &[u8]) -> Vec<u8> {
let mut bytes = vec![0; 128];
bytes[8] = 2;
bytes[12..16].copy_from_slice(class);
bytes[16..20].copy_from_slice(space);
bytes[20..24].copy_from_slice(pcs);
bytes[36..40].copy_from_slice(b"acsp");
bytes.extend_from_slice(&1_u32.to_be_bytes());
bytes.extend_from_slice(b"A2B0");
bytes.extend_from_slice(&144_u32.to_be_bytes());
bytes.extend_from_slice(&(tag.len() as u32).to_be_bytes());
bytes.extend_from_slice(tag);
bytes
}
#[test]
fn a_profile_of_the_wrong_kind_says_why_it_is_refused() {
let parse = |class, space, pcs| Lut::parse(&icc_profile(class, space, pcs, &[])).unwrap_err();
// A monitor profile, a CMYK device link, and an abstract profile connected through XYZ
assert_eq!(parse(b"mntr", b"RGB ", b"XYZ "), LutParseError::IccProfileClass);
assert_eq!(parse(b"link", b"CMYK", b"Lab "), LutParseError::IccColorSpaces);
assert_eq!(parse(b"abst", b"XYZ ", b"XYZ "), LutParseError::IccColorSpaces);
// The right kind of profile with no readable transform
assert_eq!(parse(b"link", b"RGB ", b"RGB "), LutParseError::Unreadable);
}
#[test]
fn device_link_lut8_orders_first_channel_slowest() {
let mut tag = vec![0; 48];
tag[0..4].copy_from_slice(b"mft1");
tag[8] = 3;
tag[9] = 3;
tag[10] = 2;
let identity: Vec<u8> = (0..=255).collect();
for _ in 0..3 {
tag.extend_from_slice(&identity);
}
for r in 0..2_u8 {
for g in 0..2_u8 {
for b in 0..2_u8 {
// Swaps red and blue
tag.extend_from_slice(&[b * 255, g * 255, r * 255]);
}
}
}
for _ in 0..3 {
tag.extend_from_slice(&identity);
}
let lut = Lut::parse(&icc_profile(b"link", b"RGB ", b"RGB ", &tag)).unwrap();
assert_close(lut.apply([0.25, 0.5, 0.75]), [0.75, 0.5, 0.25]);
}
#[test]
fn abstract_profile_round_trips_through_lab() {
let mut tag = vec![0; 52];
tag[0..4].copy_from_slice(b"mft2");
tag[8] = 3;
tag[9] = 3;
tag[10] = 2;
tag[48..50].copy_from_slice(&2_u16.to_be_bytes());
tag[50..52].copy_from_slice(&2_u16.to_be_bytes());
let identity = [0_u16.to_be_bytes(), 65535_u16.to_be_bytes()].concat();
for _ in 0..3 {
tag.extend_from_slice(&identity);
}
for l in 0..2_u16 {
for a in 0..2_u16 {
for b in 0..2_u16 {
for value in [l, a, b] {
tag.extend_from_slice(&(value * 65535).to_be_bytes());
}
}
}
}
for _ in 0..3 {
tag.extend_from_slice(&identity);
}
let lut = Lut::parse(&icc_profile(b"abst", b"Lab ", b"Lab ", &tag)).unwrap();
let color = [0.25, 0.5, 0.75];
assert_close_within(lut.apply(color), color, 1e-3);
}
#[test]
fn lut_a_to_b_applies_curves_matrix_and_grid() {
let curv = |gamma: Option<u16>| -> Vec<u8> {
let mut bytes = b"curv\0\0\0\0".to_vec();
match gamma {
None => bytes.extend_from_slice(&0_u32.to_be_bytes()),
Some(gamma) => {
bytes.extend_from_slice(&1_u32.to_be_bytes());
bytes.extend_from_slice(&gamma.to_be_bytes());
bytes.extend_from_slice(&[0, 0]);
}
}
bytes
};
let para = |gamma: i32| -> Vec<u8> {
let mut bytes = b"para\0\0\0\0\0\0\0\0".to_vec();
bytes.extend_from_slice(&gamma.to_be_bytes());
bytes
};
// Identity A curves and grid, unit-gamma M curves, a matrix swapping red and blue, then unit-gamma parametric B curves
let mut tag = b"mAB \0\0\0\0\x03\x03\0\0".to_vec();
for offset in [232_u32, 184, 136, 68, 32] {
tag.extend_from_slice(&offset.to_be_bytes());
}
for _ in 0..3 {
tag.extend(curv(None));
}
tag.extend_from_slice(&[2, 2, 2]);
tag.extend_from_slice(&[0; 13]);
tag.push(2);
tag.extend_from_slice(&[0; 3]);
for r in 0..2_u16 {
for g in 0..2_u16 {
for b in 0..2_u16 {
for value in [r, g, b] {
tag.extend_from_slice(&(value * 65535).to_be_bytes());
}
}
}
}
for _ in 0..3 {
tag.extend(curv(Some(0x0100)));
}
for value in [0_i32, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0] {
tag.extend_from_slice(&(value << 16).to_be_bytes());
}
for _ in 0..3 {
tag.extend(para(1 << 16));
}
assert_eq!(tag.len(), 280);
let lut = Lut::parse(&icc_profile(b"link", b"RGB ", b"RGB ", &tag)).unwrap();
assert_close(lut.apply([0.25, 0.5, 0.75]), [0.75, 0.5, 0.25]);
}
#[test]
fn lut_a_to_b_without_a_grid_applies_its_curves() {
let mut tag = b"mAB \0\0\0\0\x03\x03\0\0".to_vec();
for offset in [32_u32, 0, 0, 0, 0] {
tag.extend_from_slice(&offset.to_be_bytes());
}
for _ in 0..3 {
// A parametric square
tag.extend_from_slice(b"para\0\0\0\0\0\0\0\0");
tag.extend_from_slice(&(2_i32 << 16).to_be_bytes());
}
let lut = Lut::parse(&icc_profile(b"link", b"RGB ", b"RGB ", &tag)).unwrap();
assert_close(lut.apply([0.5, 0.5, 0.5]), [0.25, 0.25, 0.25]);
}
#[test]
fn parametric_curves_clip_to_the_unit_range() {
let mut tag = b"mAB \0\0\0\0\x03\x03\0\0".to_vec();
for offset in [32_u32, 0, 0, 0, 0] {
tag.extend_from_slice(&offset.to_be_bytes());
}
for _ in 0..3 {
// Function 2 with unit gain and a 0.5 offset: y = x + 0.5
tag.extend_from_slice(b"para\0\0\0\0\0\x02\0\0");
for parameter in [1_i32 << 16, 1 << 16, 0, 1 << 15] {
tag.extend_from_slice(&parameter.to_be_bytes());
}
}
let lut = Lut::parse(&icc_profile(b"link", b"RGB ", b"RGB ", &tag)).unwrap();
assert_close(lut.apply([0.75, 0.75, 0.75]), [1., 1., 1.]);
}
}

View File

@@ -0,0 +1,62 @@
//! The Iridas/Adobe LOOK format: XML whose `<LUT>` element bakes the look into a 3D table of hex-encoded little-endian floats with red varying fastest.
use super::{Lut, LutTable, parse_triple};
pub(super) fn parse(text: &str) -> Option<Lut> {
let lut = xml_element_body(text, "LUT")?;
let size: usize = xml_element_body(lut, "size")?.trim().trim_matches('"').parse().ok()?;
let hex: Vec<u8> = xml_element_body(lut, "data")?.bytes().filter(|byte| !byte.is_ascii_whitespace() && *byte != b'"').collect();
let floats: Vec<f32> = hex
.chunks(8)
.map(|word| {
let word = std::str::from_utf8(word).ok().filter(|word| word.len() == 8)?;
Some(f32::from_bits(u32::from_str_radix(word, 16).ok()?.swap_bytes()))
})
.collect::<Option<_>>()?;
let entries: Vec<[f32; 3]> = floats.chunks(3).map(parse_triple).collect::<Option<_>>()?;
if size < 2 || size.checked_pow(3) != Some(entries.len()) {
return None;
}
Some(Lut::new(Some(LutTable::ThreeDimensional {
size: [size; 3],
red_fastest: true,
entries,
})))
}
/// The text between an element's tags, enough for the attribute-free markup of a look file.
fn xml_element_body<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = text.find(&open)? + open.len();
let end = start + text[start..].find(&close)?;
Some(&text[start..end])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color_lookup_table::tests::assert_close;
#[test]
fn decodes_hex_floats_red_fastest() {
let mut hex = String::new();
for b in 0..2 {
for g in 0..2 {
for r in 0..2 {
// Swaps red and blue
for value in [b as f32, g as f32, r as f32] {
hex.extend(value.to_le_bytes().iter().map(|byte| format!("{byte:02X}")));
}
}
}
}
let text = format!(
"<?xml version=\"1.0\" ?>\n<look>\n <shaders>\n </shaders>\n <LUT>\n <size>\"2\"</size>\n <data>\"\n {hex}\n \"</data>\n </LUT>\n <LUT1D>\n <size>\"2\"</size>\n <data>\"00\"</data>\n </LUT1D>\n</look>\n"
);
let lut = Lut::parse(text.as_bytes()).unwrap();
assert_close(lut.apply([0.25, 0.5, 0.75]), [0.75, 0.5, 0.25]);
}
}

View File

@@ -9,6 +9,8 @@ pub mod fullscreen_vertex;
#[cfg(feature = "shader-nodes")]
pub use raster_nodes_shaders::WGSL_SHADER;
#[cfg(feature = "std")]
pub mod color_lookup_table;
#[cfg(feature = "std")]
pub mod dehaze;
#[cfg(feature = "std")]