Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make it possible to specify a custom white point #14

Closed
Ogeon opened this issue Jan 19, 2016 · 21 comments · Fixed by #56
Closed

Make it possible to specify a custom white point #14

Ogeon opened this issue Jan 19, 2016 · 21 comments · Fixed by #56
Milestone

Comments

@Ogeon
Copy link
Owner

Ogeon commented Jan 19, 2016

Some color conversions depends on a white point, and Palette is currently making some assumptions in this area. Avoiding these assumptions would of course allow a lot more freedom, so it would be nice to find a good way to customize which white point to use when converting. Even better if it was encoded into the color types, themselves, when relevant.

@Ogeon
Copy link
Owner Author

Ogeon commented Jan 19, 2016

@sidred has some experiments going on for this, so I'm currently saving this for him.

@sidred
Copy link
Contributor

sidred commented Jan 31, 2016

Here is a rough working version of what I have so far.

Use a single color struct with phantom types for color space and white point and define a few common color spaces.

#[derive(Clone, Copy, PartialEq, PartialOrd)]
pub struct Color<CS, WP, T>
    where T: Float,
          CS: ColorSpace,
          WP: WhitePoint<T>
{
    param_1: T,
    param_2: T,
    param_3: T,
    _color_space: PhantomData<CS>,
    _white_point: PhantomData<WP>,
}

pub type Yxy<T> = Color<YxySpace, NoWhitePoint, T>;
pub type Xyz<T> = Color<XyzSpace, NoWhitePoint, T>;
pub type Lab<T> = Color<LabSpace, D65, T>;
pub type Rgb<T> = Color<RgbSpace, D65, T>;
pub type Srgb<T> = Color<SrgbSpace, D65, T>;
pub type AdobeRgb<T> = Color<AdobeRgbSpace, D50, T>;

Color spaces are defined by an empty trait and empty struct variants. RgbVariants need the red, blue and green points as part of the trait to help in conversions

pub trait ColorSpace {}
pub trait RgbVariants {
    fn get_red_blue_green();
}


pub struct RgbSpace;
impl ColorSpace for RgbSpace {}
impl RgbVariants for RgbSpace {}

pub struct SrgbSpace;
impl ColorSpace for SrgbSpace {}
impl RgbVariants for SrgbSpace {}

pub struct YxySpace;
impl ColorSpace for YxySpace {}

pub struct XyzSpace;
impl ColorSpace for XyzSpace {}

.... etc

The WhitePoint trait returns the Yxy values for the white point (for conversion use) and implemented for empty structs

pub trait WhitePoint<T:Float> {
    fn get_yxy() -> Color<YxySpace, NoWhitePoint, T>;
}


macro_rules! generate_white_point {
    ($x: ident => ($p1: expr, $p2:expr, $p3:expr)) => (
        impl<T:Float> WhitePoint<T> for $x {
            fn get_yxy() -> Color<YxySpace, NoWhitePoint, T> {
                Color::new(flt!($p1), flt!($p2), flt!($p3))
            }
        }
    );
}

pub struct NoWhitePoint;
generate_white_point!(NoWhitePoint => (1.0, 1.0, 1.0));

pub struct D65;
pub struct D65Fov10;
generate_white_point!(D65 => (0.31271,0.32902, 1.0));
generate_white_point!(D65Fov10 => (0.34773, 0.35952, 1.0));

etc

So all colors will just be traits implementing getters and setters and the rest can be implemented by default (like add, substract, mix etc)

pub trait RgbColor<T: Float> {
    fn r(&self) -> T;
    fn g(&self) -> T;
    fn b(&self) -> T;
    fn set_r(&mut self, T);
    fn set_g(&mut self, T);
    fn set_b(&mut self, T);

    fn new(T, T, T) -> Color<RgbSpace, D65, T>;

    fn get_tuple(&self) -> (T, T, T) {
        (self.r(), self.g(), self.b())
    }

    fn set_tuple(&mut self, p1: T, p2: T, p3: T) {
        self.set_r(p1);
        self.set_g(p2);
        self.set_b(p3);
    }
}



impl<CS, WP, U> RgbColor<U> for Color<CS, WP, U>
    where CS: ColorSpace + RgbVariants,
          U: Float,
          WP: WhitePoint<U>
{
    fn r(&self) -> U {
        self.param_1
    }
    fn g(&self) -> U {
        self.param_2
    }
    fn b(&self) -> U {
        self.param_3
    }
    fn set_r(&mut self, val: U) {
        self.param_1 = val;
    }
    fn set_g(&mut self, val: U) {
        self.param_2 = val;
    }
    fn set_b(&mut self, val: U) {
        self.param_3 = val;
    }

    fn new(p1: U, p2: U, p3: U) -> Color<RgbSpace, D65, U> {
        Color::new(p1, p2, p3)
    }
}

Here is how the conversions would work xyz -> lab (whitepoint aware)

pub trait XyzConversion<T>
where T: Float,

{
    fn from_xyz(Xyz<T>) -> Self;
    fn to_xyz(&self) -> Xyz<T>;
}



impl<T, WP> XyzConversion<T> for Color<LabSpace, WP, T>
    where T: Float,
          WP: WhitePoint<T>
{
    fn from_xyz(input_xyz: Xyz<T>) -> Self {
        let (xref, yref, zref) = (input_xyz / (WP::get_yxy()).to_xyz()).get_tuple();

        let convert = |c: T| -> T {
            let epsilon: T = flt!(26.0 / 116.0);
            let kappa: T = flt!(841.0 / 108.0);
            let delta: T = flt!(16.0 / 116.0);
            if c > epsilon {
                c.powf(flt!(1.0 / 3.0))
            } else {
                (kappa * c) + delta
            }
        };

        Color::new(convert(xref), convert(yref), convert(zref))
    }

    fn to_xyz(&self) -> Xyz<T> {
        let (inp_l, inp_a, inp_b) = self.get_tuple();
        let wp = WP::get_yxy();
        let wp_xyz = wp.to_xyz();
        let (x_wp, y_wp, z_wp) = wp_xyz.get_tuple();

        let y = (inp_l + flt!(16.0)) / flt!(116.0);
        let x = (inp_a / flt!(500.0)) + y;
        let z = y - (inp_b / flt!(200.0));

        let convert = |c: T| -> T {
            let epsilon = flt!(26.0 / 116.0);
            let kappa = flt!(841.0 / 108.0);
            let delta = flt!(16.0 / 116.0);
            if c > epsilon {
                c.powi(3)
            } else {
                (c - delta) * kappa
            }
        };

        let (xref, yref, zref) = (convert(x), convert(y), convert(z));

        Color::new(xref * x_wp, yref * y_wp, zref * z_wp)
    }
}

The end user usage will be

    let c1 = Lab::new(39.37, 15.98 , 18.28 );
    println!("{:?} {:?} {:?}", c1.l(), c1.a(), c1.b());
    let c2 = c1.to_xyz();
    println!("{:?} {:?} {:?}", c2.x(), c2.y(), c2.z());

Some advantages of this are:

  • Less boileplate for multiple structs (add, multiply etc.)
  • Conversions are simpler as the all the information is encoded in the traits
  • Multiple rgb variants (adobe rgb, srgb ) can be easily supported by just defining the red, blue and green points for the each of the variants and rest is automatically derived on the RGBColor trait

To-Do / Questions:

  • Rgb conversions via bradford transform Color<SrgbSpace,D50> -> Color<SrgbSpace,D65>. Needs some more work to implement.
  • How to handle HSL and CMYK color - are these dependent on the RGB color spaces, i.e. how to handle conversions between different rgb variants and HSL/CMYK
  • The white points and color spaces are empty structs. Is it possible to use enum variants?
  • Support alpha. I guess we can just use the current alpha trait approach.

@sidred
Copy link
Contributor

sidred commented Jan 31, 2016

link to a proof of concept repo - https://github.com/sidred/color_space

@Ogeon
Copy link
Owner Author

Ogeon commented Jan 31, 2016

I'm not sure this level of generalization is the way to go. Some things I'm a bit concerned by from the start:

  • What about color spaces with more or less than three components, like CMYK or simple gray? Adapting to the largest type will result in a bunch of unused memory, like in the alpha case.
  • What about those where one of the components have a different type, like the hues in HSV, HSL, etc.?
  • Relying on just constructors, getters and setters may have an ergonomic impact. It will, for example, not be possible to make const/static colors.

I can see the advantages, but I can't yet say if it's really worth making such a drastic change this early. I mean, it has only been downloaded 53 times from crates.io and I have barely seen how it's used by the users. We can, of course, cherry pick the parts that are applicable today, like the white point phantom type for the spaces where it matters.

Regarding you to-do/questions:

  • That involves a bit of matrix fun, so we may want to make use of pre-calculated ones to save computing time, but they are many.
  • Those are a bit tricky. Especially CMY/CMYK, since they are actually dependent on printer's ink. HSV is easier, since it's only defined as cylindrical RGB, as far as I know.
  • Enum variants are not empty, will not be perceived as distinct types and will not be extensible by the users. The second property would prevent From/Into conversion between white points. It's possible, but the question is if it's appropriate.
  • The current alpha wrapper fits just about anything, so that's no problem, I think.

@sidred
Copy link
Contributor

sidred commented Jan 31, 2016

I get your concerns regarding the ergonomics and I am not sure of the best way to proceed. Encoding the color space and white point seems specially useful for the rgb variants. It makes the conversions a lot simpler and we can support a lot more variants easily.

  • Yeah, CMYK and Grayscale will require separate structs. We cannot use the Color struct.
  • For Hues, the getters and setters can handle the cylindrical degrees transformation.
  • const is not possible but static can be generated using lazy_static crate
  • I guess my question regarding the HSV/HSL space was lets say we convert Srgb to hsl and adobe rgb to hsl. Are these 2 hsl's the same or are they defined by the rgb space as well. I don't think Srgb -> HSL -> AdobeRgb makes sense. It needs to be Srgb -> HSL -> Srgb -> AdobeRgb. Do we just let the end user handle this, otherwise may get too complicated.
  • For the transformation matrices, we can copy the table or generate them using lazy_static, what ever is simpler.
  • Pretty much every color space seems to use the white point(not sure about hsl/hsv and cmyk). Even Xyz uses the white point in the bradford transforms.

@Ogeon
Copy link
Owner Author

Ogeon commented Jan 31, 2016

Encoding the color space and white point seems specially useful for the rgb variants. It makes the conversions a lot simpler and we can support a lot more variants easily.

I guess we would end up in more or less the same situation as with the Alpha type, namely the inability to implement From<Alpha<A, T>> for Alpha<B, T> where B: From<A>. That forced specific implementation for every possible A and B. Not too different from what we have now. This library will be so much nicer when specialization lands.

Yeah, CMYK and Grayscale will require separate structs. We cannot use the Color struct.

That's a bit of a problem...

For Hues, the getters and setters can handle the cylindrical degrees transformation.

Fair point, in that case.

const is not possible but static can be generated using lazy_static crate

Yea, it's something, but comes at a runtime cost. We would also need *_mut() getters to make mutable references possible, by the way.

I guess my question regarding the HSV/HSL space was lets say we convert Srgb to hsl and adobe rgb to hsl. Are these 2 hsl's the same or are they defined by the rgb space as well. I don't think Srgb -> HSL -> AdobeRgb makes sense. It needs to be Srgb -> HSL -> Srgb -> AdobeRgb. Do we just let the end user handle this, otherwise may get too complicated.

The primaries for sRGB and AdobeRGB are different, so the conversion is not that trivial. AdobeRGB would need its own HSL/HSV types if it's supposed to be compile time checked.

For the transformation matrices, we can copy the table or generate them using lazy_static, what ever is simpler.

We'll see how that ends up. I'm more geared towards having them encoded into the library as constants, to avoid runtime costs. A build script can also be used to generate them.

Pretty much every color space seems to use the white point(not sure about hsl/hsv and cmyk). Even Xyz uses the white point in the bradford transforms.

Yes, the point of the white point is to have a reference that says what "white" is. Having a color without a white point is not very useful. You could say that it's currently fixed to D65 with the 2 degree observer, but making it parametric will be quite useful and I'm all for that. The types in the current design that would need a white point parameter is XYZ, the future xyY, Lab, Lch and perhaps Luma, since none of those have it built into their specifications. sRGB uses D65 and that affects HSV and HSL, as well. I'm currently ignoring CMYK, since it's a special kind of crazy, but the naive variant is affected in the same way as HSL and HSV.

The way I see it is that we are actually discussing two separable problems here:

  1. How to define and structure the color types.
  2. How to implement variable white points + chromatic adaption.

I'm still skeptic towards your proposal for the first problem, since I think it's a bit early to know if it will be too limiting for the user, but I think the way you have implemented variable white points has a very good potential. I'm suggesting that we isolate the white point part and try to make it work in the big picture. We can put the other proposal in a separate issue to keep this discussion focused on white points. What do you think about that?

@sidred
Copy link
Contributor

sidred commented Jan 31, 2016

So implement Rgb<WP,T>, Srgb<WP,T>, Xyz<WP,T> etc? This implies ignoring all other rgb variants ( excpet srgb and adobergb ) listed in http://www.brucelindbloom.com/Eqn_RGB_XYZ_Matrix.html for now.

I was wrong earlier. Xyz and Yxy are white point independent. The chomatic adaptation formulas need the xyz value of the actual whitepoints for conversions. This is what I was referring to earlier.

@Ogeon
Copy link
Owner Author

Ogeon commented Jan 31, 2016

Srgb can stay as fixed D65, since that's how it's defined. We could also wait with Rgb, since it's currently defined as linear sRGB. We can deal with different kinds of RGB in a later iteration, and start with the CIE types. The RGB problem is a bit more complex than just different white points, since we have to take the primaries into account. Some of those, in that list, are very obscure and I doubt there will be an immediate need for them. AdobeRGB seems to be the most interesting one, since it's fairly common in photography, but I'm not sure about the others.

Yes, the adaption will have to be performed in XYZ space. The matrices in my link has the white points encoded into them, so it's just a matter of combining type pair and matrix. I guess D65 can be used as the default, since that's what the common modern spaces seem to be using, so Xyz::new(...) would normally result in Xyz<f32, D65> (or however the type parameters are ordered).

@sidred
Copy link
Contributor

sidred commented Jan 31, 2016

All the ICC color profiles use D50 as the reference. So sRGB with D50 might also be fairly common even though D65 is the defined white point.

There is actually no XYZ and XYZ. The white points value are defined in the XYZ/Yxy space. These color spaces do not have a concept of white points.

@Ogeon
Copy link
Owner Author

Ogeon commented Jan 31, 2016

All the ICC color profiles use D50 as the reference. So sRGB with D50 might also be fairly common even though D65 is the defined white point.

Sure, they are, but I'm still sure we can wait with that part until the basics are in. I feel like that's a deeper hole than what it looks like from above, and I'm trying to slice all of this down to bite size problems.

There is actually no XYZ and XYZ. The white points value are defined in the XYZ/Yxy space. These color spaces do not have a concept of white points.

They will still have to remember the white point to make conversions like sRGB -> XYZ -> Lab -> XYZ -> sRGB sane. The white point information will otherwise be lost in the sRGB -> XYZ step.

@Ogeon
Copy link
Owner Author

Ogeon commented Jan 31, 2016

I'm nominating this for version 0.3.0

@Ogeon Ogeon added this to the 0.3.0 milestone Jan 31, 2016
@sidred
Copy link
Contributor

sidred commented Feb 1, 2016

So lets focus on the CIE types for now.

So we will have

LabSpace<WP,T> {
 l:T,
 a:T,
 b:T,
_wp: WP
}

const Lab<T> = LabSpace<D65,T>

and do that for each of the types Xyx, Yxy, Lch . Does that make sense?

I am not convinced in having a white point for Xyz and Yxy.

  • AnyColor -> Xyz depends on the source white point.
  • Xyz -> AnyColor depends on the target white point.

So in your example does it matter if the intermediate lab's are using d50 or d65 white points? All that matters is that the sRGB in the end has the same white point as the source sRGB, white can be specified in the type for the output.

@Ogeon
Copy link
Owner Author

Ogeon commented Feb 1, 2016

Lab is normalized using the white point, so it matters a lot. I think it's also nice to get compile time errors when you are trying to use D50 adapted colors as if they were D65.

@sidred
Copy link
Contributor

sidred commented Feb 1, 2016

We can add a note stating the white points for Xyz and Yzy are for helping in conversion and not really relevant on these color spaces.

Do we still keep a NoWhitePoint type for the WhitePoint trait or just use D65 as the default for Xyz and Yxy? I think its nice to have the NoWhitePoint type we define the default white point x and y coordinates. I makes things clearer.

@Ogeon
Copy link
Owner Author

Ogeon commented Feb 1, 2016

What's white in NoWhitePoint? Would it be whatever it's converted to or is it the same as the unit spectrum (x: 0.3, y: 0.3)?

@sidred
Copy link
Contributor

sidred commented Feb 1, 2016

I haven't come across the default white point values. What is your source for the 0.3 values?

@Ogeon
Copy link
Owner Author

Ogeon commented Feb 1, 2016

Sorry, 1/3, not 0.3.

Its spectral power distribution is flat, giving the same power per unit wavelength at any wavelength. In terms of both the 1931 and 1964 CIE XYZ color spaces, its color coordinates are [k, k, k], where k is a constant, and its chromaticity coordinates are [x, y] = [1/3, 1/3]

Over at https://en.wikipedia.org/wiki/White_point. I haven't yet checked other sources, but it looks like that's how XYZ is designed.

@sidred
Copy link
Contributor

sidred commented Feb 1, 2016

Yeah look like the white point for Illiminant E. Using Yxy = (1, 0.3, 0.3) gives XYZ as (1,1,1) and that will avoid any scaling in XYZ <-> Lab conversions

@Ogeon
Copy link
Owner Author

Ogeon commented Feb 1, 2016

Exactly. The concept of no white point feels a bit strange, but thinking about it makes me think that it's just signals without interpretation. Conversion to and from it would not cause any chromatic adaption, since there is nothing to adapt to, right?

@sidred
Copy link
Contributor

sidred commented Feb 2, 2016

looks like the conversion will cause chromatic adaptation. Look at the matrices for white point E at http://www.brucelindbloom.com/Eqn_ChromAdapt.html. None of them are identity matrices.

I'll start with the NoWhitePoint (1/3, 1/3 ,1.0) and lets see if it makes sense.

@Ogeon
Copy link
Owner Author

Ogeon commented Feb 2, 2016

That's why I thought it made little sense. You can pretend that a D50 color is an E color, but they wouldn't look the same. I would, by the way, suggest calling it E, since that's what it is.

@homu homu closed this as completed in #56 Mar 5, 2016
homu added a commit that referenced this issue Mar 5, 2016
Make color spaces white point aware

This is a breaking change.

- All colors now have an additional trait bound on WhitePoint.
- All "new" methods on colors now assume a default white point of D65. Use "with_wp" method to get color with another white point.
- Delete tristimulus module.
- Add Xyz values for standard observers and illuminants (D65, D50 etc).
- Implement methods for chromatic adaptation using Bradford, VonKries and Xyz scaling.
- Add Srgb primaries for Rgb <-> Xyz converison.
- Make Rgb to luma conversion to convert via xyz.

closes #14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants