Content is user-generated and unverified.

Color Space Support Implementation Guide for Brush

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.

Overview

Currently, Brush treats all images as sRGB. We need to:

  1. Detect color space from image metadata
  2. Support ACES2065-1, ProPhoto RGB, and Rec.2020
  3. Transform to working space (linear sRGB or ACES)
  4. Properly handle EXR float formats

Architecture

1. Color Space Enum

First, define a comprehensive color space enum in a new module crates/brush-dataset/src/color_space.rs:

rust
#[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])
    }
}

2. Color Transform Matrices

Add transformation matrices in crates/brush-dataset/src/color_transform.rs:

rust
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,
        ])
    }
}

3. Image Loading Integration

Modify the image loading code (likely in crates/brush-dataset/src/image.rs or similar):

rust
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)
        }
    }
}

4. Dependencies

Add to Cargo.toml:

toml
[dependencies]
openexr = "1.2"
glam = "0.27"

5. Testing

Create comprehensive tests in crates/brush-dataset/src/color_space_tests.rs:

rust
#[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);
    }
}

Implementation Steps

  1. Create color space infrastructure (Week 1)
    • Implement ColorSpace enum
    • Add color space detection logic
    • Create test cases
  2. Add transformation matrices (Week 1)
    • Implement ColorTransform struct
    • Add all required transformation matrices
    • Validate against reference implementations
  3. Integrate with image loading (Week 2)
    • Modify EXR loading path
    • Add metadata parsing
    • Apply transformations
  4. Testing and validation (Week 2)
    • Create test images in various color spaces
    • Compare output with reference tools (e.g., OIIO, OpenColorIO)
    • Profile performance impact
  5. Documentation (Week 3)
    • Document color space handling
    • Add examples for each supported space
    • Update CLI help text

Configuration Options

Add CLI flags for color space handling:

rust
#[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,
}

Performance Considerations

  1. GPU acceleration: Consider moving transforms to GPU for large datasets
  2. Caching: Cache transformation matrices
  3. SIMD: Use SIMD for pixel transformations
  4. Lazy loading: Only transform when needed

Future Enhancements

  • OpenColorIO (OCIO) integration for arbitrary transforms
  • LUT-based color grading
  • HDR tone mapping options
  • Support for additional color spaces (ACEScg, DCI-P3, etc.)
  • Per-image color space configuration
  • Exposure compensation for HDR content

References

Content is user-generated and unverified.
    Color Space Support Implementation Guide for Brush | Claude