diff --git a/coloraide/everything.py b/coloraide/everything.py index a3992fe5a..5ddd2bcff 100644 --- a/coloraide/everything.py +++ b/coloraide/everything.py @@ -32,6 +32,8 @@ from .spaces.cam16_ucs import CAM16UCS, CAM16LCD, CAM16SCD from .spaces.hellwig import HellwigJMh, HellwigHKJMh from .spaces.zcam import ZCAMJMh +from .spaces.scam import sCAMJMh +from .spaces.sucs import sUCS from .spaces.hct import HCT from .spaces.ucs import UCS from .spaces.rec709 import Rec709 @@ -107,6 +109,8 @@ class ColorAll(Base): ZCAMJMh(), Rec2020OETF(), Msh(), + sCAMJMh(), + sUCS(), # Delta E DE99o(), diff --git a/coloraide/spaces/cam16.py b/coloraide/spaces/cam16.py index 41c453279..273d1e2a0 100644 --- a/coloraide/spaces/cam16.py +++ b/coloraide/spaces/cam16.py @@ -48,7 +48,7 @@ } -def hue_quadrature(h: float) -> float: +def hue_quadrature(h: float, hquad: dict[str, tuple[float, float, float, float, float]] = HUE_QUADRATURE) -> float: """ Hue to hue quadrature. @@ -56,13 +56,13 @@ def hue_quadrature(h: float) -> float: """ hp = util.constrain_hue(h) - if hp <= HUE_QUADRATURE['h'][0]: + if hp <= hquad['h'][0]: hp += 360 - i = bisect.bisect_left(HUE_QUADRATURE['h'], hp) - 1 - hi, hii = HUE_QUADRATURE['h'][i:i + 2] - ei, eii = HUE_QUADRATURE['e'][i:i + 2] - Hi = HUE_QUADRATURE['H'][i] + i = bisect.bisect_left(hquad['h'], hp) - 1 + hi, hii = hquad['h'][i:i + 2] + ei, eii = hquad['e'][i:i + 2] + Hi = hquad['H'][i] t = (hp - hi) / ei return Hi + (100 * t) / (t + (hii - hp) / eii) @@ -74,14 +74,14 @@ def eccentricity(h: float) -> float: return 0.25 * (math.cos(h + 2) + 3.8) -def inv_hue_quadrature(H: float) -> float: +def inv_hue_quadrature(H: float, hquad: dict[str, tuple[float, float, float, float, float]] = HUE_QUADRATURE) -> float: """Hue quadrature to hue.""" Hp = (H % 400 + 400) % 400 i = math.floor(0.01 * Hp) Hp = Hp % 100 - hi, hii = HUE_QUADRATURE['h'][i:i + 2] - ei, eii = HUE_QUADRATURE['e'][i:i + 2] + hi, hii = hquad['h'][i:i + 2] + ei, eii = hquad['e'][i:i + 2] return util.constrain_hue((Hp * (eii * hi - ei * hii) - 100 * hi * eii) / (Hp * (eii - ei) - 100 * eii)) diff --git a/coloraide/spaces/scam.py b/coloraide/spaces/scam.py new file mode 100644 index 000000000..7c8484996 --- /dev/null +++ b/coloraide/spaces/scam.py @@ -0,0 +1,321 @@ +""" +Simple Color Appearance Model (sCAM). + +https://opg.optica.org/oe/fulltext.cfm?uri=oe-32-3-3100&id=545619 +""" +from __future__ import annotations +import math +from .. import util +from .. import algebra as alg +from .lch import LCh +from ..channels import Channel, FLG_ANGLE +from .cam16 import M16, M16_INV, hue_quadrature, inv_hue_quadrature +from .sucs import xyz_to_sucs, sucs_to_xyz +from ..cat import WHITES +from ..types import Vector, VectorLike + +SURROUND = { + 'dark': (0.39, 0.85), + 'dim': (0.5, 0.95), + 'average': (0.52, 1) +} + +HUE_QUADRATURE = { + # Red, Yellow, Green, Blue, Red + "h": (15.6, 80.3, 157.8, 219.7, 376.6), + "e": (0.7, 0.6, 1.2, 0.9, 0.7), + "H": (0.0, 100.0, 200.0, 300.0, 400.0) +} + + +def eccentricity(h: float) -> float: + """Calculate eccentricity.""" + + return 1 + 0.06 * math.cos(math.radians(110 + h)) + + +def adapt(xyz: Vector, xyz_ws: Vector, xyz_wd: Vector, d: float) -> Vector: + """ + Adapt using CAT16 matrix but using CAM02 degree of adaptation. + + This was proposed by one of the authors Li, Molin in the Colour project: + https://github.com/colour-science/colour/pull/1349#issuecomment-3058339414 + """ + + lms = alg.matmul_x3(M16, xyz, dims=alg.D2_D1) + lms_ws = alg.matmul_x3(M16, xyz_ws, dims=alg.D2_D1) + lms_wd = alg.matmul_x3(M16, xyz_wd, dims=alg.D2_D1) + + y_ratio = xyz_ws[1] / xyz_wd[1] + lms_r = alg.divide_x3(lms_wd, lms_ws, dims=alg.D1) + lms_a = [lms[r] * (d * y_ratio * lms_r[r] + (1 - d)) for r in range(3)] + return alg.matmul_x3(M16_INV, lms_a, dims=alg.D2_D1) + + +class Environment: + """ + Class to calculate and contain any required environmental data (viewing conditions included). + + Usage Guidelines for CIECAM97s (Nathan Moroney) + https://www.researchgate.net/publication/220865484_Usage_guidelines_for_CIECAM97s + + `white`: This is the (x, y) chromaticity points for the white point. This should be the same + value as set in the color class `WHITE` value. + + `adapting_luminance`: This is the luminance of the adapting field. The units are in cd/m2. + The equation is `L = (E * R) / π`, where `E` is the illuminance in lux, `R` is the reflectance, + and `L` is the luminance. If we assume a perfectly reflecting diffuser, `R` is assumed as 1. + For the "gray world" assumption, we must also divide by 5 (or multiply by 0.2 - 20%). + This results in `La = E / π * 0.2`. You can also ignore this gray world assumption converting + lux directly to nits (cd/m2) `lux / π`. + + `background_luminance`: The background is the region immediately surrounding the stimulus and + for images is the neighboring portion of the image. Generally, this value is set to a value of 20. + This implicitly assumes a gray world assumption. + + `surround`: The surround is categorical and is defined based on the relationship between the relative + luminance of the surround and the luminance of the scene or image white. While there are 4 defined + surrounds, usually just `average`, `dim`, and `dark` are used. + + Dark | 0% | Viewing film projected in a dark room + Dim | 0% to 20% | Viewing television + Average | > 20% | Viewing surface colors + + `discounting`: Whether we are discounting the illuminance. Done when eye is assumed to be fully adapted. + """ + + def __init__( + self, + *, + white: VectorLike, + adapting_luminance: float, + background_luminance: float, + surround: str, + discounting: bool + ): + """ + Initialize environmental viewing conditions. + + Using the specified viewing conditions, and general environmental data, + initialize anything that we can ahead of time to speed up the process. + """ + + self.discounting = discounting + self.ref_white = util.xy_to_xyz(white) + self.surround = surround + + # The average luminance of the environment in `cd/m^2cd/m` (a.k.a. nits) + self.la = adapting_luminance + # The relative luminance of the nearby background + self.yb = background_luminance + # Absolute luminance of the reference white. + self.input_white = util.scale100(self.ref_white) + self.yw = self.input_white[1] + # Destination luminance + self.output_white = alg.multiply_x3( + util.xy_to_xyz(WHITES['2deg']['D65']), + (self.la * 100) / self.yb, + dims=alg.D1_SC + ) + + # Surround: dark, dim, and average + self.c, self.fm = SURROUND[self.surround] + + self.fl = 0.1710 * (self.la ** (1 / 3)) * (1 / (1 - 0.4934 * math.exp(-0.9934 * self.la))) + self.n = self.yb / self.yw + self.z = 1.48 + math.sqrt(self.n) + self.cz = self.c * self.z + + # Factor of luminance level adaptation + self.d = alg.clamp(self.fm * (1 - 1 / 3.6 * math.exp((-self.la - 42) / 92)), 0, 1) if not discounting else 1 + + +def scam_to_xyz( + J: float | None = None, + C: float | None = None, + h: float | None = None, + Q: float | None = None, + M: float | None = None, + D: float | None = None, + V: float | None = None, + W: float | None = None, + K: float | None = None, + H: float | None = None, + env: Environment | None = None +) -> Vector: + """ + From sCAM to XYZ. + + Reverse calculation can actually be obtained from a small subset of the sCAM components + Really, only one suitable value is needed for each type of attribute: (lightness/brightness), + (chroma/colorfulness/depth/vividness/whiteness/blackness), (hue/hue quadrature). If more than one for a given + category is given, we will fail as we have no idea which is the right one to use. Also, + if none are given, we must fail as well as there is nothing to calculate with. + """ + + # These check ensure one, and only one attribute for a given category is provided. + if not ((J is not None) ^ (Q is not None)): + raise ValueError("Conversion requires one and only one: 'J' or 'Q'") + + if not ((C is not None) ^ (M is not None) ^ (D is not None) ^ (V is not None) ^ (W is not None) ^ (K is not None)): + raise ValueError("Conversion requires one and only one: 'C', 'M', 'D', 'V', 'W', 'K'") + + # Hue is absolutely required + if not ((h is not None) ^ (H is not None)): + raise ValueError("Conversion requires one and only one: 'h' or 'H'") + + # We need viewing conditions + if env is None: + raise ValueError("No viewing conditions/environment provided") + + # Calculate hue + if h is not None: + h = h % 360 + elif H is not None: + h = inv_hue_quadrature(H, HUE_QUADRATURE) + + # Calculate `I` from one of the lightness derived coordinates. + Ia = 0.0 + if J is not None: + Ia = J + elif Q is not None: + Ia = Q / ((2 * (env.fl ** 0.1)) / env.c) + I = alg.nth_root(Ia * 0.01, env.cz) * 100 + + # Calculate the chroma component + if W is not None: + D = 100 - W + elif K is not None: + V = 100 - K + if D is not None: + C = alg.nth_root(((D / 1.3) ** 2 - (100 - Ia) ** 2) / 1.6, 2) + elif V is not None: + C = alg.nth_root((V ** 2 - Ia ** 2) / 3, 2) + elif M is not None: + et = eccentricity(h) # type: ignore[arg-type] + C = M * alg.spow(Ia, 0.27) / ((env.fl ** 0.1) * et * env.fm) + + # Convert to XYZ from sUCS + xyz = sucs_to_xyz([I, C, h]) # type: ignore[list-item] + + # Apply chromatic adaptation + return adapt(xyz, env.output_white, env.input_white, env.d) + + +def xyz_to_scam(xyz: Vector, env: Environment, calc_hue_quadrature: bool = False) -> Vector: + """From XYZ to sCAM.""" + + # Apply chromatic adaptation + xyz = adapt(xyz, env.input_white, env.output_white, env.d) + + # Convert from XYZ to sUCS + I, C, h = xyz_to_sucs(xyz) + + # Eccentricity + et = eccentricity(h) + + # Lightness + Ia = 100 * alg.spow(I * 0.01, env.cz) + + # Brightness + Q = Ia * ((2 * (env.fl ** 0.1)) / env.c) + + # Colorfulness + M = (C * (env.fl ** 0.1) * et) * alg.zdiv(1, alg.spow(Ia, 0.27), 0.0) * env.fm + + # Depth + D = 1.3 * math.sqrt((100 - Ia) ** 2 + 1.6 * C ** 2) + + # Vividness + V = math.sqrt(Ia ** 2 + 3 * C ** 2) + + # Whiteness + W = 100 - D + + # Blackness + K = 100 - V + + # Hue quadrature if required + H = hue_quadrature(h, HUE_QUADRATURE) if calc_hue_quadrature else alg.NaN + + return [Ia, C, h, Q, M, D, V, W, K, H] + + +def xyz_to_scam_jmh(xyz: Vector, env: Environment) -> Vector: + """XYZ to sCAM JMh.""" + + scam = xyz_to_scam(xyz, env) + return [scam[0], scam[4], scam[2]] + + +def scam_jmh_to_xyz(jmh: Vector, env: Environment) -> Vector: + """Convert sCAM JMh to XYZ.""" + + J, M, h = jmh + return scam_to_xyz(J=J, M=M, h=h, env=env) + + +class sCAMJMh(LCh): + """sCAM class (JMh).""" + + BASE = "xyz-d65" + NAME = "scam-jmh" + SERIALIZE = ("--scam-jmh",) + CHANNEL_ALIASES = { + "lightness": "j", + "colorfulness": 'm', + "hue": 'h' + } + WHITE = WHITES['2deg']['D65'] + # Assuming sRGB which has a lux of 64: `((E * R) / PI) / 5` where `R = 1`. + ENV = Environment( + # Our white point. + white=WHITE, + # Assuming sRGB which has a lux of 64: `((E * R) / PI)` where `R = 1`. + # Divided by 5 (or multiplied by 20%) assuming gray world. + adapting_luminance=64 / math.pi * 0.2, + # Gray world assumption, 20% of reference white's `Yw = 100`. + background_luminance=20, + # Average surround + surround='average', + # Do not discount illuminant + discounting=False + ) + CHANNELS = ( + Channel("j", 0.0, 100.0), + Channel("m", 0, 25.0), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) + + def lightness_name(self) -> str: + """Get lightness name.""" + + return "j" + + def radial_name(self) -> str: + """Get radial name.""" + + return "m" + + def is_achromatic(self, coords: Vector) -> bool: + """Check if color is achromatic.""" + + return coords[0] == 0.0 or abs(coords[1]) < self.achromatic_threshold + + def normalize(self, coords: Vector) -> Vector: + """Normalize.""" + + if coords[1] < 0.0: + return self.from_base(self.to_base(coords)) + coords[2] %= 360.0 + return coords + + def to_base(self, coords: Vector) -> Vector: + """From sCAM JMh to XYZ.""" + + return scam_jmh_to_xyz(coords, self.ENV) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ to sCAM JMh.""" + + return xyz_to_scam_jmh(coords, self.ENV) diff --git a/coloraide/spaces/sucs.py b/coloraide/spaces/sucs.py new file mode 100644 index 000000000..0ba61b9fd --- /dev/null +++ b/coloraide/spaces/sucs.py @@ -0,0 +1,84 @@ +""" +Simple Color Appearance Model (sUCS). + +https://opg.optica.org/oe/fulltext.cfm?uri=oe-32-3-3100&id=545619 +""" +from __future__ import annotations +import math +from .. import algebra as alg +from .lch import LCh +from ..channels import Channel, FLG_ANGLE +from .ipt import XYZ_TO_LMS, LMS_TO_XYZ +from ..cat import WHITES +from ..types import Vector + +# Iab transformation matrices +TO_IAB = [ + alg.divide([200.0, 100.0, 5.0], 3.05, dims=alg.D1_SC), + [430.0, -470.0, 40.0], + [49.0, 49.0, -98.0] +] +FROM_IAB = alg.inv(TO_IAB) + + +def sucs_to_xyz(ich: Vector) -> Vector: + """From sUCS to XYZ.""" + + i, c, h = ich + c = (math.exp(0.0252 * c) - 1) / 0.0447 + r = math.radians(h) + a, b = c * math.cos(r), c * math.sin(r) + lms = [alg.nth_root(x, 0.43) for x in alg.matmul_x3(FROM_IAB, [i, a, b], dims=alg.D2_D1)] + return alg.matmul_x3(LMS_TO_XYZ, lms, dims=alg.D2_D1) + + +def xyz_to_sucs(xyz: Vector) -> Vector: + """From XYZ to sUCS.""" + + lms_p = [math.copysign(abs(i) ** 0.43, i) for i in alg.matmul_x3(XYZ_TO_LMS, xyz, dims=alg.D2_D1)] + i, a, b = alg.matmul_x3(TO_IAB, lms_p, dims=alg.D2_D1) + c = (1 / 0.0252) * math.log(1 + 0.0447 * math.sqrt(a ** 2 + b ** 2)) + h = math.atan2(b, a) % math.tau + return [i, c, math.degrees(h)] + + +class sUCS(LCh): + """sUCS class.""" + + BASE = "xyz-d65" + NAME = "sucs" + SERIALIZE = ("--sucs",) + CHANNEL_ALIASES = { + "intensity": "i", + "chroma": 'c', + "hue": 'h' + } + WHITE = WHITES['2deg']['D65'] + CHANNELS = ( + Channel("i", 0.0, 100), + Channel("c", 0, 65.0), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) + + def lightness_name(self) -> str: + """Get lightness name.""" + + return "i" + + def normalize(self, coords: Vector) -> Vector: + """Normalize.""" + + if coords[1] < 0.0: + return self.from_base(self.to_base(coords)) + coords[2] %= 360.0 + return coords + + def to_base(self, coords: Vector) -> Vector: + """From sCAM JMh to XYZ.""" + + return sucs_to_xyz(coords) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ to sCAM JMh.""" + + return xyz_to_sucs(coords) diff --git a/coloraide/spaces/zcam.py b/coloraide/spaces/zcam.py index efe141a1f..1caea5e38 100644 --- a/coloraide/spaces/zcam.py +++ b/coloraide/spaces/zcam.py @@ -11,7 +11,6 @@ """ from __future__ import annotations import math -import bisect from .. import util from .. import algebra as alg from ..cat import WHITES @@ -19,6 +18,7 @@ from ..types import Vector, VectorLike from .lch import LCh from .jzazbz import izazbz_to_xyz, xyz_to_izazbz +from .cam16 import hue_quadrature, inv_hue_quadrature from .. import cat DEF_ILLUMINANT_BI = util.xyz_to_absxyz(util.xy_to_xyz(cat.WHITES['2deg']['E']), yw=100.0) @@ -52,38 +52,6 @@ } -def hue_quadrature(h: float) -> float: - """ - Hue to hue quadrature. - - https://onlinelibrary.wiley.com/doi/pdf/10.1002/col.22324 - """ - - hp = util.constrain_hue(h) - if hp <= HUE_QUADRATURE['h'][0]: - hp += 360 - - i = bisect.bisect_left(HUE_QUADRATURE['h'], hp) - 1 - hi, hii = HUE_QUADRATURE['h'][i:i + 2] - ei, eii = HUE_QUADRATURE['e'][i:i + 2] - Hi = HUE_QUADRATURE['H'][i] - - t = (hp - hi) / ei - return Hi + (100 * t) / (t + (hii - hp) / eii) - - -def inv_hue_quadrature(Hz: float) -> float: - """Hue quadrature to hue.""" - - Hp = (Hz % 400 + 400) % 400 - i = math.floor(0.01 * Hp) - Hp = Hp % 100 - hi, hii = HUE_QUADRATURE['h'][i:i + 2] - ei, eii = HUE_QUADRATURE['e'][i:i + 2] - - return util.constrain_hue((Hp * (eii * hi - ei * hii) - 100 * hi * eii) / (Hp * (eii - ei) - 100 * eii)) - - def adapt( xyz_b: Vector, xyz_wb: Vector, @@ -260,7 +228,7 @@ def zcam_to_xyz( # Break hue into Cartesian components h_rad = 0.0 if hz is None: - hz = inv_hue_quadrature(Hz) # type: ignore[arg-type] + hz = inv_hue_quadrature(Hz, HUE_QUADRATURE) # type: ignore[arg-type] h_rad = math.radians(hz % 360) cos_h = math.cos(h_rad) sin_h = math.sin(h_rad) @@ -323,7 +291,7 @@ def xyz_to_zcam(xyz: Vector, env: Environment, calc_hue_quadrature: bool = False hz = util.constrain_hue(math.degrees(math.atan2(bz, az))) # Step 10 - Hz = hue_quadrature(hz) if calc_hue_quadrature else alg.NaN + Hz = hue_quadrature(hz, HUE_QUADRATURE) if calc_hue_quadrature else alg.NaN # Step 11 hp = hz diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt index 4b8c724ce..cc46cf84e 100644 --- a/docs/src/dictionary/en-custom.txt +++ b/docs/src/dictionary/en-custom.txt @@ -102,6 +102,7 @@ ICtCp IPT ITP ITU +Iab IgPgTg Illuminant Interpolator @@ -148,6 +149,7 @@ Machado Matcher Miquel MkDocs +Molin Mollon Monochromacy Moreland @@ -359,7 +361,9 @@ reflectance reflectances repurpose rgb +sCAM sRGB +sUCS seperable severities smoothstep diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md index 4ad03cbc5..3db1d37e1 100644 --- a/docs/src/markdown/about/changelog.md +++ b/docs/src/markdown/about/changelog.md @@ -5,6 +5,7 @@ icon: lucide/scroll-text ## 7.0 +- **NEW**: Add the `sucs` and `scam-jmh` color space. - **NEW**: Add Porter Duff operator `plus-darker` and `plus-lighter` from the CSS Compositing Level 2 spec, both should be considered experimental and are subject to change. - **NEW**: Usage of boolean to disable blend mode or Porter Duff operator in `layer` has been deprecated. Only diff --git a/docs/src/markdown/colors/index.md b/docs/src/markdown/colors/index.md index 3f6f8686c..65ce4b9df 100644 --- a/docs/src/markdown/colors/index.md +++ b/docs/src/markdown/colors/index.md @@ -103,6 +103,10 @@ flowchart LR xyz-d65 --- hct + xyz-d65 --- sucs + + xyz-d65 --- scam-jmh + xyz-d65 --- aces2065-1 xyz-d65 --- acescg --- acescc @@ -186,6 +190,8 @@ flowchart LR ryb(RYB) srgb(sRGB) srgb-linear(Linear sRGB) + scam-jmh(sCAM JMh) + sucs(sUCS) ucs(CIE 1960 UCS) xyb(XYB) xyy(xyY) @@ -257,6 +263,8 @@ flowchart LR click ryb "./ryb/" _self click srgb "./srgb/" _self click srgb-linear "./srgb_linear/" _self + click scam-jmh "./scam/" _self + click sucs "./sucs" _self click ucs "./ucs/" _self click xyb "./xyb/" _self click xyy "./xyy/" _self @@ -337,6 +345,8 @@ Color Space | ID [RLAB](./rlab.md) | `rlab` [RYB](./ryb.md) | `ryb` [sRGB](./srgb.md) | `srgb` +[sCAM JMh](./scam.md) | `scam-jmh` +[sUCS](./sucs.md) | `sucs` [UCS](./ucs.md) | `ucs` [XYB](./xyb.md) | `xyb` [xyY](./xyy.md) | `xyy` diff --git a/docs/src/markdown/colors/scam.md b/docs/src/markdown/colors/scam.md new file mode 100644 index 000000000..0b240c7b5 --- /dev/null +++ b/docs/src/markdown/colors/scam.md @@ -0,0 +1,141 @@ +# sCAM JMh + +> [!failure] The sCAM JMh color space is not registered in `Color` by default + +/// html | div.info-container +> [!info | inline | end] Properties +> **Name:** `scam-jmh` +> +> **White Point:** D65 / 2˚ +> +> **Coordinates:** +> +> Name | Range^\*^ +> ---- | ----- +> `j` | [0, 100] +> `m` | [0, 25] +> `h` | [0, 360) +> +> ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs in +> relation to the Display P3 color space. + +![sCAM JMh](../images/scam-jmh-3d.png) +//// figure-caption +The sRGB gamut represented within the sCAM JMh color space. +//// + +A color appearance model (CAM) is a mathematical model that seeks to describe the perceptual aspects of human color +vision, i.e. viewing conditions under which the appearance of a color does not tally with the corresponding physical +measurement of the stimulus source. + +The simple Color Appearance Model (sCAM) is a CAM model that is built on top of the simple Uniform Color Space (sUCS) +whose structure is based on that of [IPT](./ipt.md) and [CAM16-UCS](./cam16-ucs.md) for their hue linearity, and +space uniformity, respectively. The model has a simple structure but provides comprehensive functions for color related +applications. + +The model defines numerous different attributes: + +Name | Description +---- | ----------- +J | Lightness +C | Chroma +h | hue +Q | Brightness +M | Colorfulness +D | Saturation +V | Vividness +W | Whiteness +K | Blackness +H | Hue Quadrature + +A color space can be constructed by using a subset of these attributes: JMh, JCh, QMh, etc. The provided color +spaces uses JMh. + +[Learn more](https://opg.optica.org/oe/fulltext.cfm?uri=oe-29-4-6036&id=447640). +/// + +## Viewing Conditions + +sCAM is a color appearance model and can be configured with different viewing environments. An sCAM color space will +also have an associated environment object. This environment object determines the viewing conditions. Colors will +appear different based on the viewing conditions. + +Viewing\ Conditions | Description +---------------------- | ----------- +White | This is the white point and output white and should be the same as defined in the color class. This is provided as (x, y) chromaticity coordinates. sCAM expects and was designed for this to be D65. +Adapting\ Luminance | The luminance of the adapting field (`La`). The units are in cd/m2. +Background\ Luminance | The background luminance (`Yb`) the relative luminance of the nearby background (out to 10°), relative to the reference white's luminance (`Y`). +Surround | A description of the peripheral area. Use "dark" for a movie theater, "dim" for e.g. viewing a bright television in a dimly lit room, or "average" for surface colors. +Discounting | Discounts the illuminant. If true, the eye is assumed to be fully adapted to the illuminant. Otherwise, the degree of discounting is based on other parameters. When the eye is not fully adapted, it can affect the way colors appear and the chromatic response. + +ColorAide must provide some defaults, so sCAM comes with a default set of viewing conditions that uses a D65 white +point, an adapting luminance of 64 lux or a value of ~4 cd/m^2^, it uses the "gray world" assumption and sets the +background to 20, an "average" surround and leaves discounting set to `#!py False`. + +The default settings do not have to be used and a new sCAM variant with different viewing conditions can be created. +When doing this, the space should be derived from the default + +```py play +from coloraide import Color as Base +from coloraide.spaces.scam import sCAMJMh, Environment +from coloraide.cat import WHITES +from coloraide import util +import math + +cdm2 = 1000 / math.pi + +class CustomSCAMJMh(sCAMJMh): + NAME = "scam-custom" + SERIALIZE = ("--scam-custom",) + WHITE = WHITES['2deg']['D65'] + ENV = Environment( + white=WHITE, + adapting_luminance=cdm2, + background_luminance=100, + surround='average', + discounting=False + ) + +class Color(Base): ... + +Color.register([sCAMJMh(), CustomSCAMJMh()]) + +Color('red').convert('scam-jmh') +Color('red').convert('scam-custom') +``` + +## Channel Aliases + +Channels | Aliases +-------- | ------- +`j` | `lightness` +`m` | `colorfulness` +`h` | `hue` + +## Input/Output + +The sCAM JMh space is not currently supported in the CSS spec, the parsed input and string output formats use the +`#!css-color color()` function format using the custom name `#!css-color --scam-jmh`: + +```css-color +color(--scam-jmh j m h / a) // Color function +``` + +The string representation of the color object and the default string output use the +`#!css-color color(--scam-jmh j m h / a)` form. + +```py play +Color("scam-jmh", [54.635, 15.852, 29.937], 1) +Color("scam-jmh", [74.208, 11.547, 67.919], 1).to_string() +``` + +## Registering + +```py +from coloraide import Color as Base +from coloraide.spaces.scam import sCAMJMh + +class Color(Base): ... + +Color.register(sCAMJMh()) +``` diff --git a/docs/src/markdown/colors/sucs.md b/docs/src/markdown/colors/sucs.md new file mode 100644 index 000000000..bf8dafdf9 --- /dev/null +++ b/docs/src/markdown/colors/sucs.md @@ -0,0 +1,68 @@ +# sUCS + +> [!failure] The sUCS color space is not registered in `Color` by default + +/// html | div.info-container +> [!info | inline | end] Properties +> **Name:** `sucs` +> +> **White Point:** D65 / 2˚ +> +> **Coordinates:** +> +> Name | Range^\*^ +> ---- | ----- +> `jz` | [0, 100] +> `mz` | [0, 65] +> `hz` | [0, 360) +> +> ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs in +> relation to the Display P3 color space. + +![sUCS](../images/sucs-3d.png) +//// figure-caption +The sRGB gamut represented within the sUCS color space. +//// + +The sUCS (simple Uniform Color Space) is a new uniform color space developed as the base on which [sCAM](./scam_jmh.md) +(simple Color Appearance Model) was built upon. The structure of sUCS is based on the structure of [IPT](./ipt.md) and +[CAM16-UCS](./cam16-ucs.md) for their hue linearity, and space uniformity, respectively. + +[Learn more](https://opg.optica.org/oe/fulltext.cfm?uri=oe-29-4-6036&id=447640). +/// + +## Channel Aliases + +Channels | Aliases +-------- | ------- +`i` | `intensity` +`c` | `chroma` +`h` | `hue` + +## Input/Output + +The sUCS space is not currently supported in the CSS spec, the parsed input and string output formats use the +`#!css-color color()` function format using the custom name `#!css-color --sucs`: + +```css-color +color(--sucs i c h / a) // Color function +``` + +The string representation of the color object and the default string output use the +`#!css-color color(--sucs i c h / a)` form. + +```py play +Color("sucs", [54.706, 55.669, 29.937], 1) +Color("sucs", [74.256, 44.701, 67.919], 1).to_string() +``` + +## Registering + +```py +from coloraide import Color as Base +from coloraide.spaces.sucs import sUCS + +class Color(Base): ... + +Color.register(sUCS()) +``` diff --git a/docs/src/markdown/colors/zcam.md b/docs/src/markdown/colors/zcam.md index 0cfa2b9f0..8a3e203b7 100644 --- a/docs/src/markdown/colors/zcam.md +++ b/docs/src/markdown/colors/zcam.md @@ -63,7 +63,7 @@ Viewing\ Conditions | Description White | This is the white point and output white and should be the same as defined in the color class. This is provided as (x, y) chromaticity coordinates. ZCAM expects and was designed for this to be D65. Reference\ White | The absolute reference white where `Yw` is scaled to the luminance. Adapting\ Luminance | The luminance of the adapting field (`La`). The units are in cd/m2. -Background\ Luminance | The background luminance (`Yb`) the relative luminance of the nearby background (out to 10°), relative to the reference white's luminance (`Y`). Usually 20 providing a gray world assumption. +Background\ Luminance | The background luminance (`Yb`) the relative luminance of the nearby background (out to 10°), relative to the reference white's luminance (`Y`). Surround | A description of the peripheral area. Use "dark" for a movie theater, "dim" for e.g. viewing a bright television in a dimly lit room, or "average" for surface colors. Discounting | Discounts the illuminant. If true, the eye is assumed to be fully adapted to the illuminant. Otherwise, the degree of discounting is based on other parameters. When the eye is not fully adapted, it can affect the way colors appear and the chromatic response. diff --git a/docs/src/markdown/images/scam-jmh-3d.png b/docs/src/markdown/images/scam-jmh-3d.png new file mode 100644 index 000000000..d2320f4ed Binary files /dev/null and b/docs/src/markdown/images/scam-jmh-3d.png differ diff --git a/docs/src/markdown/images/sucs-3d.png b/docs/src/markdown/images/sucs-3d.png new file mode 100644 index 000000000..dd0825b53 Binary files /dev/null and b/docs/src/markdown/images/sucs-3d.png differ diff --git a/docs/src/zensical.yml b/docs/src/zensical.yml index c651df6b3..454483149 100644 --- a/docs/src/zensical.yml +++ b/docs/src/zensical.yml @@ -146,6 +146,8 @@ nav: - HCT: colors/hct.md - ZCAM JMh: colors/zcam.md - Msh: colors/msh.md + - sUCS: colors/sucs.md + - sCAM: colors/scam.md - ACES Spaces: - ACES 2065-1: colors/aces2065_1.md diff --git a/tests/test_scam.py b/tests/test_scam.py new file mode 100644 index 000000000..a98416a08 --- /dev/null +++ b/tests/test_scam.py @@ -0,0 +1,261 @@ +"""Test sCAM JMh.""" +import unittest +from . import util +from coloraide.everything import ColorAll as Color, NaN +from coloraide.spaces.scam import scam_to_xyz, xyz_to_scam, sCAMJMh +from typing import NamedTuple +import pytest + + +class sCAMCoords(NamedTuple): + """sCAM coordinates.""" + + J: float + C: float + h: float + Q: float + M: float + D: float + V: float + W: float + K: float + H: float + + +class TestSCAMJMh(util.ColorAssertsPyTest): + """Test sCAM JMh.""" + + COLORS = [ + ('red', 'color(--scam-jmh 54.635 15.852 29.937)'), + ('orange', 'color(--scam-jmh 74.208 11.547 67.919)'), + ('yellow', 'color(--scam-jmh 96.243 12.049 101.84)'), + ('green', 'color(--scam-jmh 43.205 11.159 134.25)'), + ('blue', 'color(--scam-jmh 31.197 19.885 250.27)'), + ('indigo', 'color(--scam-jmh 23.328 12.625 290.85)'), + ('violet', 'color(--scam-jmh 69.669 11.021 325.3)'), + ('white', 'color(--scam-jmh 100 0 0)'), + ('gray', 'color(--scam-jmh 51.651 0 0)'), + ('black', 'color(--scam-jmh 0 0 0)'), + # Test color + ('color(--scam-jmh 50 30 270)', 'color(--scam-jmh 50 30 270)'), + ('color(--scam-jmh 50 30 270 / 0.5)', 'color(--scam-jmh 50 30 270 / 0.5)'), + ('color(--scam-jmh 50% 50% 100 / 50%)', 'color(--scam-jmh 50 12.5 100 / 0.5)'), + ('color(--scam-jmh none none none / none)', 'color(--scam-jmh none none none / none)'), + # Test percent ranges + ('color(--scam-jmh 0% 0% 0)', 'color(--scam-jmh 0 0 0)'), + ('color(--scam-jmh 100% 100% 100)', 'color(--scam-jmh 100 25 100)'), + ('color(--scam-jmh -100% -100% 100)', 'color(--scam-jmh -100 -25 100)'), + # Test miscellaneous cases + ('color(--scam-jmh -10 none 270)', 'color(--scam-jmh -10 0 270)') + ] + + @pytest.mark.parametrize('color1,color2', COLORS) + def test_colors(self, color1, color2): + """Test colors.""" + + self.assertColorEqual(Color(color1).convert('scam-jmh'), Color(color2)) + + +class TestSCAMJMhPoperties(util.ColorAsserts, unittest.TestCase): + """Test sCAM JMh properties.""" + + def test_names(self): + """Test LCh-ish names.""" + + c = Color('color(--scam-jmh 97.139 75.504 111.05)') + self.assertEqual(c._space.names(), ('j', 'm', 'h')) + self.assertEqual(c._space.radial_name(), 'm') + self.assertEqual(c._space.hue_name(), 'h') + self.assertEqual(c._space.lightness_name(), 'j') + + def test_h(self): + """Test `h`.""" + + c = Color('color(--scam-jmh 97.139 75.504 111.05)') + self.assertEqual(c['h'], 111.05) + c['h'] = 270 + self.assertEqual(c['h'], 270) + + def test_m(self): + """Test `m`.""" + + m = Color('color(--scam-jmh 97.139 75.504 111.05)') + self.assertEqual(m['m'], 75.504) + m['m'] = 30 + self.assertEqual(m['m'], 30) + + def test_j(self): + """Test `j`.""" + + c = Color('color(--scam-jmh 97.139 75.504 111.05)') + self.assertEqual(c['j'], 97.139) + c['j'] = 50 + self.assertEqual(c['j'], 50) + + def test_alpha(self): + """Test `alpha`.""" + + c = Color('color(--scam-jmh 97.139 75.504 111.05)') + self.assertEqual(c['alpha'], 1) + c['alpha'] = 0.5 + self.assertEqual(c['alpha'], 0.5) + + +class TestNull(util.ColorAsserts, unittest.TestCase): + """Test Null cases.""" + + def test_null_input(self): + """Test null input.""" + + c = Color('scam-jmh', [30, 20, NaN], 1) + self.assertTrue(c.is_nan('hue')) + + def test_none_input(self): + """Test `none` null.""" + + c = Color('color(--scam-jmh 30 20 none / 1)') + self.assertTrue(c.is_nan('hue')) + + def test_null_normalization_min_chroma(self): + """Test minimum chroma.""" + + c = Color(Color('scam-jmh', [20, 0, 270]).convert('scam-jmh').to_string()).normalize() + self.assertTrue(c.is_nan('hue')) + + def test_null_normalization_negative_chroma(self): + """Test negative chroma.""" + + c = Color(Color('scam-jmh', [20, -5, 270]).convert('scam-jmh').to_string()).normalize() + self.assertTrue(c.get('hue'), 270 + 180) + + +class TestsAchromatic(util.ColorAsserts, unittest.TestCase): + """Test achromatic.""" + + def test_achromatic(self): + """Test when color is achromatic.""" + + self.assertEqual( + Color('srgb', [0.000000001] * 3).convert('cam16-jmh').set('m', lambda x: x + 1e-8).is_achromatic(), + True + ) + self.assertEqual(Color('scam-jmh', [NaN, 0.00001, 270]).is_achromatic(), True) + self.assertEqual(Color('scam-jmh', [0, NaN, 270]).is_achromatic(), True) + self.assertEqual(Color('scam-jmh', [0, 50, 270]).is_achromatic(), True) + self.assertEqual(Color('scam-jmh', [NaN, 50, 270]).is_achromatic(), True) + self.assertEqual(Color('scam-jmh', [20, NaN, 270]).is_achromatic(), True) + self.assertEqual(Color('scam-jmh', [NaN, NaN, 270]).is_achromatic(), True) + self.assertEqual(Color('scam-jmh', [20, -1.3, 90]).is_achromatic(), False) + + +class TestSCAMApperanceModel(util.ColorAsserts, unittest.TestCase): + """Test sCAM appearance model.""" + + COORDS = sCAMCoords( + 54.635215259160006, 55.6689281833851, 29.93650277259878, 184.71839737681037, 15.852250348849166, + 108.89322562877724, 110.82461568476299, -8.893225628777245, -10.824615684762989, 19.613811267787895 + ) + + def test_no_lightness(self): + """Test conversion failure when no equivalent lightness.""" + + with self.assertRaises(ValueError): + scam_to_xyz(C=self.COORDS.C, h=self.COORDS.h, env=sCAMJMh.ENV) + + def test_no_chroma(self): + """Test conversion failure when no equivalent chroma.""" + + with self.assertRaises(ValueError): + scam_to_xyz(J=self.COORDS.J, h=self.COORDS.h, env=sCAMJMh.ENV) + + def test_no_hue(self): + """Test conversion failure when no equivalent hue.""" + + with self.assertRaises(ValueError): + scam_to_xyz(J=self.COORDS.J, C=self.COORDS.C, env=sCAMJMh.ENV) + + def test_no_environment(self): + """Test no test no environment.""" + + with self.assertRaises(ValueError): + scam_to_xyz(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h) + + def test_lightness_convert(self): + """Test convert lightness.""" + + for a, b in zip( + scam_to_xyz(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=sCAMJMh.ENV), + scam_to_xyz(Q=self.COORDS.Q, C=self.COORDS.C, h=self.COORDS.h, env=sCAMJMh.ENV) + ): + self.assertCompare(a, b, 14) + + def test_chroma_convert(self): + """Test convert chroma.""" + + for a, b in zip( + scam_to_xyz(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=sCAMJMh.ENV), + scam_to_xyz(Q=self.COORDS.Q, M=self.COORDS.M, h=self.COORDS.h, env=sCAMJMh.ENV) + ): + self.assertCompare(a, b, 14) + + for a, b in zip( + scam_to_xyz(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=sCAMJMh.ENV), + scam_to_xyz(Q=self.COORDS.Q, V=self.COORDS.V, h=self.COORDS.h, env=sCAMJMh.ENV) + ): + self.assertCompare(a, b, 14) + + for a, b in zip( + scam_to_xyz(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=sCAMJMh.ENV), + scam_to_xyz(Q=self.COORDS.Q, K=self.COORDS.K, h=self.COORDS.h, env=sCAMJMh.ENV) + ): + self.assertCompare(a, b, 14) + + for a, b in zip( + scam_to_xyz(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=sCAMJMh.ENV), + scam_to_xyz(Q=self.COORDS.Q, W=self.COORDS.W, h=self.COORDS.h, env=sCAMJMh.ENV) + ): + self.assertCompare(a, b, 14) + + def test_hue_convert(self): + """Test convert hue.""" + + for a, b in zip( + scam_to_xyz(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=sCAMJMh.ENV), + scam_to_xyz(J=self.COORDS.J, C=self.COORDS.C, H=self.COORDS.H, env=sCAMJMh.ENV) + ): + self.assertCompare(a, b, 14) + + def test_Qz_zero_high_colorfulness(self): + """Test Qz as zero with high colorfulness.""" + + for a, b in zip( + scam_to_xyz(Q=0, M=self.COORDS.M, h=self.COORDS.h, env=sCAMJMh.ENV), + [0.0, 0.0, 0.0] + ): + self.assertCompare(a, b, 14) + + def test_Qz_zero_low_colorfulness(self): + """Test Qz as zero with zero colorfulness.""" + + for a, b in zip( + scam_to_xyz(Q=0, M=0, h=self.COORDS.h, env=sCAMJMh.ENV), + [0, 0, 0] + ): + self.assertCompare(a, b, 14) + + def test_hue_quadrature(self): + """Test conversion to and from test_hue_quadrature.""" + + xyz = scam_to_xyz(J=self.COORDS.J, C=self.COORDS.C, H=self.COORDS.H, env=sCAMJMh.ENV) + coords = xyz_to_scam(xyz, sCAMJMh.ENV, True) + self.assertCompare(self.COORDS.H, coords[-1], 12) + + def test_hue_quadrature_low_end(self): + """Exercise when the hue quadrature is on the low end.""" + + xyz = Color('pink').convert('xyz-d65') + coords = sCAMCoords(*xyz_to_scam(xyz.coords(), sCAMJMh.ENV, True)) + self.assertCompare(393.28263, coords[-1]) + xyz2 = scam_to_xyz(J=coords.J, M=coords.M, H=coords.H, env=sCAMJMh.ENV) + for a, b in zip(xyz, xyz2): + self.assertCompare(a, b, 12) diff --git a/tests/test_sucs.py b/tests/test_sucs.py new file mode 100644 index 000000000..749069778 --- /dev/null +++ b/tests/test_sucs.py @@ -0,0 +1,112 @@ +"""Test sUCS.""" +import unittest +from . import util +from coloraide.everything import ColorAll as Color +import pytest + + +class TestSUCS(util.ColorAssertsPyTest): + """Test sUCS.""" + + COLORS = [ + ('red', 'color(--sucs 54.706 55.669 29.937)'), + ('orange', 'color(--sucs 74.256 44.701 67.919)'), + ('yellow', 'color(--sucs 96.251 49.565 101.84)'), + ('green', 'color(--sucs 43.283 36.032 134.25)'), + ('blue', 'color(--sucs 31.275 54.027 250.27)'), + ('indigo', 'color(--sucs 23.401 32.158 290.85)'), + ('violet', 'color(--sucs 69.723 38.839 325.3)'), + ('white', 'color(--sucs 100 0 0)'), + ('gray', 'color(--sucs 51.724 0 0)'), + ('black', 'color(--sucs 0 0 0)'), + # Test color + ('color(--sucs 50 10 50)', 'color(--sucs 50 10 50)'), + ('color(--sucs 50 10 50 / 0.5)', 'color(--sucs 50 10 50 / 0.5)'), + ('color(--sucs 50% 50% 50 / 50%)', 'color(--sucs 50 32.5 50 / 0.5)'), + ('color(--sucs none none none / none)', 'color(--sucs none none none / none)'), + # Test percent ranges + ('color(--sucs 0% 0% 50)', 'color(--sucs 0 0 50)'), + ('color(--sucs 100% 100% 50)', 'color(--sucs 100 65 50)'), + ('color(--sucs -100% -100% 50)', 'color(--sucs -100 -65 50)') + ] + + @pytest.mark.parametrize('color1,color2', COLORS) + def test_colors(self, color1, color2): + """Test colors.""" + + self.assertColorEqual(Color(color1).convert('sucs'), Color(color2)) + + +class TestSUCSSerialize(util.ColorAssertsPyTest): + """Test sUCS serialization.""" + + COLORS = [ + # Test color + ('color(--sucs 75 10 50 / 0.5)', {}, 'color(--sucs 75 10 50 / 0.5)'), + # Test alpha + ('color(--sucs 75 10 50)', {'alpha': True}, 'color(--sucs 75 10 50 / 1)'), + ('color(--sucs 75 10 50 / 0.5)', {'alpha': False}, 'color(--sucs 75 10 50)'), + # Test None + ('color(--sucs none 10 50)', {}, 'color(--sucs 0 10 50)'), + ('color(--sucs none 10 50)', {'none': True}, 'color(--sucs none 10 50)'), + # Test Fit (not bound) + ('color(--sucs 120 10 50)', {}, 'color(--sucs 120 10 50)'), + ('color(--sucs 120 10 50)', {'fit': False}, 'color(--sucs 120 10 50)') + ] + + @pytest.mark.parametrize('color1,options,color2', COLORS) + def test_colors(self, color1, options, color2): + """Test colors.""" + + self.assertEqual(Color(color1).to_string(**options), color2) + + +class TestSUCSPoperties(util.ColorAsserts, unittest.TestCase): + """Test sUCS.""" + + def test_names(self): + """Test LCh-ish names.""" + + c = Color('color(--sucs 96.323 21.06 55.728)') + self.assertEqual(c._space.names(), ('i', 'c', 'h')) + self.assertEqual(c._space.radial_name(), 'c') + self.assertEqual(c._space.hue_name(), 'h') + self.assertEqual(c._space.lightness_name(), 'i') + + def test_i(self): + """Test `i`.""" + + c = Color('color(--sucs 96.323 21.06 55.728)') + self.assertEqual(c['i'], 96.323) + c['i'] = 0.2 + self.assertEqual(c['i'], 0.2) + + def test_c(self): + """Test `c`.""" + + c = Color('color(--sucs 96.323 21.06 55.728)') + self.assertEqual(c['c'], 21.06) + c['c'] = 0.1 + self.assertEqual(c['c'], 0.1) + + def test_h(self): + """Test `h`.""" + + c = Color('color(--sucs 96.323 21.06 55.728)') + self.assertEqual(c['h'], 55.728) + c['h'] = 0.1 + self.assertEqual(c['h'], 0.1) + + def test_alpha(self): + """Test `alpha`.""" + + c = Color('color(--sucs 96.323 21.06 55.728)') + self.assertEqual(c['alpha'], 1) + c['alpha'] = 0.5 + self.assertEqual(c['alpha'], 0.5) + + def test_null_normalization_negative_chroma(self): + """Test minimum saturation.""" + + c = Color('color(--sucs 90% -10 120 / 1)').normalize() + self.assertColorEqual(c, Color('color(--sucs 90% 7.9804 300)')) diff --git a/tests/test_zcam.py b/tests/test_zcam.py index 958001a55..57f81a412 100644 --- a/tests/test_zcam.py +++ b/tests/test_zcam.py @@ -57,7 +57,7 @@ def test_colors(self, color1, color2): class TestZCAMJMhPoperties(util.ColorAsserts, unittest.TestCase): - """Test CAM16 JMh properties.""" + """Test ZCAM JMh properties.""" def test_names(self): """Test LCh-ish names.""" diff --git a/tools/gen_3d_models.py b/tools/gen_3d_models.py index b6723546f..16f07b9b4 100644 --- a/tools/gen_3d_models.py +++ b/tools/gen_3d_models.py @@ -33,57 +33,59 @@ def plot_model(name, title, filename, gamut='srgb', elev=45, azim=-60.0): models = { - 'srgb': {'title': 'sRGB Color Space', 'filename': 'srgb-3d.png'}, - 'hsl': {'title': 'HSL Color Space', 'filename': 'hsl-3d.png'}, + 'cam02-jmh': {'title': TEMPLATE.format('CAM02 JMh'), 'filename': 'cam02-jmh-3d.png'}, + 'cam02-lcd': {'title': TEMPLATE.format('CAM02 LCD'), 'filename': 'cam02-lcd-3d.png'}, + 'cam02-scd': {'title': TEMPLATE.format('CAM02 SCD'), 'filename': 'cam02-scd-3d.png'}, + 'cam02-ucs': {'title': TEMPLATE.format('CAM02 UCS'), 'filename': 'cam02-ucs-3d.png'}, + 'cam16-jmh': {'title': TEMPLATE.format('CAM16 JMh'), 'filename': 'cam16-jmh-3d.png'}, + 'cam16-lcd': {'title': TEMPLATE.format('CAM16 LCD'), 'filename': 'cam16-lcd-3d.png'}, + 'cam16-scd': {'title': TEMPLATE.format('CAM16 SCD'), 'filename': 'cam16-scd-3d.png'}, + 'cam16-ucs': {'title': TEMPLATE.format('CAM16 UCS'), 'filename': 'cam16-ucs-3d.png'}, + 'cmy': {'title': TEMPLATE.format('CMY'), 'filename': 'cmy-3d.png'}, + 'cubehelix': {'title': TEMPLATE.format('Cubehelix'), 'filename': 'cubehelix-3d.png'}, + 'din99o': {'title': TEMPLATE.format('DIN99o'), 'filename': 'din99o-3d.png'}, + 'hct': {'title': TEMPLATE.format('HCT'), 'filename': 'hct-3d.png'}, + 'hellwig-hk-jmh': {'title': TEMPLATE.format('Hellwig H-K JMh'), 'filename': 'hellwig-hk-jmh-3d.png'}, + 'hellwig-jmh': {'title': TEMPLATE.format('Hellwig JMh'), 'filename': 'hellwig-jmh-3d.png'}, + 'hpluv': {'title': 'HPLuv Color Space', 'filename': 'hpluv-3d.png', 'gamut': 'hpluv'}, 'hsi': {'title': 'HSI Color Space', 'filename': 'hsi-3d.png'}, + 'hsl': {'title': 'HSL Color Space', 'filename': 'hsl-3d.png'}, + 'hsluv': {'title': 'HSLuv Color Space', 'filename': 'hsluv-3d.png'}, 'hsv': {'title': 'HSV Color Space', 'filename': 'hsv-3d.png'}, + 'hunter-lab': {'title': TEMPLATE.format('Hunter Lab'), 'filename': 'hunter-lab-3d.png', 'elev': 5, 'azim': -30}, 'hwb': {'title': 'HWB Color Space', 'filename': 'hwb-3d.png', 'elev': 210}, - 'okhsl': {'title': 'Okhsl Color Space', 'filename': 'okhsl-3d.png'}, - 'okhsv': {'title': 'Okhsv Color Space', 'filename': 'okhsv-3d.png'}, - 'hsluv': {'title': 'HSLuv Color Space', 'filename': 'hsluv-3d.png'}, - 'hpluv': {'title': 'HPLuv Color Space', 'filename': 'hpluv-3d.png', 'gamut': 'hpluv'}, - 'xyz-d50': {'title': TEMPLATE.format('XYZ D50'), 'filename': 'xyz-d50-3d.png'}, - 'xyz-d65': {'title': TEMPLATE.format('XYZ D65'), 'filename': 'xyz-d65-3d.png'}, - 'xyy': {'title': TEMPLATE.format('xyY'), 'filename': 'xyy-3d.png'}, + 'ictcp': {'title': TEMPLATE.format('ICtCp'), 'filename': 'ictcp-3d.png', 'azim': 15}, + 'igpgtg': {'title': TEMPLATE.format('IgPgTg'), 'filename': 'igpgtg-3d.png'}, + 'ipt': {'title': TEMPLATE.format('IPT'), 'filename': 'ipt-3d.png'}, + 'jzazbz': {'title': TEMPLATE.format('Jzazbz'), 'filename': 'jzazbz-3d.png'}, + 'jzczhz': {'title': TEMPLATE.format('JzCzhz'), 'filename': 'jzczhz-3d.png', 'azim': 300}, 'lab': {'title': TEMPLATE.format('CIELab D50'), 'filename': 'lab-3d.png'}, 'lab-d65': {'title': TEMPLATE.format('CIELab D65'), 'filename': 'lab-d65-3d.png'}, 'lch': {'title': TEMPLATE.format('CIELCh D50'), 'filename': 'lch-3d.png', 'azim': 300}, 'lch-d65': {'title': TEMPLATE.format('CIELCh D65'), 'filename': 'lch-d65-3d.png', 'azim': 300}, + 'lch99o': {'title': TEMPLATE.format('DIN99o LCh'), 'filename': 'lch99o-3d.png', 'azim': 300}, + 'lchuv': {'title': TEMPLATE.format('LChuv'), 'filename': 'lchuv-3d.png', 'azim': 300}, + 'luv': {'title': TEMPLATE.format('CIELuv'), 'filename': 'luv-3d.png'}, + 'msh': {'title': TEMPLATE.format('Msh'), 'filename': 'msh-3d.png'}, + 'okhsl': {'title': 'Okhsl Color Space', 'filename': 'okhsl-3d.png'}, + 'okhsv': {'title': 'Okhsv Color Space', 'filename': 'okhsv-3d.png'}, 'oklab': {'title': TEMPLATE.format('Oklab'), 'filename': 'oklab-3d.png'}, 'oklch': {'title': TEMPLATE.format('OkLCh'), 'filename': 'oklch-3d.png', 'azim': 300}, 'oklrab': {'title': TEMPLATE.format('Oklrab'), 'filename': 'oklrab-3d.png'}, 'oklrch': {'title': TEMPLATE.format('OkLrCh'), 'filename': 'oklrch-3d.png', 'azim': 300}, - 'luv': {'title': TEMPLATE.format('CIELuv'), 'filename': 'luv-3d.png'}, - 'lchuv': {'title': TEMPLATE.format('LChuv'), 'filename': 'lchuv-3d.png', 'azim': 300}, - 'msh': {'title': TEMPLATE.format('Msh'), 'filename': 'msh-3d.png'}, - 'din99o': {'title': TEMPLATE.format('DIN99o'), 'filename': 'din99o-3d.png'}, - 'lch99o': {'title': TEMPLATE.format('DIN99o LCh'), 'filename': 'lch99o-3d.png', 'azim': 300}, - 'jzazbz': {'title': TEMPLATE.format('Jzazbz'), 'filename': 'jzazbz-3d.png'}, - 'jzczhz': {'title': TEMPLATE.format('JzCzhz'), 'filename': 'jzczhz-3d.png', 'azim': 300}, - 'rlab': {'title': TEMPLATE.format('RLAB'), 'filename': 'rlab-3d.png'}, - 'hunter-lab': {'title': TEMPLATE.format('Hunter Lab'), 'filename': 'hunter-lab-3d.png', 'elev': 5, 'azim': -30}, - 'ipt': {'title': TEMPLATE.format('IPT'), 'filename': 'ipt-3d.png'}, - 'ictcp': {'title': TEMPLATE.format('ICtCp'), 'filename': 'ictcp-3d.png', 'azim': 15}, - 'igpgtg': {'title': TEMPLATE.format('IgPgTg'), 'filename': 'igpgtg-3d.png'}, - 'cam02-ucs': {'title': TEMPLATE.format('CAM02 UCS'), 'filename': 'cam02-ucs-3d.png'}, - 'cam02-scd': {'title': TEMPLATE.format('CAM02 SCD'), 'filename': 'cam02-scd-3d.png'}, - 'cam02-lcd': {'title': TEMPLATE.format('CAM02 LCD'), 'filename': 'cam02-lcd-3d.png'}, - 'cam02-jmh': {'title': TEMPLATE.format('CAM02 JMh'), 'filename': 'cam02-jmh-3d.png'}, - 'cam16-ucs': {'title': TEMPLATE.format('CAM16 UCS'), 'filename': 'cam16-ucs-3d.png'}, - 'cam16-scd': {'title': TEMPLATE.format('CAM16 SCD'), 'filename': 'cam16-scd-3d.png'}, - 'cam16-lcd': {'title': TEMPLATE.format('CAM16 LCD'), 'filename': 'cam16-lcd-3d.png'}, - 'cam16-jmh': {'title': TEMPLATE.format('CAM16 JMh'), 'filename': 'cam16-jmh-3d.png'}, - 'hellwig-jmh': {'title': TEMPLATE.format('Hellwig JMh'), 'filename': 'hellwig-jmh-3d.png'}, - 'hellwig-hk-jmh': {'title': TEMPLATE.format('Hellwig H-K JMh'), 'filename': 'hellwig-hk-jmh-3d.png'}, - 'hct': {'title': TEMPLATE.format('HCT'), 'filename': 'hct-3d.png'}, - 'cmy': {'title': TEMPLATE.format('CMY'), 'filename': 'cmy-3d.png'}, 'orgb': {'title': TEMPLATE.format('oRGB'), 'filename': 'orgb-3d.png', 'azim': -90, 'elev': 80}, - 'xyb': {'title': TEMPLATE.format('XYB'), 'filename': 'xyb-3d.png', 'azim': 45}, - 'ucs': {'title': TEMPLATE.format('UCS'), 'filename': 'ucs-3d.png', 'azim': 60, 'elev': 10}, + 'rlab': {'title': TEMPLATE.format('RLAB'), 'filename': 'rlab-3d.png'}, 'ryb': {'title': 'RYB Color Space', 'filename': 'ryb-3d.png', 'gamut': 'ryb'}, 'ryb-biased': {'title': 'RYB Color Space (Biased)', 'filename': 'ryb-biased-3d.png', 'gamut': 'ryb-biased'}, - 'cubehelix': {'title': TEMPLATE.format('Cubehelix'), 'filename': 'cubehelix-3d.png'}, - 'zcam-jmh': {'title': TEMPLATE.format('ZCAM JMh'), 'filename': 'zcam-jmh-3d.png', 'azim': 320}, + 'scam-jmh': {'title': TEMPLATE.format('sCAM JMh'), 'filename': 'scam-jmh-3d.png', 'azim': 320}, + 'srgb': {'title': 'sRGB Color Space', 'filename': 'srgb-3d.png'}, + 'sucs': {'title': TEMPLATE.format('sUCS'), 'filename': 'sucs-3d.png', 'azim': 320}, + 'ucs': {'title': TEMPLATE.format('UCS'), 'filename': 'ucs-3d.png', 'azim': 60, 'elev': 10}, + 'xyb': {'title': TEMPLATE.format('XYB'), 'filename': 'xyb-3d.png', 'azim': 45}, + 'xyy': {'title': TEMPLATE.format('xyY'), 'filename': 'xyy-3d.png'}, + 'xyz-d50': {'title': TEMPLATE.format('XYZ D50'), 'filename': 'xyz-d50-3d.png'}, + 'xyz-d65': {'title': TEMPLATE.format('XYZ D65'), 'filename': 'xyz-d65-3d.png'}, + 'zcam-jmh': {'title': TEMPLATE.format('ZCAM JMh'), 'filename': 'zcam-jmh-3d.png', 'azim': 320} } diff --git a/zensical.yml b/zensical.yml index 2bbea2547..c2e3c8f9c 100644 --- a/zensical.yml +++ b/zensical.yml @@ -146,6 +146,8 @@ nav: - HCT: colors/hct.md - ZCAM JMh: colors/zcam.md - Msh: colors/msh.md + - sUCS: colors/sucs.md + - sCAM: colors/scam.md - ACES Spaces: - ACES 2065-1: colors/aces2065_1.md