From 97d8452a1d7cc410a197d51be015fb9085a3f598 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 19 Sep 2026 02:56:14 -0700 Subject: [PATCH] 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 --- .../node_graph/document_node_definitions.rs | 7 + .../document/node_graph/node_properties.rs | 4 +- .../ingest/ingest_message_handler.rs | 100 +++- .../portfolio/ingest/utility_types.rs | 20 +- node-graph/nodes/raster/src/adjustments.rs | 50 +- .../nodes/raster/src/color_lookup_table.rs | 442 ++++++++++++++++++ .../src/color_lookup_table/format_3dl.rs | 174 +++++++ .../src/color_lookup_table/format_csp.rs | 90 ++++ .../src/color_lookup_table/format_cube.rs | 77 +++ .../src/color_lookup_table/format_icc.rs | 362 ++++++++++++++ .../src/color_lookup_table/format_look.rs | 62 +++ node-graph/nodes/raster/src/lib.rs | 2 + 12 files changed, 1370 insertions(+), 20 deletions(-) create mode 100644 node-graph/nodes/raster/src/color_lookup_table.rs create mode 100644 node-graph/nodes/raster/src/color_lookup_table/format_3dl.rs create mode 100644 node-graph/nodes/raster/src/color_lookup_table/format_csp.rs create mode 100644 node-graph/nodes/raster/src/color_lookup_table/format_cube.rs create mode 100644 node-graph/nodes/raster/src/color_lookup_table/format_icc.rs create mode 100644 node-graph/nodes/raster/src/color_lookup_table/format_look.rs diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 01ae577ee5..ad9d4bc201 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -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| { diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 0eeda6fbcf..0919b06e9d 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -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() diff --git a/editor/src/messages/portfolio/ingest/ingest_message_handler.rs b/editor/src/messages/portfolio/ingest/ingest_message_handler.rs index f6e09f5bc0..76f85d62cd 100644 --- a/editor/src/messages/portfolio/ingest/ingest_message_handler.rs +++ b/editor/src/messages/portfolio/ingest/ingest_message_handler.rs @@ -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 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 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) { }); } -/// 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 { + ingest_named(data, None, action, document_open) + } + + fn ingest_named(data: &[u8], file_name: Option<&str>, action: IngestAction, document_open: bool) -> VecDeque { 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(); diff --git a/editor/src/messages/portfolio/ingest/utility_types.rs b/editor/src/messages/portfolio/ingest/utility_types.rs index 50e2e83f7d..d96b55af9b 100644 --- a/editor/src/messages/portfolio/ingest/utility_types.rs +++ b/editor/src/messages/portfolio/ingest/utility_types.rs @@ -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 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()); } } diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 8f1b57ffc4..08a910974a 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -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>( } // 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 + Send>( + _: impl Ctx, + /// The image whose colors are remapped by the LUT (lookup table). + #[implementations(Raster, Color, Gradient)] + image: Item, + /// 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, + #[data] lut_cache: LutCache, +) -> Item { + 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::{ diff --git a/node-graph/nodes/raster/src/color_lookup_table.rs b/node-graph/nodes/raster/src/color_lookup_table.rs new file mode 100644 index 0000000000..837f6c3bee --- /dev/null +++ b/node-graph/nodes/raster/src/color_lookup_table.rs @@ -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, 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>>); + +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), + /// Output samples at the given inputs (the CSP format's pre-LUT). + Piecewise { + inputs: Vec, + outputs: Vec, + }, + Power(f32), + /// One of the ICC parametric curve functions, with its parameters in the specification's order. + Parametric { + function: u16, + parameters: Vec, + }, +} + +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, + output_stages: Vec, + /// Set when the table maps CIELAB rather than RGB, as an abstract profile's does. + lab_encoding: Option, +} + +impl Lut { + fn new(table: Option) -> 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 { + 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 { + 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> { + line.split_whitespace().map(|token| token.parse::().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!("2{data}"); + 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"4194304").is_err()); + assert!(Lut::parse(b"CSPLUTV100\n3D\n0\n0\n0\n4194304 4194304 4194304\n").is_err()); + } +} diff --git a/node-graph/nodes/raster/src/color_lookup_table/format_3dl.rs b/node-graph/nodes/raster/src/color_lookup_table/format_3dl.rs new file mode 100644 index 0000000000..4054ae50b4 --- /dev/null +++ b/node-graph/nodes/raster/src/color_lookup_table/format_3dl.rs @@ -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. +//! +//! + +use super::{Curve, Lut, LutTable, parse_numbers}; + +pub(super) fn parse(text: &str) -> Option { + let mut samples: Option> = 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 { + 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()); + } +} diff --git a/node-graph/nodes/raster/src/color_lookup_table/format_csp.rs b/node-graph/nodes/raster/src/color_lookup_table/format_csp.rs new file mode 100644 index 0000000000..0d50ebda37 --- /dev/null +++ b/node-graph/nodes/raster/src/color_lookup_table/format_csp.rs @@ -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. +//! +//! + +use super::{Curve, Lut, LutTable, parse_numbers, parse_triple}; + +pub(super) fn parse(text: &str) -> Option { + 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::>()?; + 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::>()?; + 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]); + } +} diff --git a/node-graph/nodes/raster/src/color_lookup_table/format_cube.rs b/node-graph/nodes/raster/src/color_lookup_table/format_cube.rs new file mode 100644 index 0000000000..75b18ee123 --- /dev/null +++ b/node-graph/nodes/raster/src/color_lookup_table/format_cube.rs @@ -0,0 +1,77 @@ +//! The Iridas/Adobe CUBE format: keyword lines, then one entry per line with red varying fastest. +//! +//! + +use super::{Lut, LutTable, parse_numbers, parse_triple}; + +pub(super) fn parse(text: &str) -> Option { + 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::().ok()?), + "LUT_3D_SIZE" => size_3d = Some(rest.trim().parse::().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]); + } +} diff --git a/node-graph/nodes/raster/src/color_lookup_table/format_icc.rs b/node-graph/nodes/raster/src/color_lookup_table/format_icc.rs new file mode 100644 index 0000000000..8efd9800a0 --- /dev/null +++ b/node-graph/nodes/raster/src/color_lookup_table/format_icc.rs @@ -0,0 +1,362 @@ +//! ICC "abstract" and "device link" profiles, evaluated through their `A2B0` tag. +//! +//! + +use super::{Curve, LabEncoding, Lut, LutParseError, LutTable, Stage, parse_triple}; + +pub(super) fn parse(bytes: &[u8]) -> Result { + 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 { + 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) -> Option { + 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::>()?; + 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) -> Option { + 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::>()?; + 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 = (0..12).map(|index| fixed_16(tag, matrix + index * 4)).collect::>()?; + 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::>()?; + (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> { + 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 { + Some(u32::from_be_bytes(bytes.get(offset..offset + 4)?.try_into().ok()?)) +} + +fn big_endian_u16(bytes: &[u8], offset: usize) -> Option { + Some(u16::from_be_bytes(bytes.get(offset..offset + 2)?.try_into().ok()?)) +} + +/// An ICC `s15Fixed16Number`. +fn fixed_16(bytes: &[u8], offset: usize) -> Option { + 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 { + 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 = (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| -> Vec { + 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 { + 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(¶meter.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.]); + } +} diff --git a/node-graph/nodes/raster/src/color_lookup_table/format_look.rs b/node-graph/nodes/raster/src/color_lookup_table/format_look.rs new file mode 100644 index 0000000000..1c0322c705 --- /dev/null +++ b/node-graph/nodes/raster/src/color_lookup_table/format_look.rs @@ -0,0 +1,62 @@ +//! The Iridas/Adobe LOOK format: XML whose `` 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 { + let lut = xml_element_body(text, "LUT")?; + let size: usize = xml_element_body(lut, "size")?.trim().trim_matches('"').parse().ok()?; + let hex: Vec = xml_element_body(lut, "data")?.bytes().filter(|byte| !byte.is_ascii_whitespace() && *byte != b'"').collect(); + + let floats: Vec = 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::>()?; + let entries: Vec<[f32; 3]> = floats.chunks(3).map(parse_triple).collect::>()?; + 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!(""); + 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!( + "\n\n \n \n \n \"2\"\n \"\n {hex}\n \"\n \n \n \"2\"\n \"00\"\n \n\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]); + } +} diff --git a/node-graph/nodes/raster/src/lib.rs b/node-graph/nodes/raster/src/lib.rs index 58efebdbd9..5d541d3770 100644 --- a/node-graph/nodes/raster/src/lib.rs +++ b/node-graph/nodes/raster/src/lib.rs @@ -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")]