Is This Color Definition Optimal? Exploring Its Use Cases, Limitations, and Rust Design Philosophy

I am currently learning about graphics-related knowledge. Regarding color, I have the following definition, and I would like to understand the applicable scenarios and limitations of this definition, as well as any issues from the perspective of Rust's design philosophy. Thank you all for your help.

use std::marker::PhantomData;

pub trait ColorSpace {}

#[derive(Debug)]
pub struct Srgb ;

impl ColorSpace for Srgb {}

pub trait Premultipliable {
    type Output;
    fn premultiply(&self) -> Self::Output;
}

pub trait Depremultipliable {
    type Output;
    fn depremultiply(&self) -> Self::Output;
}

pub trait IsPremultiplied {
    const IS_PREMULTIPLIED: bool;
}

#[derive(Debug)]
pub struct NotPremultiplied;
#[derive(Debug)]
pub struct Premultiplied;

impl IsPremultiplied for NotPremultiplied {
    const IS_PREMULTIPLIED: bool = false;
}
impl IsPremultiplied for Premultiplied {
    const IS_PREMULTIPLIED: bool = true;
}

#[derive(Debug, Clone, Copy)]
#[repr(transparent)]
pub struct Color<T, const N: usize, CS: ColorSpace = Srgb , P = NotPremultiplied> {
    pub components: [T; N],
    pub _cs: PhantomData<CS>,
    pub _p: PhantomData<P>,
}

impl<T, const N: usize, CS: ColorSpace, P: IsPremultiplied> Color<T, N, CS, P> {
    pub fn new(components: [T; N]) -> Self {
        Self {
            components,
            _cs: PhantomData,
            _p: PhantomData,
        }
    }
    pub fn is_premultiplied(self) -> bool {
        P::IS_PREMULTIPLIED
    }
}

pub trait OpaqueColor<T> {
    fn red(&self) -> T;
    fn green(&self) -> T;
    fn blue(&self) -> T;
}

pub trait AlphaColor<T> {
    fn red(&self) -> T;
    fn green(&self) -> T;
    fn blue(&self) -> T;
    fn alpha(&self) -> T;
}

impl<T: Copy, CS: ColorSpace> OpaqueColor<T> for Color<T, 3, CS> {
    fn red(&self) -> T {
        self.components[0]
    }

    fn green(&self) -> T {
        self.components[1]
    }

    fn blue(&self) -> T {
        self.components[2]
    }
}

impl<T: Copy, CS: ColorSpace, P: IsPremultiplied> AlphaColor<T> for Color<T, 4, CS, P> {
    fn red(&self) -> T {
        self.components[0]
    }

    fn green(&self) -> T {
        self.components[1]
    }

    fn blue(&self) -> T {
        self.components[2]
    }

    fn alpha(&self) -> T {
        self.components[3]
    }
}

impl<CS: ColorSpace> Premultipliable for Color<u8, 4, CS, NotPremultiplied> {
    type Output = Color<u8, 4, CS, Premultiplied>;

    fn premultiply(&self) -> Self::Output {
        let a = self.alpha();
        if a != 255 {
            Color {
                components: [
                    premultiply_u8(self.red(), a),
                    premultiply_u8(self.green(), a),
                    premultiply_u8(self.blue(), a),
                    a,
                ],
                _cs: PhantomData,
                _p: PhantomData,
            }
        } else {
            Color {
                components: [self.red(), self.green(), self.blue(), a],
                _cs: PhantomData,
                _p: PhantomData,
            }
        }
    }
}

impl<CS: ColorSpace> Depremultipliable for Color<u8, 4, CS, Premultiplied> {
    type Output = Color<u8, 4, CS, NotPremultiplied>;

    fn depremultiply(&self) -> Self::Output {
        let alpha = self.alpha();
        if alpha == 255 {
            Color {
                components: self.components,
                _cs: PhantomData,
                _p: PhantomData,
            }
        } else {
            let a = alpha as f64 / 255.0;
            Color {
                components: [
                    (self.red() as f64 / a + 0.5) as u8,
                    (self.green() as f64 / a + 0.5) as u8,
                    (self.blue() as f64 / a + 0.5) as u8,
                    alpha,
                ],
                _cs: PhantomData,
                _p: PhantomData,
            }
        }
    }
}

pub type Rgb = Color<u8, 3>;
pub type Rgba = Color<u8, 4>;
pub type PremulRgba = Color<u8, 4, Srgb , Premultiplied>;

pub type Rgbaf32 = Color<f32, 4>;
pub type PremulRgbaf32 = Color<f32, 4, Srgb , Premultiplied>;

/// Return a*b/255, rounding any fractional bits.
pub fn premultiply_u8(c: u8, a: u8) -> u8 {
    let prod = u32::from(c) * u32::from(a) + 128;
    ((prod + (prod >> 8)) >> 8) as u8
}

#[test]
fn test1() {
    let c = Rgba::new([12, 32, 56, 65]);
    let pc = c.premultiply();

    println!("{:?}", pc);
}

Looks pretty good to me!

You might not need the separate premultiply traits if they are instead to_premultiplied/to_notpremultiplied methods which are just no-ops if it's already in the right representation, that can simplify generic code in some cases but it's a little more code so don't bother if you don't need it.

I would suggest you have separate Rgb and Rgba structures and implement indexing on that; while it can be handy to have indexed access to components, you generally want to be more explicit here rather than less. (and having simple .r .g, etc fields is kind of nice)

Even handwaving image format concerns like R5G6B5, padding BGR ordering etc, which may not matter for your uses; you still want to be able to distinguish from other representations like HSL, Lab and XYZ, which are not just color spaces but alter what you can directly implement.

But again, this depends on what your usage is, it is often extremely obvious that you're only going to have RGB in some context, or you can use the color space to represent that split too, eg require an PrimaryColorSpace impl on the CS parameters of the premultiply implementations.

(also I did a Google in disbelief that we don't have a better name than "de-/not premultiplied" and it seems that "straight" alpha is slightly more "official" at least: but I'm not a fan of that either :disappointed_face:)

one other thing to consider is that you should only do color mixing (which alpha is a form of) in a linear colorspace, sRGB is generally not linear.

2 Likes

True, but that doesn't stop people doing it, and it's at least less bad than trying this in HSL!

It does raise that you'd want some reasonable way to move between color spaces which is always a bit of a pain. I think the general solution is mapping to and from XYZ, which is likely overkill here.

That depends on the purpose. It's correct for representing light intensity, but most graphical software blend "layers" in sRGB due to aesthetics or for legacy reasons. Blending in linear is better for the hues and nonlinear makes lightness look better due to how we perceive it. There are spaces like Oklab that tries to look as good as possible in all dimensions, but they aren't representations of light intensity.

I think your general approach looks nice if the purpose is to be very generic over what the data it represents. Especially if you want to read and write it to a buffer. That's a trade-off compared to structs with a fixed component order. The way I solved that specifically in the palette create was to make a bridge struct that looks quite similar to yours. Just with fewer features.

If anything, you may have to make some implementations depend on a specific color space parameter so you can represent more of them without conflicting implementations. Or delegate more to it via traits. Ideally, the array length would depend on it too, but that's not so simple just yet. All that assuming you ever need need to extend it that much.

And yes, be aware of when linear and nonlinear representations are appropriate.

Working with abstract types through traits can get pretty annoying (you can't use struct fields directly, you can't call methods on integers), so I recommend avoiding them unless you really need to abstract this away.

You could put color space info as an extra more explicit field:

struct Rgb<S> {
   r: u8, 
   ...
   color_space: PhantomData<S>,
}

and implement conversion for Rgb<sRGB> etc.