This guide outlines how to add comprehensive color space interpretation to Brush, particularly for ACES2065-1, ProPhoto RGB, and Rec.2020, with proper EXR float format support.
Currently, Brush treats all images as sRGB. We need to:
First, define a comprehensive color space enum in a new module crates/brush-dataset/src/color_space.rs:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorSpace {
Srgb,
LinearSrgb,
Aces2065_1,
AcesCg,
ProPhotoRgb,
Rec2020,
DisplayP3,
Unknown,
}
impl ColorSpace {
/// Detect color space from EXR metadata
pub fn from_exr_metadata(metadata: &openexr::core::Header) -> Self {
// Check chromaticities attribute
if let Ok(chrom) = metadata.chromaticities() {
if Self::matches_aces2065_1(&chrom) {
return Self::Aces2065_1;
}
if Self::matches_prophoto(&chrom) {
return Self::ProPhotoRgb;
}
if Self::matches_rec2020(&chrom) {
return Self::Rec2020;
}
}
Self::LinearSrgb // EXR default
}
fn matches_aces2065_1(chrom: &Chromaticities) -> bool {
// ACES2065-1 primaries
const ACES_RED: [f32; 2] = [0.7347, 0.2653];
const ACES_GREEN: [f32; 2] = [0.0, 1.0];
const ACES_BLUE: [f32; 2] = [0.0001, -0.077];
const ACES_WHITE: [f32; 2] = [0.32168, 0.33767]; // D60
Self::matches_primaries(chrom, ACES_RED, ACES_GREEN, ACES_BLUE, ACES_WHITE)
}
fn matches_prophoto(chrom: &Chromaticities) -> bool {
// ProPhoto RGB primaries
const PROPHOTO_RED: [f32; 2] = [0.7347, 0.2653];
const PROPHOTO_GREEN: [f32; 2] = [0.1596, 0.8404];
const PROPHOTO_BLUE: [f32; 2] = [0.0366, 0.0001];
const PROPHOTO_WHITE: [f32; 2] = [0.34567, 0.35850]; // D50
Self::matches_primaries(chrom, PROPHOTO_RED, PROPHOTO_GREEN, PROPHOTO_BLUE, PROPHOTO_WHITE)
}
fn matches_rec2020(chrom: &Chromaticities) -> bool {
// Rec.2020 primaries
const REC2020_RED: [f32; 2] = [0.708, 0.292];
const REC2020_GREEN: [f32; 2] = [0.170, 0.797];
const REC2020_BLUE: [f32; 2] = [0.131, 0.046];
const REC2020_WHITE: [f32; 2] = [0.3127, 0.3290]; // D65
Self::matches_primaries(chrom, REC2020_RED, REC2020_GREEN, REC2020_BLUE, REC2020_WHITE)
}
fn matches_primaries(
chrom: &Chromaticities,
red: [f32; 2],
green: [f32; 2],
blue: [f32; 2],
white: [f32; 2],
) -> bool {
const TOLERANCE: f32 = 0.001;
let close = |a: f32, b: f32| (a - b).abs() < TOLERANCE;
close(chrom.red.x, red[0]) && close(chrom.red.y, red[1]) &&
close(chrom.green.x, green[0]) && close(chrom.green.y, green[1]) &&
close(chrom.blue.x, blue[0]) && close(chrom.blue.y, blue[1]) &&
close(chrom.white.x, white[0]) && close(chrom.white.y, white[1])
}
}Add transformation matrices in crates/brush-dataset/src/color_transform.rs:
use glam::{Mat3, Vec3};
pub struct ColorTransform {
matrix: Mat3,
}
impl ColorTransform {
/// Create transform from source to target color space
pub fn new(from: ColorSpace, to: ColorSpace) -> Self {
let matrix = match (from, to) {
(a, b) if a == b => Mat3::IDENTITY,
// ACES2065-1 to Linear sRGB
(ColorSpace::Aces2065_1, ColorSpace::LinearSrgb) => {
Self::aces2065_1_to_linear_srgb()
},
// ProPhoto RGB to Linear sRGB
(ColorSpace::ProPhotoRgb, ColorSpace::LinearSrgb) => {
Self::prophoto_to_linear_srgb()
},
// Rec.2020 to Linear sRGB
(ColorSpace::Rec2020, ColorSpace::LinearSrgb) => {
Self::rec2020_to_linear_srgb()
},
_ => {
log::warn!("Unsupported color transform {:?} -> {:?}, using identity", from, to);
Mat3::IDENTITY
}
};
Self { matrix }
}
/// Apply color transform to RGB pixel
pub fn apply(&self, rgb: Vec3) -> Vec3 {
self.matrix * rgb
}
// ACES AP0 (2065-1) -> sRGB transformation matrix
// Derived from chromatic adaptation and primaries
fn aces2065_1_to_linear_srgb() -> Mat3 {
Mat3::from_cols_array(&[
2.52168619, -1.13413098, -0.38755521,
-0.27609149, 1.37259412, -0.09650263,
-0.01592537, -0.14811307, 1.16403844,
])
}
// ProPhoto RGB -> sRGB transformation matrix
// Note: ProPhoto uses D50 white point, requires chromatic adaptation
fn prophoto_to_linear_srgb() -> Mat3 {
// Bradford chromatic adaptation D50 -> D65
let d50_to_d65 = Mat3::from_cols_array(&[
0.9555766, -0.0230393, 0.0631636,
-0.0282895, 1.0099416, 0.0210077,
0.0122982, -0.0204830, 1.3299098,
]);
// ProPhoto RGB primaries to XYZ
let prophoto_to_xyz = Mat3::from_cols_array(&[
0.7976749, 0.1351917, 0.0313534,
0.2880402, 0.7118741, 0.0000857,
0.0000000, 0.0000000, 0.8252100,
]);
// XYZ to sRGB primaries
let xyz_to_srgb = Mat3::from_cols_array(&[
3.2404542, -1.5371385, -0.4985314,
-0.9692660, 1.8760108, 0.0415560,
0.0556434, -0.2040259, 1.0572252,
]);
xyz_to_srgb * d50_to_d65 * prophoto_to_xyz
}
// Rec.2020 -> sRGB transformation matrix
fn rec2020_to_linear_srgb() -> Mat3 {
Mat3::from_cols_array(&[
1.6604910, -0.5876411, -0.0728499,
-0.1245505, 1.1328999, -0.0083494,
-0.0181508, -0.1005789, 1.1187297,
])
}
}Modify the image loading code (likely in crates/brush-dataset/src/image.rs or similar):
use image::{DynamicImage, Rgba};
use openexr::prelude::*;
pub struct LoadedImage {
pub data: Vec<f32>, // RGBA linear float data
pub width: u32,
pub height: u32,
pub color_space: ColorSpace,
}
impl LoadedImage {
pub fn from_path(path: &Path) -> Result<Self> {
let extension = path.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
match extension.to_lowercase().as_str() {
"exr" => Self::load_exr(path),
_ => Self::load_standard(path),
}
}
fn load_exr(path: &Path) -> Result<Self> {
let file = std::fs::File::open(path)?;
let mut input = InputFile::new(file)?;
let header = input.header();
let data_window = header.data_window::<[i32; 2]>();
let width = (data_window[1][0] - data_window[0][0] + 1) as u32;
let height = (data_window[1][1] - data_window[0][1] + 1) as u32;
// Detect color space from metadata
let source_space = ColorSpace::from_exr_metadata(header);
// Read RGB(A) channels
let pixel_data = Self::read_exr_pixels(&mut input, width, height)?;
// Transform to working space (linear sRGB)
let transform = ColorTransform::new(source_space, ColorSpace::LinearSrgb);
let transformed = Self::transform_pixels(&pixel_data, &transform);
Ok(Self {
data: transformed,
width,
height,
color_space: ColorSpace::LinearSrgb,
})
}
fn read_exr_pixels(
input: &mut InputFile,
width: u32,
height: u32,
) -> Result<Vec<f32>> {
let mut rgba_data = vec![0.0f32; (width * height * 4) as usize];
// Try to read RGBA channels
let has_alpha = input.header().find_channel("A").is_some();
input.read_pixels(&["R", "G", "B", if has_alpha { "A" } else { "" }])?;
// Process pixel data...
// (Implementation depends on specific EXR library being used)
Ok(rgba_data)
}
fn transform_pixels(pixels: &[f32], transform: &ColorTransform) -> Vec<f32> {
let mut result = Vec::with_capacity(pixels.len());
for chunk in pixels.chunks(4) {
let rgb = Vec3::new(chunk[0], chunk[1], chunk[2]);
let transformed = transform.apply(rgb);
result.push(transformed.x);
result.push(transformed.y);
result.push(transformed.z);
result.push(chunk[3]); // Keep alpha unchanged
}
result
}
fn load_standard(path: &Path) -> Result<Self> {
let img = image::open(path)?;
let rgba = img.to_rgba32f();
// Standard images are assumed sRGB
// Convert sRGB -> Linear sRGB
let mut linear_data = Vec::with_capacity(rgba.len());
for pixel in rgba.pixels() {
let r = Self::srgb_to_linear(pixel[0]);
let g = Self::srgb_to_linear(pixel[1]);
let b = Self::srgb_to_linear(pixel[2]);
let a = pixel[3]; // Alpha is linear
linear_data.extend_from_slice(&[r, g, b, a]);
}
Ok(Self {
data: linear_data,
width: rgba.width(),
height: rgba.height(),
color_space: ColorSpace::LinearSrgb,
})
}
fn srgb_to_linear(value: f32) -> f32 {
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
}Add to Cargo.toml:
[dependencies]
openexr = "1.2"
glam = "0.27"Create comprehensive tests in crates/brush-dataset/src/color_space_tests.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_aces_primaries_detection() {
// Test ACES2065-1 detection
let chrom = Chromaticities {
red: V2f { x: 0.7347, y: 0.2653 },
green: V2f { x: 0.0, y: 1.0 },
blue: V2f { x: 0.0001, y: -0.077 },
white: V2f { x: 0.32168, y: 0.33767 },
};
assert!(ColorSpace::matches_aces2065_1(&chrom));
}
#[test]
fn test_color_transform_identity() {
let transform = ColorTransform::new(
ColorSpace::LinearSrgb,
ColorSpace::LinearSrgb
);
let input = Vec3::new(0.5, 0.7, 0.3);
let output = transform.apply(input);
assert!((output - input).length() < 0.0001);
}
#[test]
fn test_aces_to_srgb_transform() {
let transform = ColorTransform::new(
ColorSpace::Aces2065_1,
ColorSpace::LinearSrgb
);
// Test with ACES white point
let aces_white = Vec3::new(1.0, 1.0, 1.0);
let srgb_result = transform.apply(aces_white);
// Should produce valid sRGB values
assert!(srgb_result.x >= 0.0 && srgb_result.x <= 2.0);
assert!(srgb_result.y >= 0.0 && srgb_result.y <= 2.0);
assert!(srgb_result.z >= 0.0 && srgb_result.z <= 2.0);
}
}ColorSpace enumColorTransform structAdd CLI flags for color space handling:
#[derive(clap::Parser)]
pub struct ColorSpaceArgs {
/// Override detected color space
#[arg(long)]
color_space: Option<ColorSpace>,
/// Target working color space
#[arg(long, default_value = "linear-srgb")]
working_space: ColorSpace,
/// Disable color space transformations
#[arg(long)]
no_color_transform: bool,
}