Repository: facelessuser/coloraide (language: python)

Issue:
ZCAM
ZCAM is roughly what Jzazbz is based on.

The article is here: https://opg.optica.org/oe/fulltext.cfm?uri=oe-29-4-6036&id=447640.
Supplemental information containing info regarding the inversion of the conversion and some comparable examples is found here: https://opticapublishing.figshare.com/articles/journal_contribution/Supplementary_document_for_ZCAM_a_psychophysical_model_for_colour_appearance_prediction_-_5022171_pdf/13640927.

The article also references using a two-stage chromatic adaptation by Qiyan Zhai and Ming R. Luo using CAM02: https://opg.optica.org/oe/fulltext.cfm?uri=oe-26-6-7724&id=383537. CAM16 could also be used.

I technically have it working, though I haven't calculated the achromatic response or anything like that yet. We are using a reference lighting of absolute XYZ of [256, 264, 202] and an adapting luminance of 264 with a background luminance of 100. This is from an example in the supplemental article.

![zcam](https://github.com/facelessuser/coloraide/assets/1055125/5f25b5dc-bedb-42da-93a6-e907b00d1f55)

We seem to match the article's examples close enough.  Their exact white point for D65 isn't specified, so it is likely responsible for the slight difference.

<img width="422" alt="Screenshot 2023-07-04 at 7 54 15 AM" src="https://github.com/facelessuser/coloraide/assets/1055125/858f5e62-1ee6-477a-904e-e1231e14c49e">

```py
>>> from coloraide.everything import ColorAll as Color
>>> Color('xyz-d65', [185 / 100, 206 / 100, 163 / 100]).convert('zcam')[:]
[92.25044378072363, 10.525621789845456, 196.32457375575555, 1.0]
```

Anyways, I don't know what a good default for this would even be or what immediate use people would have for it. What drove us to add CAM16 was HCT. Using these "CAM" spaces are inherently slow, especially when compared to using Jzazbz which streamlines calculations making it more accessible.

It may end up in extras at least.






Discussion / hints:
The achromatic response has been calculated, though it isn't quite as precise as I'd like to see and requires more data points than I'd like as well. It requires 230 points compared to CAM16 which uses 162. We also have to drop precision to 4 places in the roundtrip test when verifying achromatic values. It's likely this can be improved when I have time in the future, for now, we have something working that we can play with. I may throw it up on a gist.
It should be noted that with further testing, the third example seems to have a typo as it states `La = 150` and `yb = 60`, but that seems like nonsense as it cannot resolve properly while all the other examples are fairly close.

That brings us to the second issue. No matter what we do, we cannot replicate the exact values in the paper. That is likely due to a difference in D65 white point or something similar. I've double-checked all the values, and it all seems correct. I downloaded `colour-science` and it appears we get similar close, but not quite right values as they do. Luxpy has an open issue complaining about the same problem, and I've seen some others as well. Based on this information, we are probably as close as we can get without the author of the original article further clarifying. It seems that had other slight typos, so I question how well it was reviewed.

If I recall, there is some ambiguity from the same author in regard to Jzazbz. exact scaling used in the paper was never clarified, so people seem to do it differently depending on the implementer. We assume a `yw=203` when converting to absolute xyz, but others do not.
I think we will target the next release with ZCAM. I think we'll just pick a default. This is related to #408. ZCAM seem to match the reference as well as everyone else, probably no reason to keep sitting on this.

Fix patch:
diff --git a/coloraide/__meta__.py b/coloraide/__meta__.py
index 98bbf444..9bb3bf1f 100644
--- a/coloraide/__meta__.py
+++ b/coloraide/__meta__.py
@@ -193,5 +193,5 @@ def parse_version(ver: str) -> Version:
     return Version(major, minor, micro, release, pre, post, dev)
 
 
-__version_info__ = Version(3, 1, 2, "final")
+__version_info__ = Version(3, 2, 0, "final")
 __version__ = __version_info__._get_canonical()
diff --git a/coloraide/everything.py b/coloraide/everything.py
index a3368bcd..4d45250f 100644
--- a/coloraide/everything.py
+++ b/coloraide/everything.py
@@ -26,6 +26,7 @@
 from .spaces.acescct import ACEScct
 from .spaces.cam16_jmh import CAM16JMh
 from .spaces.cam16_ucs import CAM16UCS, CAM16LCD, CAM16SCD
+from .spaces.zcam_jmh import ZCAMJMh
 from .spaces.hct import HCT
 from .spaces.ucs import UCS
 from .spaces.rec709 import Rec709
@@ -85,6 +86,7 @@ class ColorAll(Base):
         RYB(),
         RYBBiased(),
         Cubehelix(),
+        ZCAMJMh(),
 
         # Delta E
         DE99o(),
diff --git a/coloraide/spaces/cam16_jmh.py b/coloraide/spaces/cam16_jmh.py
index 165667f6..c1f2879a 100644
--- a/coloraide/spaces/cam16_jmh.py
+++ b/coloraide/spaces/cam16_jmh.py
@@ -112,7 +112,8 @@ class Environment:
         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`. Some also simplify this to simply `lux / π`.
+        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.
@@ -269,7 +270,7 @@ def cam16_to_xyz_d65(
     return util.scale1(alg.matmul(MI6_INV, alg.multiply(rgb_c, env.d_rgb_inv, dims=alg.D1), dims=alg.D2_D1))
 
 
-def xyz_d65_to_cam16(xyzd65: Vector, env: Environment) -> Vector:
+def xyz_d65_to_cam16(xyzd65: Vector, env: Environment, calc_hue_quadrature: bool = False) -> Vector:
     """From XYZ to CAM16."""
 
     # Cone response
@@ -317,7 +318,7 @@ def xyz_d65_to_cam16(xyzd65: Vector, env: Environment) -> Vector:
     h = util.constrain_hue(math.degrees(h_rad))
 
     # Hue quadrature
-    H = hue_quadrature(h)
+    H = hue_quadrature(h) if calc_hue_quadrature else alg.NaN
 
     # Saturation
     s = 50 * alg.nth_root(env.c * alpha / (env.a_w + 4), 2)
@@ -363,7 +364,7 @@ class CAM16JMh(LChish, Space):
         background_luminance=20,
         # Average surround
         surround='average',
-        # No discount of illuminant
+        # Do not discount illuminant
         discounting=False
     )
     CHANNELS = (
diff --git a/coloraide/spaces/jzazbz.py b/coloraide/spaces/jzazbz.py
index e3fd2d78..95548a2a 100644
--- a/coloraide/spaces/jzazbz.py
+++ b/coloraide/spaces/jzazbz.py
@@ -15,7 +15,7 @@
 from ..channels import Channel, FLG_MIRROR_PERCENT
 from .. import util
 from .. import algebra as alg
-from ..types import Vector  # noqa: F401
+from ..types import Vector, Matrix  # noqa: F401
 from .lab import Lab
 
 B = 1.15
@@ -40,71 +40,82 @@
 
 
 # XYZ transform matrices
-xyz_to_lms_m = [
+XYZ_TO_LMS = [
     [0.41478972, 0.579999, 0.014648],
     [-0.20151, 1.120649, 0.0531008],
     [-0.0166008, 0.2648, 0.6684799]
 ]
 
-lms_to_xyz_mi = [
+LMS_TO_XYZ = [
     [1.9242264357876069, -1.0047923125953657, 0.037651404030617994],
     [0.35031676209499907, 0.7264811939316552, -0.06538442294808501],
     [-0.09098281098284755, -0.31272829052307394, 1.5227665613052603]
 ]
 
 # LMS to Izazbz matrices
-lms_p_to_izazbz_m = [
+LMS_P_TO_IZAZBZ = [
     [0.5, 0.5, 0],
     [3.524, -4.066708, 0.542708],
     [0.199076, 1.096799, -1.295875]
 ]
 
-izazbz_to_lms_p_mi = [
+IZAZBZ_TO_LMS_P = [
     [1.0, 0.13860504327153927, 0.05804731615611883],
     [1.0, -0.1386050432715393, -0.058047316156118904],
     [1.0, -0.09601924202631895, -0.811891896056039]
 ]
 
 
-def jzazbz_to_xyz_d65(jzazbz: Vector) -> Vector:
-    """From Jzazbz to XYZ."""
+def xyz_d65_to_izazbz(xyz: Vector, lms_matrix: Matrix, m2: float) -> Vector:
+    """Absolute XYZ to Izazbz."""
 
-    jz, az, bz = jzazbz
+    xa, ya, za = xyz
+    xm = (B * xa) - ((B - 1) * za)
+    ym = (G * ya) - ((G - 1) * xa)
 
-    # Calculate Iz
-    iz = alg.zdiv((jz + D0), (1 + D - D * (jz + D0)))
+    # Convert to LMS
+    lms = alg.matmul(XYZ_TO_LMS, [xm, ym, za], dims=alg.D2_D1)
+
+    # PQ encode the LMS
+    pqlms = util.pq_st2084_oetf(lms, m2=m2)
+
+    # Calculate Izazbz
+    return alg.matmul(lms_matrix, pqlms, dims=alg.D2_D1)
+
+
+def izazbz_to_xyz_d65(izazbz: Vector, lms_matrix: Matrix, m2: float) -> Vector:
+    """Izazbz to absolute XYZ."""
 
     # Convert to LMS prime
-    pqlms = alg.matmul(izazbz_to_lms_p_mi, [iz, az, bz], dims=alg.D2_D1)
+    pqlms = alg.matmul(lms_matrix, izazbz, dims=alg.D2_D1)
 
     # Decode PQ LMS to LMS
-    lms = util.pq_st2084_eotf(pqlms, m2=M2)
+    lms = util.pq_st2084_eotf(pqlms, m2=m2)
 
     # Convert back to absolute XYZ D65
-    xm, ym, za = alg.matmul(lms_to_xyz_mi, lms, dims=alg.D2_D1)
+    xm, ym, za = alg.matmul(LMS_TO_XYZ, lms, dims=alg.D2_D1)
     xa = (xm + ((B - 1) * za)) / B
     ya = (ym + ((G - 1) * xa)) / G
 
-    # Convert back to normal XYZ D65
-    return util.absxyz_to_xyz([xa, ya, za], YW)
+    return [xa, ya, za]
 
 
-def xyz_d65_to_jzazbz(xyzd65: Vector) -> Vector:
-    """From XYZ to Jzazbz."""
+def jzazbz_to_xyz_d65(jzazbz: Vector) -> Vector:
+    """From Jzazbz to XYZ."""
 
-    # Convert from XYZ D65 to an absolute XYZ D5
-    xa, ya, za = util.xyz_to_absxyz(xyzd65, YW)
-    xm = (B * xa) - ((B - 1) * za)
-    ym = (G * ya) - ((G - 1) * xa)
+    jz, az, bz = jzazbz
 
-    # Convert to LMS
-    lms = alg.matmul(xyz_to_lms_m, [xm, ym, za], dims=alg.D2_D1)
+    # Calculate Iz
+    iz = alg.zdiv((jz + D0), (1 + D - D * (jz + D0)))
 
-    # PQ encode the LMS
-    pqlms = util.pq_st2084_oetf(lms, m2=M2)
+    # Convert back to normal XYZ D65
+    return util.absxyz_to_xyz(izazbz_to_xyz_d65([iz, az, bz], IZAZBZ_TO_LMS_P, M2), YW)
 
-    # Calculate Izazbz
-    iz, az, bz = alg.matmul(lms_p_to_izazbz_m, pqlms, dims=alg.D2_D1)
+
+def xyz_d65_to_jzazbz(xyzd65: Vector) -> Vector:
+    """From XYZ to Jzazbz."""
+
+    iz, az, bz = xyz_d65_to_izazbz(util.xyz_to_absxyz(xyzd65, YW), LMS_P_TO_IZAZBZ,  M2)
 
     # Calculate Jz
     jz = ((1 + D) * iz) / (1 + (D * iz)) - D0
diff --git a/coloraide/spaces/zcam_jmh.py b/coloraide/spaces/zcam_jmh.py
new file mode 100644
index 00000000..c184b2eb
--- /dev/null
+++ b/coloraide/spaces/zcam_jmh.py
@@ -0,0 +1,444 @@
+"""
+ZCAM.
+
+```
+- ZCAM: https://opg.optica.org/oe/fulltext.cfm?uri=oe-29-4-6036&id=447640.
+- Supplemental ZCAM (inverse transform): https://opticapublishing.figshare.com/articles/journal_contribution/\
+  Supplementary_document_for_ZCAM_a_psychophysical_model_for_colour_appearance_prediction_-_5022171_pdf/13640927.
+- Two-stage chromatic adaptation by Qiyan Zhai and Ming R. Luo using CAM02: https://opg.optica.org/oe/\
+  fulltext.cfm?uri=oe-26-6-7724&id=383537
+```
+"""
+from __future__ import annotations
+import math
+import bisect
+from .. import util
+from .. import algebra as alg
+from ..spaces import Space
+from ..cat import WHITES
+from ..channels import Channel, FLG_ANGLE
+from ..types import Vector, VectorLike
+from .lch import LCh, ACHROMATIC_THRESHOLD
+from .jzazbz import izazbz_to_xyz_d65, xyz_d65_to_izazbz
+from .. import cat
+
+DEF_ILLUMINANT_BI = util.xyz_to_absxyz(util.xy_to_xyz(cat.WHITES['2deg']['E']), yw=100.0)
+CAT02 = cat.CAT02.MATRIX
+CAT02_INV = alg.inv(CAT02)
+
+# ZCAM uses a slightly different matrix than Jzazbz
+# It updates how `Iz` is calculated.
+LMS_P_TO_IZAZBZ = [
+    [0.0, 1.0, 0.0],
+    [3.524, -4.066708, 0.542708],
+    [0.199076, 1.096799, -1.295875]
+]
+IZAZBZ_TO_LMS_P = alg.inv(LMS_P_TO_IZAZBZ)
+
+SURROUND = {
+    'dark': (0.8, 0.525, 0.8),
+    'dim': (0.9, 0.59, 0.9),
+    'average': (1, 0.69, 1)
+}
+
+HUE_QUADRATURE = {
+    # Red, Yellow, Green, Blue, Red
+    "h": (33.44, 89.29, 146.30, 238.36, 393.44),
+    "e": (0.68, 0.64, 1.52, 0.77, 0.68),
+    "H": (0.0, 100.0, 200.0, 300.0, 400.0)
+}
+
+
+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,
+    xyz_wd: Vector,
+    db: float,
+    dd: float,
+    xyz_wo: Vector = DEF_ILLUMINANT_BI
+) -> Vector:
+    """
+    Use 2 step chromatic adaptation by Qiyan Zhai and Ming R. Luo using CAM02.
+
+    https://opg.optica.org/oe/fulltext.cfm?uri=oe-26-6-7724&id=383537
+
+    `xyz_b`: the sample color
+    `xyz_wb`: input illuminant of the sample color
+    `xyz_wd`: output illuminant
+    `xyz_wo`: the baseline illuminant, by default we use equal energy.
+    """
+
+    yb = xyz_wb[1] / xyz_wo[1]
+    yd = xyz_wd[1] / xyz_wo[1]
+
+    rgb_b = alg.dot(CAT02, xyz_b, dims=alg.D2_D1)
+    rgb_wb = alg.dot(CAT02, xyz_wb, dims=alg.D2_D1)
+    rgb_wd = alg.dot(CAT02, xyz_wd, dims=alg.D2_D1)
+    rgb_wo = alg.dot(CAT02, xyz_wo, dims=alg.D2_D1)
+
+    d_rgb_wb = alg.add(
+        alg.multiply(db * yb, alg.divide(rgb_wo, rgb_wb, dims=alg.D1), dims=alg.SC_D1),
+        1 - db,
+        dims=alg.D1_SC
+    )
+    d_rgb_wd = alg.add(
+        alg.multiply(dd * yd, alg.divide(rgb_wo, rgb_wd, dims=alg.D1), dims=alg.SC_D1),
+        1 - dd,
+        dims=alg.D1_SC
+    )
+    d_rgb = alg.divide(d_rgb_wb, d_rgb_wd, dims=alg.D1)
+    rgb_d = alg.multiply(d_rgb, rgb_b, dims=alg.D1)
+    return alg.dot(CAT02_INV, rgb_d, dims=alg.D2_D1)
+
+
+class Environment:
+    """
+    Class to calculate and contain any required environmental data (viewing conditions included).
+
+    While originally for CIECAM models, the following applies to ZCAM as well.
+    Usage Guidelines for CIECAM97s (Nathan Moroney)
+    https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf
+
+    white: This is the (x, y) chromaticity points for the white point. ZCAM is designed to use D65.
+        Generally, D65 should always be used, but we allow the possibility of variants of D65. This should
+        be the same value as set in the color class `WHITE` value.
+
+    ref_white: The reference white in XYZ scaled by 100.
+
+    adapting_luminance: This is the 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,
+        reference_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.output_white = util.xyz_to_absxyz(util.xy_to_xyz(white), yw=100)
+        self.ref_white = list(reference_white)
+        self.surround = surround
+        self.discounting = discounting
+        xyz_w = self.ref_white
+
+        # 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.
+        yw = xyz_w[1]
+        self.fb = math.sqrt(self.yb / yw)
+        self.fl = 0.171 * alg.nth_root(self.la, 3) * (1 - math.exp((-48 / 9) * self.la))
+
+        # Surround: dark, dim, and average
+        f, self.c, _ = SURROUND[self.surround]
+        self.fs = self.c
+        self.epsilon = 3.7035226210190005e-11
+        self.rho = 1.7 * 2523 / (2 ** 5)
+        self.b = 1.15
+        self.g = 0.66
+
+        self.izw = xyz_d65_to_izazbz(xyz_w, LMS_P_TO_IZAZBZ, self.rho)[0] - self.epsilon
+        self.qzw = (
+            2700 * alg.spow(self.izw, (1.6 * self.fs) / (self.fb ** 0.12)) *
+            ((self.fs ** 2.2) * (self.fb ** 0.5) * (self.fl ** 0.2))
+        )
+
+        # Degree of adaptation calculating if not discounting illuminant (assumed eye is fully adapted)
+        self.d = alg.clamp(f * (1 - 1 / 3.6 * math.exp((-self.la - 42) / 92)), 0, 1) if not self.discounting else 1
+
+
+def zcam_to_xyz_d65(
+    Jz: float | None = None,
+    Cz: float | None = None,
+    hz: float | None = None,
+    Qz: float | None = None,
+    Mz: float | None = None,
+    Sz: float | None = None,
+    Vz: float | None = None,
+    Kz: float | None = None,
+    Wz: float | None = None,
+    Hz: float | None = None,
+    env: Environment | None = None
+) -> Vector:
+    """
+    From ZCAM to XYZ.
+
+    Reverse calculation can actually be obtained from a small subset of the ZCAM components
+    Really, only one suitable value is needed for each type of attribute: (lightness/brightness),
+    (chroma/colorfulness/saturation), (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 ((Jz is not None) ^ (Qz is not None)):
+        raise ValueError("Conversion requires one and only one: 'Jz' or 'Qz'")
+
+    if not (
+        (Cz is not None) ^ (Mz is not None) ^ (Sz is not None) ^ (Vz is not None) ^ (Kz is not None) ^ (Wz is not None)
+    ):
+        raise ValueError("Conversion requires one and only one: 'Cz', 'Mz', 'Sz', 'Vz', 'Kz', or 'Wz'")
+
+    # Hue is absolutely required
+    if not ((hz is not None) ^ (Hz is not None)):
+        raise ValueError("Conversion requires one and only one: 'hz' or 'Hz'")
+
+    # We need viewing conditions
+    if env is None:
+        raise ValueError("No viewing conditions/environment provided")
+
+    # Black
+    if Jz == 0.0 or Qz == 0.0:
+        return [0.0, 0.0, 0.0]
+
+    # Break hue into Cartesian components
+    h_rad = 0.0
+    if hz is None:
+        hz = inv_hue_quadrature(Hz)  # type: ignore[arg-type]
+    h_rad = math.radians(hz % 360)
+    cos_h = math.cos(h_rad)
+    sin_h = math.sin(h_rad)
+    hp = hz
+    if hp <= HUE_QUADRATURE['h'][0]:
+        hp += 360
+    ez = 1.015 + math.cos(math.radians(89.038 + hp))
+
+    # Calculate `iz` from one of the lightness derived coordinates.
+    if Qz is None:
+        Qz = (Jz * 0.01) * env.qzw  # type: ignore[operator]
+
+    if Jz is None:
+        Jz = 100 * (Qz / env.qzw)
+
+    iz = alg.nth_root(
+        Qz / ((env.fs ** 2.2) * (env.fb ** 0.5) * (env.fl ** 0.2) * 2700), (1.6 * env.fs) / (env.fb ** 0.12)
+    )
+
+    # Calculate `Mz` from the various chroma like parameters.
+    if Sz is not None:
+        Cz = Qz * Sz ** 2 / (100 * env.qzw * env.fl ** 1.2)
+    elif Vz is not None:
+        Cz = math.sqrt((Vz ** 2 - (Jz - 58) ** 2) / 3.4)
+    elif Kz is not None:
+        Cz = math.sqrt((((Kz - 100) / - 0.8) ** 2 - (Jz ** 2)) / 8)
+    elif Wz is not None:
+        Cz = math.sqrt((Wz - 100) ** 2 - (100 - Jz) ** 2)
+
+    if Cz is not None:
+        Mz = (Cz / 100) * env.qzw
+
+    Czp = alg.spow(
+        (Mz * (env.izw ** (0.78)) * (env.fb ** 0.1)) / (100 * (ez ** 0.068) * (env.fl ** 0.2)),
+        1.0 / 0.37 / 2
+    )
+
+    # Convert back to XYZ
+    az, bz = cos_h * Czp, sin_h * Czp
+    iz += env.epsilon
+    xyz_abs = izazbz_to_xyz_d65([iz, az, bz], IZAZBZ_TO_LMS_P, env.rho)
+
+    return util.absxyz_to_xyz(adapt(xyz_abs, env.output_white, env.ref_white, env.d, env.d))
+
+
+def xyz_d65_to_zcam(xyzd65: Vector, env: Environment, calc_hue_quadrature: bool = False) -> Vector:
+    """From XYZ to ZCAM."""
+
+    # Steps 4 - 7
+    iz, az, bz = xyz_d65_to_izazbz(
+        adapt(util.xyz_to_absxyz(xyzd65), env.ref_white, env.output_white, env.d, env.d),
+        LMS_P_TO_IZAZBZ,
+        env.rho
+    )
+
+    # Step 8
+    iz -= env.epsilon
+
+    # Step 9
+    hz = util.constrain_hue(math.degrees(math.atan2(bz, az)))
+
+    # Step 10
+    Hz = hue_quadrature(hz) if calc_hue_quadrature else alg.NaN
+
+    # Step 11
+    hp = hz
+    if hp <= HUE_QUADRATURE['h'][0]:
+        hp += 360
+    ez = 1.015 + math.cos(math.radians(89.038 + hp))
+
+    # Step 12
+    Qz = (
+        2700 * alg.spow(iz, (1.6 * env.fs) / (env.fb ** 0.12)) *
+        ((env.fs ** 2.2) * (env.fb ** 0.5) * (env.fl ** 0.2))
+    )
+
+    # Step 13
+    Jz = 100 * (Qz / env.qzw)
+
+    # Step 14
+    Mz = (
+        100 * ((az ** 2 + bz ** 2) ** (0.37)) *
+        ((alg.spow(ez, 0.068) * (env.fl ** 0.2)) / ((env.fb ** 0.1) * alg.spow(env.izw, 0.78)))
+    )
+
+    # Step 15
+    Cz = 100 * (Mz / env.qzw)
+
+    # Step 16
+    Sz = 100 * (env.fl ** 0.6) * math.sqrt(Mz / Qz) if Qz else 0.0
+
+    # Step 17
+    Vz = math.sqrt((Jz - 58) ** 2 + 3.4 * (Cz ** 2))
+
+    # Step 18
+    Kz = 100 - 0.8 * math.sqrt(Jz ** 2 + 8 * (Cz ** 2))
+
+    # Step 19
+    Wz = 100 - math.sqrt((100 - Jz) ** 2 + Cz ** 2)
+
+    return [Jz, Cz, hz, Qz, Mz, Sz, Vz, Kz, Wz, Hz]
+
+
+def xyz_d65_to_zcam_jmh(xyzd65: Vector, env: Environment) -> Vector:
+    """XYZ to ZCAM JMh."""
+
+    zcam = xyz_d65_to_zcam(xyzd65, env)
+    Jz, Mz, hz = zcam[0], zcam[4], zcam[2]
+    return [Jz, Mz, hz]
+
+
+def zcam_jmh_to_xyz_d65(jmh: Vector, env: Environment) -> Vector:
+    """ZCAM JMh to XYZ."""
+
+    Jz, Mz, hz = jmh
+    return zcam_to_xyz_d65(Jz=Jz, Mz=Mz, hz=hz, env=env)
+
+
+class ZCAMJMh(LCh, Space):
+    """ZCAM class (JMh)."""
+
+    BASE = "xyz-d65"
+    NAME = "zcam-jmh"
+    SERIALIZE = ("--zcam-jmh",)
+    CHANNEL_ALIASES = {
+        "lightness": "jz",
+        "colorfulness": 'mz',
+        "hue": 'hz',
+        'j': 'jz',
+        'm': "mz",
+        'h': 'hz'
+    }
+    WHITE = WHITES['2deg']['D65']
+    DYNAMIC_RANGE = 'hdr'
+
+    # Assuming sRGB which has a lux of 64
+    ENV = Environment(
+        # D65 white point.
+        white=WHITE,
+        # The reference white in XYZ scaled by 100
+        reference_white=util.xyz_to_absxyz(util.xy_to_xyz(WHITE), 100),
+        # 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,
+        # 20% relative to an XYZ luminance of 100 (scaled by 100) for the gray world assumption.
+        background_luminance=20,
+        # Assume an average surround
+        surround='average',
+        # Do not discount illuminant.
+        discounting=False
+    )
+    CHANNELS = (
+        Channel("jz", 0.0, 100.0),
+        Channel("mz", 0, 60.0),
+        Channel("hz", 0.0, 360.0, flags=FLG_ANGLE)
+    )
+
+    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 is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
+
+        # Account for both positive and negative chroma
+        return coords[0] == 0 or abs(coords[1]) < ACHROMATIC_THRESHOLD
+
+    def hue_name(self) -> str:
+        """Hue name."""
+
+        return "hz"
+
+    def to_base(self, coords: Vector) -> Vector:
+        """From ZCAM JMh to XYZ."""
+
+        return zcam_jmh_to_xyz_d65(coords, self.ENV)
+
+    def from_base(self, coords: Vector) -> Vector:
+        """From XYZ to ZCAM JMh."""
+
+        return xyz_d65_to_zcam_jmh(coords, self.ENV)
diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt
index 22d66eb6..33590b35 100644
--- a/docs/src/dictionary/en-custom.txt
+++ b/docs/src/dictionary/en-custom.txt
@@ -98,6 +98,7 @@ Interpolator
 Itten
 Iz
 Izazbz
+Izazbz
 JCh
 JMh
 JND
@@ -107,8 +108,10 @@ Jsh
 Judd
 Jz
 JzCzhz
+JzMzhz
 Jzazbz
 Kries
+Kz
 LCh
 LChish
 LChuv
@@ -117,6 +120,7 @@ Lab
 Labish
 Lilley
 Lstar
+Luo
 Luv
 MATLAB
 MERCHANTABILITY
@@ -128,6 +132,7 @@ MkDocs
 Mollon
 Monochromacy
 Moroney
+Mz
 NONINFRINGEMENT
 NaN
 NaNs
@@ -154,7 +159,10 @@ PyPI
 Pyodide
 QCh
 QMh
+Qiyan
 Qsh
+Qz
+QzMzhz
 RGB
 RGBish
 RLAB
@@ -167,8 +175,10 @@ SL
 SMPTE
 SRGB
 SVG
+Safdar
 Scalable
 Sharma
+Sz
 TODO
 TORTIOUS
 Tetradic
@@ -189,14 +199,18 @@ Verou
 Viénot
 Von
 Vos
+Vz
 WCAG
 WCG
+Wz
 XD
 XYB
 XYZ
 YCbCr
 Yoshi
+ZCAM
 ZD
+Zhai
 absolutizing
 accessor
 accessors
diff --git a/docs/src/markdown/colors/index.md b/docs/src/markdown/colors/index.md
index 2bdffa40..ff06cec7 100644
--- a/docs/src/markdown/colors/index.md
+++ b/docs/src/markdown/colors/index.md
@@ -85,6 +85,8 @@ flowchart TB
         cam16-jmh --- cam16-scd
         cam16-jmh --- cam16-lcd
 
+    xyz-d65 --- zcam-jmh
+
     xyz-d65 --- hct
 
     xyz-d65 --- jzazbz --- jzczhz
@@ -168,6 +170,7 @@ flowchart TB
     xyb(XYB)
     ryb(RYB)
     cubehelix(Cubehelix)
+    zcam-jmh(ZCAM JMh)
 
     click xyz-d65 "./xyz_d65/" _self
     click xyz-d50 "./xyz_d50/" _self
@@ -228,6 +231,7 @@ flowchart TB
     click xyb "./xyb/" _self
     click ryb "./ryb/" _self
     click cubehelix "./cubehelix/" _self
+    click zcam-jmh "./zcam_jmh/" _self
 ```
 ///
 
@@ -253,7 +257,7 @@ Color Space                                     | ID
 [Cubehelix](./cubehelix.md)                     | `cubehelix`
 [DIN99o](./din99o.md)                           | `din99o`
 [Display P3](./display_p3.md)                   | `display-p3`
-[Linear Display P3](.)                          | `display-p3-linear`
+[Linear Display P3](./display_p3_linear.md)     | `display-p3-linear`
 [HCT](./hct.md)                                 | `hct`
 [HPLuv](./hpluv.md)                             | `hpluv`
 [HSI](./hsi.md)                                 | `hsi`
@@ -297,3 +301,4 @@ Color Space                                     | ID
 [xyY](./xyy.md)                                 | `xyy`
 [XYZ (D50)](./xyz_d50.md)                       | `xyz-d50`
 [XYZ (D65)](./xyz_d65.md)                       | `xyz-d65`
+[ZCAM JMh](./zcam_jmh.md)                       | `zcam-jmh`
diff --git a/docs/src/markdown/colors/zcam_jmh.md b/docs/src/markdown/colors/zcam_jmh.md
new file mode 100644
index 00000000..de8a32d3
--- /dev/null
+++ b/docs/src/markdown/colors/zcam_jmh.md
@@ -0,0 +1,149 @@
+# ZCAM JMh
+
+/// failure | The ZCAM JMh color space is not registered in `Color` by default
+///
+
+/// html | div.info-container
+//// info | Properties
+    attrs: {class: inline end}
+
+**Name:** `zcam-jmh`
+
+**White Point:** D65 / 2˚
+
+**Coordinates:**
+
+Name | Range^\*^
+---- | -----
+`jz`  | [0, 100]
+`mz`  | [0, 60]
+`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.
+////
+
+//// html | figure
+![ZCAM JMh](../images/zcam-jmh-3d.png)
+
+///// html | figcaption
+The sRGB gamut represented within the ZCAM 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.
+
+ZCAM is a CAM model that builds off earlier work done with [Jzazbz](./jzazbz.md) by Safdar. It uses a Perceptual
+Quantizer (PQ) curve, developed to uniformly encode a luminance range of 0.001 to 10,000 cd/m2.
+
+The model defines numerous different attributes:
+
+Name | Description
+---- | -----------
+Jz   | Lightness
+Cz   | Chroma
+hz   | hue
+Qz   | Brightness
+Mz   | Colorfulness
+Sz   | Saturation
+Vz   | Vividness
+Kz   | Blackness
+Wz   | Whiteness
+Hz   | Hue Quadrature
+
+A color space can be constructed by using a subset of these attributes: JzCzhz, JzMzhz, QzMzhz, etc. The provided color
+spaces uses JzMzhz.
+
+[Learn more](https://opg.optica.org/oe/fulltext.cfm?uri=oe-29-4-6036&id=447640).
+///
+
+## Viewing Conditions
+
+ZCAM is a color appearance model and can be configured with different viewing environments. A ZCAM 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. 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 the reference white's luminance (`Y`). Usually 20 providing a gray world assumption.
+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 ZCAM comes with a default set of viewing conditions that uses a D65 white
+point, a reference white that uses D65 scaled to 100, 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 ZCAM 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.zcam_jmh import ZCAMJMh, Environment
+from coloraide.cat import WHITES
+from coloraide import util
+import math
+
+cdm2 = 1000 / math.pi
+
+class CustomZCAMJMh(ZCAMJMh):
+    NAME = "zcam-custom"
+    SERIALIZE = ("--zcam-custom",)
+    WHITE = WHITES['2deg']['D65']
+    ENV = Environment(
+        white=WHITE,
+        reference_white=[c * cdm2 for c in util.xy_to_xyz(WHITE)],
+        adapting_luminance=cdm2,
+        background_luminance=100,
+        surround='average',
+        discounting=False
+    )
+
+class Color(Base): ...
+
+Color.register([ZCAMJMh(), CustomZCAMJMh()])
+
+Color('red').convert('zcam-jmh')
+Color('red').convert('zcam-custom')
+```
+
+## Channel Aliases
+
+Channels | Aliases
+-------- | -------
+`jz`      | `lightness`, `j`
+`mz`      | `colorfulness`, `m`
+`hz`      | `hue`, `h`
+
+## Input/Output
+
+The ZCAM 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 --zcam-jmh`:
+
+```css-color
+color(--zcam-jmh jz mz hz / a)  // Color function
+```
+
+The string representation of the color object and the default string output use the
+`#!css-color color(--zcam-jmh jz mz hz / a)` form.
+
+```py play
+Color("zcam-jmh", [51.197, 43.776, 42.477], 1)
+Color("zcam-jmh", [71.271, 32.313, 75.038], 1).to_string()
+```
+
+## Registering
+
+```py
+from coloraide import Color as Base
+from coloraide_extras.spaces.zcam_jmh import ZCAMJMh
+
+class Color(Base): ...
+
+Color.register(ZCAMJMh())
+```
diff --git a/docs/src/markdown/demos/3d_models.html b/docs/src/markdown/demos/3d_models.html
index 867f2777..58ce033c 100644
--- a/docs/src/markdown/demos/3d_models.html
+++ b/docs/src/markdown/demos/3d_models.html
@@ -899,7 +899,7 @@ <h1>ColorAide Color Space Models</h1>
 let colorSpaces = null
 let colorGamuts = null
 let lastModel = null
-let package = 'coloraide-3.1.2-py3-none-any.whl'
+let package = 'coloraide-3.2-py3-none-any.whl'
 const defaultSpace = 'lab'
 const defaultGamut = 'srgb'
 const exceptions = new Set(['hwb', 'ryb', 'ryb-biased'])
diff --git a/docs/src/markdown/demos/colorpicker.html b/docs/src/markdown/demos/colorpicker.html
index 2217c92f..73d1aba1 100644
--- a/docs/src/markdown/demos/colorpicker.html
+++ b/docs/src/markdown/demos/colorpicker.html
@@ -421,7 +421,7 @@ <h1>ColorAide Color Picker</h1>
     let pyodide = null
     let webspace = ''
     let initial = 'oklab(0.69 0.13 -0.1 / 0.85)'
-    let package = 'coloraide-3.1.2-py3-none-any.whl'
+    let package = 'coloraide-3.2-py3-none-any.whl'
 
     const base = `${window.location.origin}/${window.location.pathname.split('/')[1]}/playground/`
     package = base + package
diff --git a/docs/src/markdown/images/zcam-jmh-3d.png b/docs/src/markdown/images/zcam-jmh-3d.png
new file mode 100644
index 00000000..e65dbadf
Binary files /dev/null and b/docs/src/markdown/images/zcam-jmh-3d.png differ
diff --git a/docs/src/mkdocs.yml b/docs/src/mkdocs.yml
index 300a9a9f..97fb3d74 100644
--- a/docs/src/mkdocs.yml
+++ b/docs/src/mkdocs.yml
@@ -114,6 +114,7 @@ nav:
         - JzCzhz: colors/jzczhz.md
         - CAM16 JMh: colors/cam16_jmh.md
         - HCT: colors/hct.md
+        - ZCAM JMh: colors/zcam_jmh.md
 
       - ACES Spaces:
         - ACES 2065-1: colors/aces2065_1.md
@@ -306,7 +307,7 @@ extra_css:
   - assets/coloraide-extras/extra.css
 extra_javascript:
   - https://unpkg.com/mermaid@10.6.1/dist/mermaid.min.js
-  - playground-config-410dc711.js
+  - playground-config-6f907977.js
   - https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js
   - assets/coloraide-extras/extra-notebook.js
 
diff --git a/docs/theme/playground-config-410dc711.js b/docs/theme/playground-config-6f907977.js
similarity index 76%
rename from docs/theme/playground-config-410dc711.js
rename to docs/theme/playground-config-6f907977.js
index 6e84393f..c8ad6270 100644
--- a/docs/theme/playground-config-410dc711.js
+++ b/docs/theme/playground-config-6f907977.js
@@ -1,5 +1,5 @@
 var colorNotebook = {
-    "playgroundWheels": ['Pygments-2.16.1-py3-none-any.whl', 'coloraide-3.1.2-py3-none-any.whl'],
-    "notebookWheels": ['pyyaml', 'Markdown-3.5.1-py3-none-any.whl', 'pymdown_extensions-10.5-py3-none-any.whl', 'Pygments-2.16.1-py3-none-any.whl', 'coloraide-3.1.2-py3-none-any.whl'],
+    "playgroundWheels": ['Pygments-2.16.1-py3-none-any.whl', 'coloraide-3.2-py3-none-any.whl'],
+    "notebookWheels": ['pyyaml', 'Markdown-3.5.1-py3-none-any.whl', 'pymdown_extensions-10.5-py3-none-any.whl', 'Pygments-2.16.1-py3-none-any.whl', 'coloraide-3.2-py3-none-any.whl'],
     "defaultPlayground": "import coloraide\ncoloraide.__version__\nColor('red')"
 }
diff --git a/mkdocs.yml b/mkdocs.yml
index d78f7c19..fd13730f 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -114,6 +114,7 @@ nav:
         - JzCzhz: colors/jzczhz.md
         - CAM16 JMh: colors/cam16_jmh.md
         - HCT: colors/hct.md
+        - ZCAM JMh: colors/zcam_jmh.md
 
       - ACES Spaces:
         - ACES 2065-1: colors/aces2065_1.md
@@ -306,7 +307,7 @@ extra_css:
   - assets/coloraide-extras/extra-728132a6d6.css
 extra_javascript:
   - https://unpkg.com/mermaid@10.6.1/dist/mermaid.min.js
-  - playground-config-410dc711.js
+  - playground-config-6f907977.js
   - https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js
   - assets/coloraide-extras/extra-notebook-KS-5jx8t.js
 
diff --git a/tools/gen_3d_models.py b/tools/gen_3d_models.py
index 96613f8a..b6365cd5 100644
--- a/tools/gen_3d_models.py
+++ b/tools/gen_3d_models.py
@@ -72,7 +72,8 @@ def plot_model(name, title, filename, gamut='srgb', elev=45, azim=-60.0):
     'ucs': {'title': TEMPLATE.format('UCS'), 'filename': 'ucs-3d.png', 'azim': 60, 'elev': 10},
     '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'}
+    'cubehelix': {'title': TEMPLATE.format('Cubehelix'), 'filename': 'cubehelix-3d.png'},
+    'zcam-jmh': {'title': TEMPLATE.format('ZCAM JMh'), 'filename': 'zcam-jmh-3d.png', 'azim': 320},
 }
 
 


Test patch:
diff --git a/tests/test_cam16_jmh.py b/tests/test_cam16_jmh.py
index c4915a27..0c40a819 100644
--- a/tests/test_cam16_jmh.py
+++ b/tests/test_cam16_jmh.py
@@ -2,7 +2,7 @@
 import unittest
 from . import util
 from coloraide.everything import ColorAll as Color, NaN
-from coloraide.spaces.cam16_jmh import cam16_to_xyz_d65, CAM16JMh
+from coloraide.spaces.cam16_jmh import cam16_to_xyz_d65, xyz_d65_to_cam16, CAM16JMh
 from collections import namedtuple
 import pytest
 
@@ -59,13 +59,13 @@ def test_h(self):
         c['h'] = 270
         self.assertEqual(c['h'], 270)
 
-    def test_c(self):
+    def test_m(self):
         """Test `m`."""
 
-        c = Color('color(--cam16-jmh 97.139 75.504 111.05)')
-        self.assertEqual(c['m'], 75.504)
-        c['m'] = 30
-        self.assertEqual(c['m'], 30)
+        m = Color('color(--cam16-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`."""
@@ -102,13 +102,13 @@ def test_none_input(self):
     def test_null_normalization_min_chroma(self):
         """Test minimum chroma."""
 
-        c = Color(Color('cam16-jmh', [20, 0, 270]).convert('cam16-jmh').to_string()).normalize()
+        c = Color(Color('cam16-jmh', [20, 0, 270]).to_string()).normalize()
         self.assertTrue(c.is_nan('hue'))
 
     def test_null_normalization_negative_chroma(self):
         """Test negative chroma."""
 
-        c = Color(Color('cam16-jmh', [20, -5, 270]).convert('cam16-jmh').to_string()).normalize()
+        c = Color(Color('cam16-jmh', [20, -5, 270]).to_string()).normalize()
         self.assertTrue(c.get('hue'), 270 + 180)
 
 
@@ -195,3 +195,20 @@ def test_hue_convert(self):
             cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, H=self.COORDS.H, env=CAM16JMh.ENV)
         ):
             self.assertCompare(a, b, 14)
+
+    def test_hue_quadrature(self):
+        """Test conversion to and from test_hue_quadrature."""
+
+        xyz = cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, H=self.COORDS.H, env=CAM16JMh.ENV)
+        coords = xyz_d65_to_cam16(xyz, CAM16JMh.ENV, True)
+        self.assertCompare(self.COORDS.H, coords[-1], 14)
+
+    def test_hue_quadrature_low_end(self):
+        """Exercise when the hue quadrature is on the low end."""
+
+        xyz = Color('pink').convert('xyz-d65')
+        coords = CAM16Coords(*xyz_d65_to_cam16(xyz.coords(), CAM16JMh.ENV, True))
+        self.assertCompare(386.0860840290135, coords[-1])
+        xyz2 = cam16_to_xyz_d65(J=coords.J, M=coords.M, H=coords.H, env=CAM16JMh.ENV)
+        for a, b in zip(xyz, xyz2):
+            self.assertCompare(a, b, 14)
diff --git a/tests/test_zcam_jmh.py b/tests/test_zcam_jmh.py
new file mode 100644
index 00000000..3c78c138
--- /dev/null
+++ b/tests/test_zcam_jmh.py
@@ -0,0 +1,232 @@
+"""Test ZCAM JMh."""
+import unittest
+from . import util
+from coloraide.everything import ColorAll as Color, NaN
+from coloraide.spaces.zcam_jmh import zcam_to_xyz_d65, xyz_d65_to_zcam, ZCAMJMh
+from collections import namedtuple
+import pytest
+
+ZCAMCoords = namedtuple("ZCAMCoords", "Jz Cz hz Qz Mz Sz Vz Kz Wz Hz")
+
+
+class TestZCAMJMh(util.ColorAssertsPyTest):
+    """Test ZCAM JMh."""
+
+    COLORS = [
+        ('red', 'color(--zcam-jmh 51.197 43.776 42.477)'),
+        ('orange', 'color(--zcam-jmh 71.271 32.313 75.038)'),
+        ('yellow', 'color(--zcam-jmh 92.838 33.644 101.8)'),
+        ('green', 'color(--zcam-jmh 38.754 31.201 133.24)'),
+        ('blue', 'color(--zcam-jmh 43.495 55.652 258.81)'),
+        ('indigo', 'color(--zcam-jmh 23.243 34.446 287.19)'),
+        ('violet', 'color(--zcam-jmh 72.267 30.713 319.27)'),
+        ('white', 'color(--zcam-jmh 100 0.35188 216.08)'),
+        ('gray', 'color(--zcam-jmh 49.965 0.2662 216.08)'),
+        ('black', 'color(--zcam-jmh 0 0 0)'),
+        # Test color
+        ('color(--zcam-jmh 50 30 270)', 'color(--zcam-jmh 50 30 270)'),
+        ('color(--zcam-jmh 50 30 270 / 0.5)', 'color(--zcam-jmh 50 30 270 / 0.5)'),
+        ('color(--zcam-jmh 50% 50% 100 / 50%)', 'color(--zcam-jmh 50 30 100 / 0.5)'),
+        ('color(--zcam-jmh none none none / none)', 'color(--zcam-jmh none none none / none)'),
+        # Test percent ranges
+        ('color(--zcam-jmh 0% 0% 0)', 'color(--zcam-jmh 0 0 0)'),
+        ('color(--zcam-jmh 100% 100% 100)', 'color(--zcam-jmh 100 60 100)'),
+        ('color(--zcam-jmh -100% -100% 100)', 'color(--zcam-jmh -100 -60 100)'),
+        # Test miscellaneous cases
+        ('color(--zcam-jmh -10 none 270)', 'color(--zcam-jmh -10 0 270)')
+    ]
+
+    @pytest.mark.parametrize('color1,color2', COLORS)
+    def test_colors(self, color1, color2):
+        """Test colors."""
+
+        self.assertColorEqual(Color(color1).convert('zcam-jmh'), Color(color2))
+
+
+class TestZCAMJMhPoperties(util.ColorAsserts, unittest.TestCase):
+    """Test CAM16 JMh properties."""
+
+    def test_names(self):
+        """Test LCh-ish names."""
+
+        self.assertEqual(Color('color(--zcam-jmh 97.139 75.504 111.05)')._space.names(), ('jz', 'mz', 'hz'))
+
+    def test_hz(self):
+        """Test `hz`."""
+
+        c = Color('color(--zcam-jmh 97.139 75.504 111.05)')
+        self.assertEqual(c['hz'], 111.05)
+        c['h'] = 270
+        self.assertEqual(c['hz'], 270)
+
+    def test_mz(self):
+        """Test `mz`."""
+
+        m = Color('color(--zcam-jmh 97.139 75.504 111.05)')
+        self.assertEqual(m['mz'], 75.504)
+        m['m'] = 30
+        self.assertEqual(m['mz'], 30)
+
+    def test_jz(self):
+        """Test `jz`."""
+
+        c = Color('color(--zcam-jmh 97.139 75.504 111.05)')
+        self.assertEqual(c['jz'], 97.139)
+        c['j'] = 50
+        self.assertEqual(c['jz'], 50)
+
+    def test_alpha(self):
+        """Test `alpha`."""
+
+        c = Color('color(--zcam-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('zcam-jmh', [30, 20, NaN], 1)
+        self.assertTrue(c.is_nan('hue'))
+
+    def test_none_input(self):
+        """Test `none` null."""
+
+        c = Color('color(--zcam-jmh 30 20 none / 1)')
+        self.assertTrue(c.is_nan('hue'))
+
+    def test_null_normalization_min_chroma(self):
+        """Test minimum chroma."""
+
+        c = Color(Color('zcam-jmh', [20, 0, 270]).convert('zcam-jmh').to_string()).normalize()
+        self.assertTrue(c.is_nan('hue'))
+
+    def test_null_normalization_negative_chroma(self):
+        """Test negative chroma."""
+
+        c = Color(Color('zcam-jmh', [20, -5, 270]).convert('zcam-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('zcam-jmh', [NaN, 0.00001, 270]).is_achromatic(), True)
+        self.assertEqual(Color('zcam-jmh', [0, NaN, 270]).is_achromatic(), True)
+        self.assertEqual(Color('zcam-jmh', [0, 50, 270]).is_achromatic(), True)
+        self.assertEqual(Color('zcam-jmh', [NaN, 50, 270]).is_achromatic(), True)
+        self.assertEqual(Color('zcam-jmh', [20, NaN, 270]).is_achromatic(), True)
+        self.assertEqual(Color('zcam-jmh', [NaN, NaN, 270]).is_achromatic(), True)
+        self.assertEqual(Color('zcam-jmh', [20, -1.3, 90]).is_achromatic(), False)
+
+
+class TestZCAMApperanceModel(util.ColorAsserts, unittest.TestCase):
+    """Test ZCAM appearance model."""
+
+    COORDS = ZCAMCoords(
+        43.49495951236768, 37.06625840802933, 258.81201346495345, 65.3037302338701, 55.651619566827854,
+        42.37206156525153, 69.86903277997048, 9.197104532689465, 32.422436320330604, 311.82891934461054
+    )
+
+    def test_no_lightness(self):
+        """Test conversion failure when no equivalent lightness."""
+
+        with self.assertRaises(ValueError):
+            zcam_to_xyz_d65(Cz=self.COORDS.Cz, hz=self.COORDS.hz, env=ZCAMJMh.ENV)
+
+    def test_no_chroma(self):
+        """Test conversion failure when no equivalent chroma."""
+
+        with self.assertRaises(ValueError):
+            zcam_to_xyz_d65(Jz=self.COORDS.Jz, hz=self.COORDS.hz, env=ZCAMJMh.ENV)
+
+    def test_no_hue(self):
+        """Test conversion failure when no equivalent hue."""
+
+        with self.assertRaises(ValueError):
+            zcam_to_xyz_d65(Jz=self.COORDS.Jz, Cz=self.COORDS.Cz, env=ZCAMJMh.ENV)
+
+    def test_no_environment(self):
+        """Test no test no environment."""
+
+        with self.assertRaises(ValueError):
+            zcam_to_xyz_d65(Jz=self.COORDS.Jz, Cz=self.COORDS.Cz, hz=self.COORDS.hz)
+
+    def test_lightness_convert(self):
+        """Test convert lightness."""
+
+        for a, b in zip(
+            zcam_to_xyz_d65(Jz=self.COORDS.Jz, Cz=self.COORDS.Cz, hz=self.COORDS.hz, env=ZCAMJMh.ENV),
+            zcam_to_xyz_d65(Qz=self.COORDS.Qz, Cz=self.COORDS.Cz, hz=self.COORDS.hz, env=ZCAMJMh.ENV)
+        ):
+            self.assertCompare(a, b, 14)
+
+    def test_chroma_convert(self):
+        """Test convert chroma."""
+
+        for a, b in zip(
+            zcam_to_xyz_d65(Jz=self.COORDS.Jz, Cz=self.COORDS.Cz, hz=self.COORDS.hz, env=ZCAMJMh.ENV),
+            zcam_to_xyz_d65(Qz=self.COORDS.Qz, Sz=self.COORDS.Sz, hz=self.COORDS.hz, env=ZCAMJMh.ENV)
+        ):
+            self.assertCompare(a, b, 14)
+
+        for a, b in zip(
+            zcam_to_xyz_d65(Jz=self.COORDS.Jz, Cz=self.COORDS.Cz, hz=self.COORDS.hz, env=ZCAMJMh.ENV),
+            zcam_to_xyz_d65(Qz=self.COORDS.Qz, Mz=self.COORDS.Mz, hz=self.COORDS.hz, env=ZCAMJMh.ENV)
+        ):
+            self.assertCompare(a, b, 14)
+
+        for a, b in zip(
+            zcam_to_xyz_d65(Jz=self.COORDS.Jz, Cz=self.COORDS.Cz, hz=self.COORDS.hz, env=ZCAMJMh.ENV),
+            zcam_to_xyz_d65(Qz=self.COORDS.Qz, Vz=self.COORDS.Vz, hz=self.COORDS.hz, env=ZCAMJMh.ENV)
+        ):
+            self.assertCompare(a, b, 14)
+
+        for a, b in zip(
+            zcam_to_xyz_d65(Jz=self.COORDS.Jz, Cz=self.COORDS.Cz, hz=self.COORDS.hz, env=ZCAMJMh.ENV),
+            zcam_to_xyz_d65(Qz=self.COORDS.Qz, Kz=self.COORDS.Kz, hz=self.COORDS.hz, env=ZCAMJMh.ENV)
+        ):
+            self.assertCompare(a, b, 14)
+
+        for a, b in zip(
+            zcam_to_xyz_d65(Jz=self.COORDS.Jz, Cz=self.COORDS.Cz, hz=self.COORDS.hz, env=ZCAMJMh.ENV),
+            zcam_to_xyz_d65(Qz=self.COORDS.Qz, Wz=self.COORDS.Wz, hz=self.COORDS.hz, env=ZCAMJMh.ENV)
+        ):
+            self.assertCompare(a, b, 14)
+
+    def test_hue_convert(self):
+        """Test convert hue."""
+
+        for a, b in zip(
+            zcam_to_xyz_d65(Jz=self.COORDS.Jz, Cz=self.COORDS.Cz, hz=self.COORDS.hz, env=ZCAMJMh.ENV),
+            zcam_to_xyz_d65(Jz=self.COORDS.Jz, Cz=self.COORDS.Cz, Hz=self.COORDS.Hz, env=ZCAMJMh.ENV)
+        ):
+            self.assertCompare(a, b, 14)
+
+    def test_hue_quadrature(self):
+        """Test conversion to and from test_hue_quadrature."""
+
+        xyz = zcam_to_xyz_d65(Jz=self.COORDS.Jz, Cz=self.COORDS.Cz, Hz=self.COORDS.Hz, env=ZCAMJMh.ENV)
+        coords = xyz_d65_to_zcam(xyz, ZCAMJMh.ENV, True)
+        self.assertCompare(self.COORDS.Hz, 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 = ZCAMCoords(*xyz_d65_to_zcam(xyz.coords(), ZCAMJMh.ENV, True))
+        self.assertCompare(387.07995, coords[-1])
+        xyz2 = zcam_to_xyz_d65(Jz=coords.Jz, Mz=coords.Mz, Hz=coords.Hz, env=ZCAMJMh.ENV)
+        for a, b in zip(xyz, xyz2):
+            self.assertCompare(a, b, 12)