color-js / color.js

Color conversion & manipulation library by the editors of the CSS Color specifications
https://colorjs.io
MIT License
1.88k stars 82 forks source link

Add Pointer's Gamut Check #317

Open AntonPalmqvist opened 1 year ago

AntonPalmqvist commented 1 year ago

I know this is a bit of a niche use case, as it would be mainly used by CG artists.

Pointer's Gamut is based on a large dataset of colors found in the real world. By checking if a color is inside this gamut, you know as a CG artist that you are staying inside physically correct color values.

Here's a Python library that does this: https://github.com/colour-science/colour/blob/68342cb857c587eb5d5eb1df41e6d2e2666617f8/colour/volume/pointer_gamut.py#L32

Thanks, Anton

LeaVerou commented 1 year ago

Interesting. Right now the API is structured around the idea that there are color spaces, some of which have a restricted gamut. So, to check if a color is in gamut of a given space, you convert to that space and then use inGamut(). If I’m reading this right, it appears there is no color space here, and this is basically a function that operates on XYZ values? (not sure which XYZ, but I haven't looked too carefully).

AntonPalmqvist commented 1 year ago

That's correct!

facelessuser commented 1 year ago

The Pointer’s gamut is (an approximation of) the gamut of real surface colors as can be seen by the human eye, based on the research by Michael R. Pointer (1980). The linked library simply generates a mesh using hard coded points and Delauney's method and checks if a given point is within the mesh (using some threshold). The points come from documented tables, though I'd have to hunt around for the exact source. I don't think it is necessarily trivial to add though unless you depend on an external library to generate the mesh...I guess you could pre-generate mesh, then you just need an algorithm to resolve whether a point is within the mesh. You'd probably need a separate API method inPointersGamut() I imagine.

LeaVerou commented 1 year ago

That would be less trivial than it seems to implement then, as it would require a restructing of the Color.js API itself to support this sort of thing. Unless it's just implemented as an ad-hoc method, and doesn't hook into isGamut(). That would be pretty unfortunate though.

facelessuser commented 1 year ago

I'm sure you could special case isGamut('pointers') so that is probably fine, if you plan to implement this.

Myndex commented 1 year ago

that you are staying inside physically correct color values

Staying within non-luminous, non-florescent surface colors, but it doesn't guarantee that you'll be within a given display space.

If the working space is a display space such as sRGB or P3, by definition you'll always have "physically correct" color values that are physically realizable by the display. The Pointer’s gamut mostly exceeds the physically realizable colors of those display spaces.

If the working space is a space built around imaginary primaries is like ACES or ProPhoto, that's a different story—but arguably, if you're going to clamp to a gamut I'd suggest it's usually best to clamp to your largest realizable destination space.

For reference, the triangle is sRGB/rec709, squiggly line defines Pointer’s gamut. Pointers gamut compared to sRGB

LeaVerou commented 1 year ago

I'm sure you could special case isGamut('pointers') so that is probably fine, if you plan to implement this.

I could, but it would be a code smell. Architecturally, inGamut() should be color space and gamut agnostic, and the logic specifics should live elsewhere. In terms of modularity, we don't want everybody using inGamut() to have to also pull in code for Pointer's Gamut (or any other similar gamut that may arise in the future). Something like Pointer's Gamut would need to be able to live in a separate module, and "hook" into inGamut() through an extension point.

For reference, this is the implementation of inGamut().

One solution would be to convert the space parameter to spaceOrGamut, i.e. it could be a color space, or the name of a custom gamut ("pointers"). Then, have a data structure as a named export:

// inGamut.js
const gamuts = {};
export { gamuts };
// in inGamut(): 
if ( spaceOrGamut in gamuts) {
    return gamuts[spaceOrGamut](color, {epsilon});
}

that other modules could import and add to:

// pointers.js
import { gamuts } from "/path/to/src/space.js";
export default const pointers = gamuts.pointers = function(color, {epsilon} = {}) {
    /* logic here */
}

The downside of this is that a string argument could be either a color space id or a named gamut and you can't tell which one until runtime. Also, in theory they could conflict. Alternatively, these could be separate options (i.e. have a gamut option), but then either you have an optional argument that is not at the end (antipattern), or you have a pointless empty space argument. Or we make a backwards incompatible API change and move the space argument into the options dictionary, since it’s optional so it should have been there in the first place. But if they’re separate options, how do you deal with both being specified? Error, conjunction, or precedence?

As you can see, this is not simple at all! 😃

AntonPalmqvist commented 1 year ago

Thank you all for your input, it's much appreciated!

If the working space is a display space such as sRGB or P3, by definition you'll always have "physically correct" color values that are physically realizable by the display. The Pointer’s gamut mostly exceeds the physically realizable colors of those display spaces.

This illustration (from https://twitter.com/bjornornorn/status/1347633870343200770 ) better shows that even within sRGB there are colors that would be physically implausible. Also, as you mentioned, working with larger gamuts such as ACEScg (which is common in visual effects), being able to check against Pointer's Gamut would be even more relevant. Some more reading about this here: https://chrisbrejon.com/articles/albedo-and-pointers-gamut/

image

facelessuser commented 1 year ago

Pointer gamut data: https://www.rit-mcsl.org/UsefulData/PointerData.xls

facelessuser commented 1 year ago

I imagine it is possible to use the Pointer Data to calculate an appropriate max chroma for a given lightness and hue in LCh (using the specified SC illuminant) for a given color and then compare it against that max chroma. Then you wouldn't need to build a pointer surface with a mesh.

facelessuser commented 1 year ago

Yeah, it seems the easiest approach is to just compare the data within the data's provided model (LCh with the SC illuminant). Though, you could certainly transform the colors into an XYZ surface mesh and compare the points against that.

The data represents the gamut at discrete lightness levels, so you can bisect to find the two closest lightness values and hue values and then interpolate the maximum chroma in that LCh (SC) space for the given color. Obviously, you need to also handle lightness that falls outside the gamut's boundary. No complicated mesh is needed. Resolving what the API looks like is probably the most complicated part.

Red dots fall outside the gamut.

red

orange

Randomly generated points at the same lightness.

random

EDIT: Fix bad lightness in title of images

facelessuser commented 1 year ago

If you wanted to fit a color to the Pointer gamut, you could probably just clamp the lightness and chroma within the LCh (SC) space. This assumes you've already gamut-mapped the color with the current working color space if desired.

fit

EDIT: Corrected title showing wrong lightness

svgeesus commented 1 year ago

Pointer's gamut is an under-estimation of the gamut of physically realizable, non-fluorescent, non-emissive matt surface colors. Oh and for objects where the bidirectional reflectance distribution function (BRDF) is also flat (no dependence on angle of illumination)

I forget the name now but there is another one which is an over-estimate, because it constrains all reflectance spectra to a step function with one transition (so full reflectance for part of the wavelength range and zero reflectance for the rest). That gives a theoretical limit.

So an interesting functionality to have, but unrelated to the color.js gamut checks which are colorspace specific.

facelessuser commented 1 year ago

Yeah, it is definitely not a color-specific gamut, and I came to the same conclusion that it should probably be something separate from the "gamut" check that exists in color.js currently.

Anyways, it is pretty easy to implement. I have the code I used to implement it here (for anyone anxious to port it here): https://github.com/facelessuser/coloraide/blob/main/coloraide/gamut/pointer.py

pointer

eeeps commented 8 months ago

Possibly I should file a separate issue, but this is just to say: it would also be nice to be able to check if a color is in the visible gamut.

@facelessuser from https://github.com/facelessuser/coloraide/issues/333 it sounds like this is doable with a lookup table, as you've done with the Pointer's Gamut, although an algorithmic solution would be more precise?

LeaVerou commented 8 months ago

Possibly I should file a separate issue, but this is just to say: it would also be nice to be able to check if a color is in the visible gamut.

Yes, this would need a separate issue, but I don't think we have sufficiently granular info about the display to be able to tell…

eeeps commented 8 months ago

@LeaVerou filed https://github.com/color-js/color.js/issues/382