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

Add color clipping #6

Merged
merged 4 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions color/src/colorspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ pub trait ColorSpace: Clone + Copy + 'static {
TargetCS::from_linear_srgb(lin_rgb)
}
}

/// Clip the color's components to fit within the natural gamut of the color space.
///
/// There are many possible ways to map colors outside of a color space's gamut to colors
/// inside the gamut. Some methods are perceptually better than others (for example, preserving
/// the mapped color's hue is usually preferred over preserving saturation). This method will
/// generally do the mathematically simplest thing, namely clamping the individual color
/// components' values to the color space's natural limits of those components, bringing
/// out-of-gamut colors just onto the gamut boundary. The resultant color may be perceptually
/// quite distinct from the original color.
///
/// # Examples
///
/// ```rust
/// use color::{ColorSpace, Srgb, XyzD65};
///
/// assert_eq!(Srgb::clip([0.4, -0.2, 1.2]), [0.4, 0., 1.]);
/// assert_eq!(XyzD65::clip([0.4, -0.2, 1.2]), [0.4, -0.2, 1.2]);
/// ```
fn clip(src: [f32; 3]) -> [f32; 3];
}

/// The layout of a color space, particularly the hue channel.
Expand Down Expand Up @@ -139,6 +159,10 @@ impl ColorSpace for LinearSrgb {
];
matmul(&OKLAB_LMS_TO_SRGB, lms_scaled.map(|x| x * x * x))
}

fn clip([r, g, b]: [f32; 3]) -> [f32; 3] {
[r.clamp(0., 1.), g.clamp(0., 1.), b.clamp(0., 1.)]
}
}

// It might be a better idea to write custom debug impls for AlphaColor and friends
Expand Down Expand Up @@ -171,6 +195,10 @@ impl ColorSpace for Srgb {
fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] {
src.map(lin_to_srgb)
}

fn clip([r, g, b]: [f32; 3]) -> [f32; 3] {
[r.clamp(0., 1.), g.clamp(0., 1.), b.clamp(0., 1.)]
}
}

#[derive(Clone, Copy, Debug)]
Expand All @@ -196,6 +224,10 @@ impl ColorSpace for DisplayP3 {
];
matmul(&LINEAR_SRGB_TO_DISPLAYP3, src).map(lin_to_srgb)
}

fn clip([r, g, b]: [f32; 3]) -> [f32; 3] {
[r.clamp(0., 1.), g.clamp(0., 1.), b.clamp(0., 1.)]
}
}

#[derive(Clone, Copy, Debug)]
Expand Down Expand Up @@ -223,6 +255,10 @@ impl ColorSpace for XyzD65 {
];
matmul(&LINEAR_SRGB_TO_XYZ, src)
}

fn clip([x, y, z]: [f32; 3]) -> [f32; 3] {
[x, y, z]
}
}

#[derive(Clone, Copy, Debug)]
Expand Down Expand Up @@ -282,6 +318,10 @@ impl ColorSpace for Oklab {
TargetCS::from_linear_srgb(lin_rgb)
}
}

fn clip([l, a, b]: [f32; 3]) -> [f32; 3] {
[l.clamp(0., 1.), a, b]
}
}

/// Rectangular to cylindrical conversion.
Expand Down Expand Up @@ -332,4 +372,8 @@ impl ColorSpace for Oklch {
TargetCS::from_linear_srgb(lin_rgb)
}
}

fn clip([l, c, h]: [f32; 3]) -> [f32; 3] {
[l.clamp(0., 1.), c.max(0.), h]
}
}
16 changes: 16 additions & 0 deletions color/src/css.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ impl CssColor {
}
}

/// Clip the color's components to fit within the natural gamut of the color space, and clamp
/// the color's alpha to be in the range `[0, 1]`.
///
/// See [`ColorSpace::clip`] for more details.
#[must_use]
pub fn clip(self) -> Self {
let (opaque, alpha) = split_alpha(self.components);
let components = self.cs.clip(opaque);
let alpha = alpha.clamp(0., 1.);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be meaningful to have a separate clip_alpha method? Semantically, a less than zero alpha is definitely equivalent to zero, and likely similarly for greater than 1?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe; would clip leave alpha unchanged in that case?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so? The other thing to think about is clipping the alpha for premultiplied

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that makes a good case to add it as a convenience method. Clipping unconditionally to premultiply would be a performance hit (plus premul math still works out when out-of-bounds, if alpha is interpreted as an arbitrary weighting).

Self {
cs: self.cs,
missing: self.missing,
components: add_alpha(components, alpha),
}
}

fn premultiply_split(self) -> ([f32; 3], f32) {
// Reference: §12.3 of Color 4 spec
let (opaque, alpha) = split_alpha(self.components);
Expand Down
15 changes: 15 additions & 0 deletions color/src/tagged.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,21 @@ impl ColorSpaceTag {
}
}
}

/// Clip the color's components to fit within the natural gamut of the color space.
///
/// See [`ColorSpace::clip`] for more details.
pub fn clip(self, src: [f32; 3]) -> [f32; 3] {
match self {
Self::Srgb => Srgb::clip(src),
Self::LinearSrgb => LinearSrgb::clip(src),
Self::Oklab => Oklab::clip(src),
Self::Oklch => Oklch::clip(src),
Self::DisplayP3 => DisplayP3::clip(src),
Self::XyzD65 => XyzD65::clip(src),
_ => todo!(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't ideal, but I think this is fine. The expectation is that all three todo!s in this file would be fixed as a block

Copy link
Member Author

@tomcur tomcur Nov 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some tags are not yet implemented. To move the concerns (and code duplication) to a single place, perhaps a private match-like macro that takes a ColorSpace method invocation makes sense for the ergonomics. ColorSpace is not currently object-safe so we can't go through a &dyn ColorSpace.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Alternatively, we deal with this when more color spaces are implemented and accept the code duplication.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will probably end up with a macro, but keep in mind some methods do really want custom logic; convert is the big one.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect for the amount of things, a macro is probably not necessary. But we can discuss that when the last implementations land.

}
}
}

impl TaggedColor {
Expand Down