Repository: facelessuser/coloraide (language: python)

Issue:
CCT related support
Not quite sure how this will look, but I imagine we'd like at the very least a method to create a color from a given temperature in Kelvin and a way to retrieve an approximate temperature for a given color.

There are many different ways to calculate to and from CCT. Some are good for certain ranges, some are more accurate than others, and some don't convert as easily in the reverse direction.


Discussion / hints:
Generating the Planckian Locus from the standard observer, we can generate colors on the black body curve. Colors are currently returned as XYZ values using the calculated x, y chromaticities, normalizing them and then using a Y of 1. This means that the colors are all very bright.

In order to get the colors that other implementations return, you need to take the max of the RGB values and normalize by dividing all the values by that max. Afterward, we can clip any values that fall outside the gamut. Not sure if this should be included in the function, or let the user do this step...

<img width="1333" alt="Screenshot 2023-05-28 at 9 23 39 AM" src="https://github.com/facelessuser/coloraide/assets/1055125/9988f5c3-0ad9-40cf-8a73-d598da4fa690">

I was able to get the Robertson 1968 to work locally. The idea was to use it to get the temperature of a given color. The one problem with it is it is that it starts to break down when you get lower than ~1666.7K.  Ideally, we'd like to target a range from 1000K+. I imagine that if a method had to be capped at some high end we could, we don't have to go as high as 100000K, but it would be nice if we could, even if accuracy does drop some.

The problem is that each method has some strengths and some weaknesses. Robertson has reliability only in the range of 1666.7+ though I imagine you could still go as low as 1500 and still be good enough. Some are only good for 1000 - 25000, or something like that. Some don't really expose getting the DUV, which I'd really like to use as it helps you to rule out some colors that are not close enough to the locus to be considered accurate. Some require some more complex approximation to get the inverse result. I'm not sure if we need the inverse as we have a way already to generate the locus using Planck, but it may be nice to have it reversible. Probably all methods suffer from varying degrees of errors, so selecting one that is good enough is probably fine.

I've seen the Tanner Helland approach that uses a spline to calculate an easy way to test RGB colors, but you don't get the DUV, and it really isn't trying to match a color on the isotherms, it just bisects temperatures on the spline to see which one the color is closest to which can give some non-ideal results. It's probably not a bad way to generate the black body curve though, but we can do that fine with using Planck.

Robertson might be a fine way to start. We also don't need to implement every possible approach under the sun. We aren't trying to compete with things like Colour Science.

One possible approach, if not now, maybe later, is to use the method in Yoshi Ohno's "Practical Use and Calculation of CCT and Duv". The required table could be cached, and it has a range from 1000+ being most accurate around 1000 - 20000. It seems to employ two different approaches, a triangular solver for values close to the locus, and then a parabolic solver further out.

I'm not sure about speed implications in any of these approaches currently, so we'll have to see about practical use cases as well. It also may be that practical applications may not require a DUV, so if that is the case, we could consider alternatives we weren't already considering.
I did get the Ohno approach working. It allows us to get all the way down to 1000 forward and backward. We can also utilize DUV. I haven't tested the speed and such, but I have enough to start evaluating.
Keep in mind the following statements are for correlating a uv pair to a temperature, not a temperature to a uv pair.

As expected, the Ohno method is slower but provides a good range and decent accuracy (depending on the number of data points). Robertson method is much faster, but we can have larger errors in some ranges and is not great further below ~1666.7.

The Ohno paper suggests using a table of 303 data points for reasonable accuracy, but you have to store all those data points in memory. For better accuracy, you'd have to store a lot more. We are currently doing a more dynamic approach which may slow things down a bit. We take X samples across the entire range (1000 - 10000) which essentially does a low-resolution test. By finding the lowest value in those samples and taking its closest neighbors as the new range, we iterate and take another set of samples, essentially a higher resolution test in the new region. We can usually get good results in about 5 iterations.

Assuming 10 samples for each iteration, we can test about 50 iterations instead of testing a full 303 each time, but we are currently recalculating each sample as opposed to using a pre-calculated list. We could definitely use caching in the approach to speed this up. I'm not sure we'd like to store the full set of data for very accurate results. It also depends on the application in which it is being used. Being slower may not be a big deal depending on how it is being used.

Fix patch:
diff --git a/.pyspelling.yml b/.pyspelling.yml
index 708b849e9..bbb7bbc38 100644
--- a/.pyspelling.yml
+++ b/.pyspelling.yml
@@ -56,6 +56,12 @@ matrix:
       ignores:
       - code
       - pre
+  - pyspelling.filters.context:
+      context_visible_first: true
+      delimiters:
+      # Ignore possessive endings
+      - open: '(?<=\w)''s(?!\w)'
+        close: '\b'
   - pyspelling.filters.url:
 
 - name: python
@@ -98,4 +104,6 @@ matrix:
       # Ignore text between inline back ticks
       - open: '(?P<open>`+)'
         close: '(?P=open)'
+      - open: '(?<=\w)''s(?!\w)'
+        close: '\b'
   - pyspelling.filters.url:
diff --git a/coloraide/cmfs.py b/coloraide/cmfs.py
new file mode 100644
index 000000000..20f89bb13
--- /dev/null
+++ b/coloraide/cmfs.py
@@ -0,0 +1,201 @@
+"""Color matching functions."""
+
+# CIE 1931 2 degree observer
+# http://www-cvrl.ucsd.edu/cmfs.htm
+cie_1931_2deg = {
+    360: (0.000129900000, 0.000003917000, 0.000606100000),
+    365: (0.000232100000, 0.000006965000, 0.001086000000),
+    370: (0.000414900000, 0.000012390000, 0.001946000000),
+    375: (0.000741600000, 0.000022020000, 0.003486000000),
+    380: (0.001368000000, 0.000039000000, 0.006450001000),
+    385: (0.002236000000, 0.000064000000, 0.010549990000),
+    390: (0.004243000000, 0.000120000000, 0.020050010000),
+    395: (0.007650000000, 0.000217000000, 0.036210000000),
+    400: (0.014310000000, 0.000396000000, 0.067850010000),
+    405: (0.023190000000, 0.000640000000, 0.110200000000),
+    410: (0.043510000000, 0.001210000000, 0.207400000000),
+    415: (0.077630000000, 0.002180000000, 0.371300000000),
+    420: (0.134380000000, 0.004000000000, 0.645600000000),
+    425: (0.214770000000, 0.007300000000, 1.039050100000),
+    430: (0.283900000000, 0.011600000000, 1.385600000000),
+    435: (0.328500000000, 0.016840000000, 1.622960000000),
+    440: (0.348280000000, 0.023000000000, 1.747060000000),
+    445: (0.348060000000, 0.029800000000, 1.782600000000),
+    450: (0.336200000000, 0.038000000000, 1.772110000000),
+    455: (0.318700000000, 0.048000000000, 1.744100000000),
+    460: (0.290800000000, 0.060000000000, 1.669200000000),
+    465: (0.251100000000, 0.073900000000, 1.528100000000),
+    470: (0.195360000000, 0.090980000000, 1.287640000000),
+    475: (0.142100000000, 0.112600000000, 1.041900000000),
+    480: (0.095640000000, 0.139020000000, 0.812950100000),
+    485: (0.057950010000, 0.169300000000, 0.616200000000),
+    490: (0.032010000000, 0.208020000000, 0.465180000000),
+    495: (0.014700000000, 0.258600000000, 0.353300000000),
+    500: (0.004900000000, 0.323000000000, 0.272000000000),
+    505: (0.002400000000, 0.407300000000, 0.212300000000),
+    510: (0.009300000000, 0.503000000000, 0.158200000000),
+    515: (0.029100000000, 0.608200000000, 0.111700000000),
+    520: (0.063270000000, 0.710000000000, 0.078249990000),
+    525: (0.109600000000, 0.793200000000, 0.057250010000),
+    530: (0.165500000000, 0.862000000000, 0.042160000000),
+    535: (0.225749900000, 0.914850100000, 0.029840000000),
+    540: (0.290400000000, 0.954000000000, 0.020300000000),
+    545: (0.359700000000, 0.980300000000, 0.013400000000),
+    550: (0.433449900000, 0.994950100000, 0.008749999000),
+    555: (0.512050100000, 1.000000000000, 0.005749999000),
+    560: (0.594500000000, 0.995000000000, 0.003900000000),
+    565: (0.678400000000, 0.978600000000, 0.002749999000),
+    570: (0.762100000000, 0.952000000000, 0.002100000000),
+    575: (0.842500000000, 0.915400000000, 0.001800000000),
+    580: (0.916300000000, 0.870000000000, 0.001650001000),
+    585: (0.978600000000, 0.816300000000, 0.001400000000),
+    590: (1.026300000000, 0.757000000000, 0.001100000000),
+    595: (1.056700000000, 0.694900000000, 0.001000000000),
+    600: (1.062200000000, 0.631000000000, 0.000800000000),
+    605: (1.045600000000, 0.566800000000, 0.000600000000),
+    610: (1.002600000000, 0.503000000000, 0.000340000000),
+    615: (0.938400000000, 0.441200000000, 0.000240000000),
+    620: (0.854449900000, 0.381000000000, 0.000190000000),
+    625: (0.751400000000, 0.321000000000, 0.000100000000),
+    630: (0.642400000000, 0.265000000000, 0.000049999990),
+    635: (0.541900000000, 0.217000000000, 0.000030000000),
+    640: (0.447900000000, 0.175000000000, 0.000020000000),
+    645: (0.360800000000, 0.138200000000, 0.000010000000),
+    650: (0.283500000000, 0.107000000000, 0.000000000000),
+    655: (0.218700000000, 0.081600000000, 0.000000000000),
+    660: (0.164900000000, 0.061000000000, 0.000000000000),
+    665: (0.121200000000, 0.044580000000, 0.000000000000),
+    670: (0.087400000000, 0.032000000000, 0.000000000000),
+    675: (0.063600000000, 0.023200000000, 0.000000000000),
+    680: (0.046770000000, 0.017000000000, 0.000000000000),
+    685: (0.032900000000, 0.011920000000, 0.000000000000),
+    690: (0.022700000000, 0.008210000000, 0.000000000000),
+    695: (0.015840000000, 0.005723000000, 0.000000000000),
+    700: (0.011359160000, 0.004102000000, 0.000000000000),
+    705: (0.008110916000, 0.002929000000, 0.000000000000),
+    710: (0.005790346000, 0.002091000000, 0.000000000000),
+    715: (0.004109457000, 0.001484000000, 0.000000000000),
+    720: (0.002899327000, 0.001047000000, 0.000000000000),
+    725: (0.002049190000, 0.000740000000, 0.000000000000),
+    730: (0.001439971000, 0.000520000000, 0.000000000000),
+    735: (0.000999949300, 0.000361100000, 0.000000000000),
+    740: (0.000690078600, 0.000249200000, 0.000000000000),
+    745: (0.000476021300, 0.000171900000, 0.000000000000),
+    750: (0.000332301100, 0.000120000000, 0.000000000000),
+    755: (0.000234826100, 0.000084800000, 0.000000000000),
+    760: (0.000166150500, 0.000060000000, 0.000000000000),
+    765: (0.000117413000, 0.000042400000, 0.000000000000),
+    770: (0.000083075270, 0.000030000000, 0.000000000000),
+    775: (0.000058706520, 0.000021200000, 0.000000000000),
+    780: (0.000041509940, 0.000014990000, 0.000000000000),
+    785: (0.000029353260, 0.000010600000, 0.000000000000),
+    790: (0.000020673830, 0.000007465700, 0.000000000000),
+    795: (0.000014559770, 0.000005257800, 0.000000000000),
+    800: (0.000010253980, 0.000003702900, 0.000000000000),
+    805: (0.000007221456, 0.000002607800, 0.000000000000),
+    810: (0.000005085868, 0.000001836600, 0.000000000000),
+    815: (0.000003581652, 0.000001293400, 0.000000000000),
+    820: (0.000002522525, 0.000000910930, 0.000000000000),
+    825: (0.000001776509, 0.000000641530, 0.000000000000),
+    830: (0.000001251141, 0.000000451810, 0.000000000000)
+}
+
+# CIE 1964 10 degree observer
+# http://www-cvrl.ucsd.edu/cmfs.htm
+cie_1964_10deg = {
+    360: (0.000000122200, 0.000000013398, 0.000000535027),
+    365: (0.000000919270, 0.000000100650, 0.000004028300),
+    370: (0.000005958600, 0.000000651100, 0.000026143700),
+    375: (0.000033266000, 0.000003625000, 0.000146220000),
+    380: (0.000159952000, 0.000017364000, 0.000704776000),
+    385: (0.000662440000, 0.000071560000, 0.002927800000),
+    390: (0.002361600000, 0.000253400000, 0.010482200000),
+    395: (0.007242300000, 0.000768500000, 0.032344000000),
+    400: (0.019109700000, 0.002004400000, 0.086010900000),
+    405: (0.043400000000, 0.004509000000, 0.197120000000),
+    410: (0.084736000000, 0.008756000000, 0.389366000000),
+    415: (0.140638000000, 0.014456000000, 0.656760000000),
+    420: (0.204492000000, 0.021391000000, 0.972542000000),
+    425: (0.264737000000, 0.029497000000, 1.282500000000),
+    430: (0.314679000000, 0.038676000000, 1.553480000000),
+    435: (0.357719000000, 0.049602000000, 1.798500000000),
+    440: (0.383734000000, 0.062077000000, 1.967280000000),
+    445: (0.386726000000, 0.074704000000, 2.027300000000),
+    450: (0.370702000000, 0.089456000000, 1.994800000000),
+    455: (0.342957000000, 0.106256000000, 1.900700000000),
+    460: (0.302273000000, 0.128201000000, 1.745370000000),
+    465: (0.254085000000, 0.152761000000, 1.554900000000),
+    470: (0.195618000000, 0.185190000000, 1.317560000000),
+    475: (0.132349000000, 0.219940000000, 1.030200000000),
+    480: (0.080507000000, 0.253589000000, 0.772125000000),
+    485: (0.041072000000, 0.297665000000, 0.570060000000),
+    490: (0.016172000000, 0.339133000000, 0.415254000000),
+    495: (0.005132000000, 0.395379000000, 0.302356000000),
+    500: (0.003816000000, 0.460777000000, 0.218502000000),
+    505: (0.015444000000, 0.531360000000, 0.159249000000),
+    510: (0.037465000000, 0.606741000000, 0.112044000000),
+    515: (0.071358000000, 0.685660000000, 0.082248000000),
+    520: (0.117749000000, 0.761757000000, 0.060709000000),
+    525: (0.172953000000, 0.823330000000, 0.043050000000),
+    530: (0.236491000000, 0.875211000000, 0.030451000000),
+    535: (0.304213000000, 0.923810000000, 0.020584000000),
+    540: (0.376772000000, 0.961988000000, 0.013676000000),
+    545: (0.451584000000, 0.982200000000, 0.007918000000),
+    550: (0.529826000000, 0.991761000000, 0.003988000000),
+    555: (0.616053000000, 0.999110000000, 0.001091000000),
+    560: (0.705224000000, 0.997340000000, 0.000000000000),
+    565: (0.793832000000, 0.982380000000, 0.000000000000),
+    570: (0.878655000000, 0.955552000000, 0.000000000000),
+    575: (0.951162000000, 0.915175000000, 0.000000000000),
+    580: (1.014160000000, 0.868934000000, 0.000000000000),
+    585: (1.074300000000, 0.825623000000, 0.000000000000),
+    590: (1.118520000000, 0.777405000000, 0.000000000000),
+    595: (1.134300000000, 0.720353000000, 0.000000000000),
+    600: (1.123990000000, 0.658341000000, 0.000000000000),
+    605: (1.089100000000, 0.593878000000, 0.000000000000),
+    610: (1.030480000000, 0.527963000000, 0.000000000000),
+    615: (0.950740000000, 0.461834000000, 0.000000000000),
+    620: (0.856297000000, 0.398057000000, 0.000000000000),
+    625: (0.754930000000, 0.339554000000, 0.000000000000),
+    630: (0.647467000000, 0.283493000000, 0.000000000000),
+    635: (0.535110000000, 0.228254000000, 0.000000000000),
+    640: (0.431567000000, 0.179828000000, 0.000000000000),
+    645: (0.343690000000, 0.140211000000, 0.000000000000),
+    650: (0.268329000000, 0.107633000000, 0.000000000000),
+    655: (0.204300000000, 0.081187000000, 0.000000000000),
+    660: (0.152568000000, 0.060281000000, 0.000000000000),
+    665: (0.112210000000, 0.044096000000, 0.000000000000),
+    670: (0.081260600000, 0.031800400000, 0.000000000000),
+    675: (0.057930000000, 0.022601700000, 0.000000000000),
+    680: (0.040850800000, 0.015905100000, 0.000000000000),
+    685: (0.028623000000, 0.011130300000, 0.000000000000),
+    690: (0.019941300000, 0.007748800000, 0.000000000000),
+    695: (0.013842000000, 0.005375100000, 0.000000000000),
+    700: (0.009576880000, 0.003717740000, 0.000000000000),
+    705: (0.006605200000, 0.002564560000, 0.000000000000),
+    710: (0.004552630000, 0.001768470000, 0.000000000000),
+    715: (0.003144700000, 0.001222390000, 0.000000000000),
+    720: (0.002174960000, 0.000846190000, 0.000000000000),
+    725: (0.001505700000, 0.000586440000, 0.000000000000),
+    730: (0.001044760000, 0.000407410000, 0.000000000000),
+    735: (0.000727450000, 0.000284041000, 0.000000000000),
+    740: (0.000508258000, 0.000198730000, 0.000000000000),
+    745: (0.000356380000, 0.000139550000, 0.000000000000),
+    750: (0.000250969000, 0.000098428000, 0.000000000000),
+    755: (0.000177730000, 0.000069819000, 0.000000000000),
+    760: (0.000126390000, 0.000049737000, 0.000000000000),
+    765: (0.000090151000, 0.000035540500, 0.000000000000),
+    770: (0.000064525800, 0.000025486000, 0.000000000000),
+    775: (0.000046339000, 0.000018338400, 0.000000000000),
+    780: (0.000033411700, 0.000013249000, 0.000000000000),
+    785: (0.000024209000, 0.000009619600, 0.000000000000),
+    790: (0.000017611500, 0.000007012800, 0.000000000000),
+    795: (0.000012855000, 0.000005129800, 0.000000000000),
+    800: (0.000009413630, 0.000003764730, 0.000000000000),
+    805: (0.000006913000, 0.000002770810, 0.000000000000),
+    810: (0.000005093470, 0.000002046130, 0.000000000000),
+    815: (0.000003767100, 0.000001516770, 0.000000000000),
+    820: (0.000002795310, 0.000001128090, 0.000000000000),
+    825: (0.000002082000, 0.000000842160, 0.000000000000),
+    830: (0.000001553140, 0.000000629700, 0.000000000000)
+}
diff --git a/coloraide/color.py b/coloraide/color.py
index db22d2c2a..583f5f69f 100644
--- a/coloraide/color.py
+++ b/coloraide/color.py
@@ -4,6 +4,7 @@
 import functools
 import random
 import math
+from . import cmfs
 from . import distance
 from . import convert
 from . import gamut
@@ -13,12 +14,13 @@
 from . import contrast
 from . import harmonies
 from . import average
+from . import temperature
 from . import util
 from . import algebra as alg
 from itertools import zip_longest as zipl
 from .css import parse
 from .types import VectorLike, Vector, ColorInput
-from .spaces import Space
+from .spaces import Space, RGBish
 from .spaces.hsv import HSV
 from .spaces.srgb.css import sRGB
 from .spaces.srgb_linear import sRGBLinear
@@ -52,7 +54,7 @@
 from .gamut import Fit
 from .gamut.fit_lch_chroma import LChChroma
 from .gamut.fit_oklch_chroma import OkLChChroma
-from .cat import CAT, Bradford
+from .cat import CAT, Bradford, WHITES
 from .filters import Filter
 from .filters.w3c_filter_effects import Sepia, Brightness, Contrast, Saturate, Opacity, HueRotate, Grayscale, Invert
 from .filters.cvd import Protan, Deutan, Tritan
@@ -62,6 +64,9 @@
 from .interpolate.bspline import BSpline
 from .interpolate.bspline_natural import NaturalBSpline
 from .interpolate.monotone import Monotone
+from .temperature import CCT
+from .temperature.ohno_2013 import Ohno2013, BlackBodyCurve
+from .temperature.robertson_1968 import Robertson1968
 from .types import Plugin
 from typing import overload, Sequence, Iterable, Any, Callable, Mapping
 
@@ -101,6 +106,7 @@ def __init__(cls, name: str, bases: tuple[object, ...], clsdict: dict[str, Any])
             cls.FILTER_MAP = cls.FILTER_MAP.copy()  # type: dict[str, Filter]
             cls.CONTRAST_MAP = cls.CONTRAST_MAP.copy()  # type: dict[str, ColorContrast]
             cls.INTERPOLATE_MAP = cls.INTERPOLATE_MAP.copy()  # type: dict[str, Interpolate]
+            cls.CCT_MAP = cls.CCT_MAP.copy()  # type: dict[str, CCT]
 
         # Ensure each derived class tracks its own conversion paths for color spaces
         # relative to the installed color space plugins.
@@ -128,14 +134,16 @@ class Color(metaclass=ColorMeta):
     CONTRAST_MAP = {}  # type: dict[str, ColorContrast]
     FILTER_MAP = {}  # type: dict[str, Filter]
     INTERPOLATE_MAP = {}  # type: dict[str, Interpolate]
+    CCT_MAP = {}  # type: dict[str, CCT]
     PRECISION = util.DEF_PREC
     FIT = util.DEF_FIT
     INTERPOLATE = util.DEF_INTERPOLATE
     DELTA_E = util.DEF_DELTA_E
     HARMONY = util.DEF_HARMONY
     AVERAGE = util.DEF_AVERAGE
-    CHROMATIC_ADAPTATION = 'bradford'
-    CONTRAST = 'wcag21'
+    CHROMATIC_ADAPTATION = util.DEF_CHROMATIC_ADAPTATION
+    CONTRAST = util.DEF_CONTRAST
+    CCT = util.DEF_CCT
 
     # It is highly unlikely that a user would ever need to override this, but
     # just in case, it is exposed, but undocumented.
@@ -344,6 +352,9 @@ def register(
             elif isinstance(i, Interpolate):
                 mapping = cls.INTERPOLATE_MAP
                 p = i
+            elif isinstance(i, CCT):
+                mapping = cls.CCT_MAP
+                p = i
             elif isinstance(i, Fit):
                 mapping = cls.FIT_MAP
                 p = i
@@ -382,10 +393,12 @@ def deregister(cls, plugin: str | Sequence[str], *, silent: bool = False) -> Non
             if p == '*':
                 cls.CS_MAP.clear()
                 cls.DE_MAP.clear()
-                cls.FIT_MAP.clear()
                 cls.CAT_MAP.clear()
+                cls.FILTER_MAP.clear()
                 cls.CONTRAST_MAP.clear()
                 cls.INTERPOLATE_MAP.clear()
+                cls.CCT_MAP.clear()
+                cls.FIT_MAP.clear()
                 return
 
             ptype, name = p.split(':', 1)
@@ -402,6 +415,8 @@ def deregister(cls, plugin: str | Sequence[str], *, silent: bool = False) -> Non
                 mapping = cls.CONTRAST_MAP
             elif ptype == 'interpolate':
                 mapping = cls.INTERPOLATE_MAP
+            elif ptype == 'cct':
+                mapping = cls.CCT_MAP
             elif ptype == "fit":
                 mapping = cls.FIT_MAP
                 if name == 'clip':
@@ -460,6 +475,62 @@ def random(cls, space: str, *, limits: Sequence[Sequence[float] | None] | None =
             obj.normalize()
         return obj
 
+    @classmethod
+    def blackbody(
+        cls,
+        temp: float,
+        duv: float = 0.0,
+        *,
+        space: str | None = 'srgb-linear',
+        out_space: str | None = None,
+        method: str | None = None,
+        **kwargs: Any
+    ) -> Color:
+        """
+        Get a color along the black body curve.
+
+        Colors are specified by temperature in Kelvin. Depending on the algorithm, the practical
+        range may differ.
+
+        Colors are returned and normalized to be within the specified RGB space (preferably linear).
+        The returned color, after normalization, is an approximation and may not exactly match the
+        temperature. If `space` is set to `None` the color will not be normalized and may not be in
+        the visible spectrum, but it should correlate with the specified temperature assuming it is
+        not too far from the locus.
+        """
+
+        cct = temperature.cct(method, cls)
+
+        if out_space is None:
+            out_space = space or 'xyz-d65'
+
+        color = cct.from_cct(cls, temp, duv, **kwargs)
+
+        # Normalize in the given RGB color space (ideally linear).
+        if space is not None and isinstance(cls.CS_MAP[space], RGBish):
+            color.convert(space, in_place=True)
+            coords = color.coords()
+
+            # Add just enough white to make r, g, b all positive.
+            w = -min(0.0, *coords)
+            if w:
+                coords = [c + w for c in coords]
+
+            # Normalize values such that the maximum is 1 (unless all are zero)
+            m = max(coords)
+            color[:-1] = [c / m for c in coords] if m else coords
+
+        if out_space != color.space():
+            color.convert(out_space, in_place=True)
+
+        return color
+
+    def cct(self, *, method: str | None = None, **kwargs: Any) -> Vector:
+        """Get color temperature."""
+
+        cct = temperature.cct(method, self)
+        return cct.to_cct(self, **kwargs)
+
     def to_dict(self, *, nans: bool = True) -> Mapping[str, Any]:
         """Return color as a data object."""
 
@@ -1123,6 +1194,10 @@ def alpha(self, *, nans: bool = True) -> float:
         Continuous(),
         BSpline(),
         NaturalBSpline(),
-        Monotone()
+        Monotone(),
+
+        # CCT
+        Robertson1968(),
+        Ohno2013(BlackBodyCurve(cmfs.cie_1931_2deg, WHITES['2deg']['D65']))
     ]
 )
diff --git a/coloraide/temperature/__init__.py b/coloraide/temperature/__init__.py
new file mode 100644
index 000000000..343753a62
--- /dev/null
+++ b/coloraide/temperature/__init__.py
@@ -0,0 +1,35 @@
+"""Temperature plugin."""
+from __future__ import annotations
+from abc import ABCMeta, abstractmethod
+from ..types import Plugin, Vector
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:  # pragma: no cover
+    from ..color import Color
+
+
+class CCT(Plugin, metaclass=ABCMeta):
+    """Delta E plugin class."""
+
+    NAME = ''
+
+    @abstractmethod
+    def to_cct(self, color: Color, **kwargs: Any) -> Vector:
+        """Calculate a color's CCT."""
+
+    @abstractmethod
+    def from_cct(self, color: type[Color], kelvin: float, duv: float = 0.0, **kwargs: Any) -> Color:
+        """Calculate a color that satisfies the CCT."""
+
+
+def cct(name: str | None, color: type[Color] | Color) -> CCT:
+    """Get the appropriate contrast plugin."""
+
+    if name is None:
+        name = color.CCT
+
+    method = color.CCT_MAP.get(name)
+    if not method:
+        raise ValueError("'{}' CCT method is not supported".format(name))
+
+    return method
diff --git a/coloraide/temperature/ohno_2013.py b/coloraide/temperature/ohno_2013.py
new file mode 100644
index 000000000..7d18c8d64
--- /dev/null
+++ b/coloraide/temperature/ohno_2013.py
@@ -0,0 +1,247 @@
+"""
+Ohno 2013 CCT calculations.
+
+https://www.researchgate.net/publication/263373260_Practical_Use_and_Calculation_of_CCT_and_Duv
+"""
+from __future__ import annotations
+import math
+from . import planck
+from .. import cat
+from .. import cmfs
+from .. import util
+from .. import algebra as alg
+from ..temperature import CCT
+from ..types import Vector, VectorLike
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:  # pragma: no cover
+    from ..color import Color
+
+
+DEFAULT_WHITE = tuple(util.xy_to_xyz(cat.WHITES['2deg']['D65']))
+
+
+class BlackBodyCurve:
+    """
+    Setup a spline that represents the black body curve.
+
+    Points between steps are approximated, but actual points can always be
+    acquired via `exact`.
+    """
+
+    def __init__(
+        self,
+        cmfs: dict[int, tuple[float, float, float]] = cmfs.cie_1931_2deg,
+        white: VectorLike = DEFAULT_WHITE
+    ) -> None:
+        """Initialize."""
+
+        keys = list(cmfs.keys())
+        self.cmfs_start = min(keys)
+        self.cmfs_end = max(keys)
+
+        points = []
+        self.domain = []
+        self.domain2 = []
+        self.cmfs = cmfs
+        self.white = white
+        start = 1000
+        end = 20000
+        step = 130
+        inc = (end - start) / step
+        count = step + 1
+
+        for r in range(count):
+            k = r * inc + start
+            u, v = planck.temp_to_uv_planckian_locus(k, self.cmfs, self.white, self.cmfs_start, self.cmfs_end)
+            self.domain.append(k)
+            points.append([u, v])
+
+        self.spline = alg.interpolate(points, method='monotone')
+
+        start = end
+        end = 100000
+        step = 220
+        inc = (end - start) / step
+        count = step + 1
+        points = []
+
+        for r in range(count):
+            k = r * inc + start
+            u, v = planck.temp_to_uv_planckian_locus(k, self.cmfs, self.white, self.cmfs_start, self.cmfs_end)
+            self.domain2.append(k)
+            points.append([u, v])
+
+        self.spline2 = alg.interpolate(points, method='monotone')
+
+    def scale(self, point: float, domain: list[float]) -> float:
+        """Scale the temperature point to match the range 0 - 1."""
+
+        # Extrapolation
+        if point <= domain[0]:
+            point = (point - domain[0]) / (domain[-1] - domain[0])
+        elif point >= self.domain[-1]:
+            point = 1.0 + (point - domain[-1]) / (domain[-1] - domain[0])
+
+        # Interpolation
+        else:
+            a, b = domain[0], domain[len(domain) - 1]
+            l = b - a
+            point = ((point - a) / l) if l else 0.0
+        return point
+
+    def __call__(self, temp: float, exact: bool = False) -> Vector:
+        """Get the uv for the given temp."""
+
+        if exact:
+            return planck.temp_to_uv_planckian_locus(temp, self.cmfs, self.white, self.cmfs_start, self.cmfs_end)
+        else:
+            if temp <= 20000:
+                return self.spline(self.scale(temp, self.domain))
+            return self.spline2(self.scale(temp, self.domain2))
+
+
+class Ohno2013(CCT):
+    """
+    Calculate temperature for a given pair of uv coordinates.
+
+    The Ohno approach requires a pre-generated table. The more data available, the more precise the values.
+    Unfortunately, to span the entire range of 1000 - 100000 with fairly good accuracy, it requires keeping
+    a very large table in memory.
+
+    To avoid storing a large amount of data in memory, we can use multiple iterations and dynamically sample
+    points on the locus, each iteration shrinking the bounds until we converge. Unfortunately, this is very
+    slow, millisecond range.
+
+    An alternative is to use the iterative approach, but generate a smaller subset of data and use a spline
+    to approximate the points in between. Obviously, the points in between will not be as accurate, but the
+    spline is used only as a way to approximate close to the temperature. Once we've sufficiently narrowed
+    the range down to our best 3 temperature points, we can calculate those points with higher accuracy and
+    proceed with the solvers. This actually allows us to use an even smaller amount of data than if we had
+    used no spline and pre-calculated enough points for a similar accuracy. This is also much faster than
+    dynamically calculating all the points.
+
+    After navigating the table of data and determining a temperature that has the lowest delta distance, we can
+    then use the triangular and parabolic solver. The triangular works best for values close to the locus (less
+    than |0.002| Duv) and the parabolic solution works better for values with a higher Duv.
+
+    For more precision, `exact` will avoid the approximation spline.
+
+    https://www.researchgate.net/publication/263373260_Practical_Use_and_Calculation_of_CCT_and_Duv
+    """
+
+    NAME = 'ohno-2013'
+
+    def __init__(self, blackbody: BlackBodyCurve | None = None):
+        """Initialize."""
+
+        self.blackbody = BlackBodyCurve(cmfs.cie_1931_2deg, DEFAULT_WHITE) if blackbody is None else blackbody
+
+    def to_cct(
+        self,
+        color: Color,
+        start: float = 1000,
+        end: float = 100000,
+        samples: int = 10,
+        iterations: int = 6,
+        exact: bool = False,
+        **kwargs: Any
+    ) -> Vector:
+        """Calculate a color's CCT."""
+
+        u, v = color.uv('1960')
+        last = samples - 1
+        index = 0
+        table = []  # type: list[tuple[float, float, float, float]]
+
+        # Each iteration we narrow the range until we are close enough
+        for _ in range(iterations):
+            table.clear()
+            lowest = alg.INF
+            index = 0
+
+            # Generate the Planckian table while tracking lowest distance
+            for j in range(samples):
+                k = alg.lerp(start, end, j / last)
+                u2, v2 = self.blackbody(k, exact=exact)
+                di = math.sqrt((u2 - u) ** 2 + (v2 - v) ** 2)
+                if di < lowest:
+                    lowest = di
+                    index = j
+                table.append((k, u2, v2, di))
+
+            # Set next iteration's range to include our best result +/-1
+            # If our best result was on the edge, that edge remains the boundary
+            start = table[index - 1][0] if index > 0 else table[index][0]
+            end = table[index + 1][0] if index < last else table[index][0]
+
+        # Select the closest 3 values. Get precise values instead of our
+        # approximated spline value so we can get the most accurate result.
+        ti = table[index][0]
+        if not exact:
+            ui, vi = self.blackbody(ti, exact=True)
+            di = math.sqrt((ui - u) ** 2 + (vi - v) ** 2)
+        else:
+            di = table[index][-1]
+
+        if index == 0 or not exact:
+            tp = ti - 1e-4 if index == 0 else table[index - 1][0]
+            up, vp = self.blackbody(tp, exact=True)
+            dp = math.sqrt((up - u) ** 2 + (vp - v) ** 2)
+        else:
+            tp, up, vp, dp = table[index - 1]
+
+        if index == last or not exact:
+            tn = ti + 1e-4 if index == last else table[index + 1][0]
+            un, vn = self.blackbody(tn, exact=True)
+            dn = math.sqrt((un - u) ** 2 + (vn - v) ** 2)
+        else:
+            tn, un, vn, dn = table[index + 1]
+
+        # Triangular solution
+        l = math.sqrt((un - up) ** 2 + (vn - vp) ** 2)
+        x = (dp ** 2 - dn ** 2 + l ** 2) / (2 * l)
+        t = tp + (tn - tp) * (x / l)
+        vtx = vp + (vn - vp) * (x / l)
+        sign = math.copysign(1, v - vtx)
+        duv = (dp ** 2 - x ** 2) ** (1 / 2) * sign
+
+        # Parabolic solution
+        if abs(duv) >= 0.002:
+            x = (tn - ti) * (tp - tn) * (ti - tp)
+            a = (
+                tp * (dn - di) +
+                ti * (dp - dn) +
+                tn * (di - dp)
+            ) * (x ** -1)
+            b = -(
+                (tp ** 2) * (dn - di) +
+                (ti ** 2) * (dp - dn) +
+                (tn ** 2) * (di - dp)
+            ) * (x ** -1)
+            c = -(
+                (dp * ti * tn) * (tn - ti) +
+                (di * tp * tn) * (tp - tn) +
+                (dn * tp * ti) * (ti - tp)
+            ) * (x ** -1)
+            t = -b / (2 * a)
+            duv = (a * (t ** 2) + b * t + c) * sign
+
+        return [t, duv]
+
+    def from_cct(self, color: type[Color], kelvin: float, duv: float = 0.0, **kwargs: Any) -> Color:
+        """Calculate a color that satisfies the CCT using Planck's law."""
+
+        u0, v0 = self.blackbody(kelvin, exact=True)
+        if duv:
+            u1, v1 = self.blackbody(kelvin + 0.01, exact=True)
+            du = u0 - u1
+            dv = v0 - v1
+            di = math.sqrt(du ** 2 + dv ** 2)
+            if di:
+                du /= di
+                dv /= di
+                u0 = u0 - duv * dv
+                v0 = v0 + duv * du
+
+        return color('xyz-d65', util.xy_to_xyz(util.uv_1960_to_xy([u0, v0]), 1))
diff --git a/coloraide/temperature/planck.py b/coloraide/temperature/planck.py
new file mode 100644
index 000000000..dc57a28b9
--- /dev/null
+++ b/coloraide/temperature/planck.py
@@ -0,0 +1,41 @@
+"""
+Planckian curve.
+
+https://en.wikipedia.org/wiki/Planckian_locus#The_Planckian_locus_in_the_XYZ_color_space
+"""
+from __future__ import annotations
+import math
+from ..types import VectorLike, Vector
+from .. import util
+
+
+# Constants for Planck's Law
+H = 6.62607015e-34 * 1e18  # Plank's constant: `nm2 kg / s`
+C = 299792458 * 1e9  # Speed of light: `nm / s`
+K = 1.380649e-23 * 1e18  # Boltzmann constant: `nm2 kg s-2 K-1`
+C1 = 2 * math.pi * H * C ** 2  # First radiation constant
+C2 = (H * C) / K  # Second radiation constant
+
+
+def temp_to_uv_planckian_locus(
+    temp: float,
+    cmfs: dict[int, tuple[float, float, float]],
+    white: VectorLike,
+    start: int = 360,
+    end: int = 830,
+    step: int = 5
+) -> Vector:
+    """
+    Temperature to Planckian locus.
+
+    https://en.wikipedia.org/wiki/Planckian_locus#The_Planckian_locus_in_the_XYZ_color_space
+    """
+    x = y = z = 0.0
+
+    for wavelength in range(start, end + 1, step):
+        m = C1 * (wavelength ** -5) * (math.exp(C2 / (wavelength * temp)) - 1.0) ** -1
+        x += m * cmfs[wavelength][0]
+        y += m * cmfs[wavelength][1]
+        z += m * cmfs[wavelength][2]
+
+    return util.xy_to_uv_1960(util.xyz_to_xyY([x, y, z], white)[:2])
diff --git a/coloraide/temperature/robertson_1968.py b/coloraide/temperature/robertson_1968.py
new file mode 100644
index 000000000..b8751ac52
--- /dev/null
+++ b/coloraide/temperature/robertson_1968.py
@@ -0,0 +1,160 @@
+"""
+Calculate CCT with Robertson 1968 method.
+
+Uses Robertson 1968 method.
+
+- https://en.wikipedia.org/wiki/Correlated_color_temperature#Robertson's_method
+- http://www.brucelindbloom.com/index.html?Math.html
+"""
+from __future__ import annotations
+import math
+from .. import algebra as alg
+from .. import util
+from ..temperature import CCT
+from ..types import Vector
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:  # pragma: no cover
+    from ..color import Color
+
+
+# Table for Robertson 1968 method
+# Data: reciprocal temperature, u, v, slope
+RUVT = [
+    (0.0, 0.18006, 0.26352, -0.24341),
+    (10.0, 0.18066, 0.26589, -0.25479),
+    (20.0, 0.18133, 0.26846, -0.26876),
+    (30.0, 0.18208, 0.27119, -0.28539),
+    (40.0, 0.18293, 0.27407, -0.30470),
+    (50.0, 0.18388, 0.27709, -0.32675),
+    (60.0, 0.18494, 0.28021, -0.35156),
+    (70.0, 0.18611, 0.28342, -0.37915),
+    (80.0, 0.18740, 0.28668, -0.40955),
+    (90.0, 0.18880, 0.28997, -0.44278),
+    (100.0, 0.19032, 0.29326, -0.47888),
+    (125.0, 0.19462, 0.30141, -0.58204),
+    (150.0, 0.19962, 0.30921, -0.70471),
+    (175.0, 0.20525, 0.31647, -0.84901),
+    (200.0, 0.21142, 0.32312, -1.0182),
+    (225.0, 0.21807, 0.32909, -1.2168),
+    (250.0, 0.22511, 0.33439, -1.4512),
+    (275.0, 0.23247, 0.33904, -1.7298),
+    (300.0, 0.24010, 0.34308, -2.0637),
+    (325.0, 0.24792, 0.34655, -2.4681),  # Note: 0.24792 is a corrected value for the error found in W&S as 0.24702
+    (350.0, 0.25591, 0.34951, -2.9641),
+    (375.0, 0.26400, 0.35200, -3.5814),
+    (400.0, 0.27218, 0.35407, -4.3633),
+    (425.0, 0.28039, 0.35577, -5.3762),
+    (450.0, 0.28863, 0.35714, -6.7262),
+    (475.0, 0.29685, 0.35823, -8.5955),
+    (500.0, 0.30505, 0.35907, -11.324),
+    (525.0, 0.31320, 0.35968, -15.628),
+    (550.0, 0.32129, 0.36011, -23.325),
+    (575.0, 0.32931, 0.36038, -40.770),
+    (600.0, 0.33724, 0.36051, -116.45),
+]
+
+
+class Robertson1968(CCT):
+    """Delta E plugin class."""
+
+    NAME = 'robertson-1968'
+
+    def to_cct(self, color: Color, **kwargs: Any) -> Vector:
+        """Calculate a color's CCT."""
+
+        u, v = color.uv('1960')
+
+        # Search for line pair coordinate is between.
+        previous_di = temp = duv = 0.0
+        end = len(RUVT) - 1
+
+        for index, current in enumerate(RUVT):
+            # Get the distance
+            di = (v - current[2]) - current[3] * (u - current[1])
+            if index > 0 and (di <= 0.0 or index == end):
+                # Calculate the required interpolation factor between the two lines
+                previous = RUVT[index - 1]
+                current_denom = math.sqrt(1.0 + current[3] ** 2)
+                di /= current_denom
+                previous_denom = math.sqrt(1.0 + previous[3] ** 2)
+                dip = previous_di / previous_denom
+                factor = dip / (dip - di)
+
+                # Calculate the temperature, if the mired value is zero
+                # assume the maximum temperature of 100000K.
+                mired = alg.lerp(previous[0], current[0], factor)
+                temp = 1.0E6 / mired if mired > 0 else 1e5
+
+                # Interpolate the slope vectors
+                dup = 1 / previous_denom
+                dvp = previous[3] / previous_denom
+                du = 1 / current_denom
+                dv = current[3] / current_denom
+                du = alg.lerp(dup, du, factor)
+                dv = alg.lerp(dvp, dv, factor)
+                denom = math.sqrt(du ** 2 + dv ** 2)
+                du /= denom
+                dv /= denom
+
+                # Calculate Duv
+                duv = (
+                    du * (u - alg.lerp(previous[1], current[1], factor)) +
+                    dv * (v - alg.lerp(previous[2], current[2], factor))
+                )
+
+                break
+
+            # Save distance as previous
+            previous_di = di
+
+        return [temp, -duv]
+
+    def from_cct(self, color: type[Color], kelvin: float, duv: float = 0.0, **kwargs: Any) -> Color:
+        """Calculate a color that satisfies the CCT."""
+
+        # Find inverse temperature to use as index.
+        r = 1.0E6 / kelvin
+        u = v = 0.0
+        end = len(RUVT) - 2
+
+        for index, current in enumerate(RUVT):
+            future = RUVT[index + 1]
+
+            # Find the two isotherms that our target temp is between
+            if r < future[0] or index == end:
+                # Find relative weight between the two values
+                f = (future[0] - r) / (future[0] - current[0])
+
+                # Interpolate the uv coordinates of our target temperature
+                u = alg.lerp(future[1], current[1], f)
+                v = alg.lerp(future[2], current[2], f)
+
+                # Calculate the offset along the slope
+                if duv:
+                    # Calculate the slope vectors
+                    u1 = 1.0
+                    v1 = current[3]
+                    length = math.sqrt(1.0 + v1 ** 2)
+                    u1 /= length
+                    v1 /= length
+
+                    u2 = 1.0
+                    v2 = future[3]
+                    length = math.sqrt(1.0 + v2 ** 2)
+                    u2 /= length
+                    v2 /= length
+
+                    # Find vector from the locus to our point.
+                    du = alg.lerp(u2, u1, f)
+                    dv = alg.lerp(v2, v1, f)
+                    denom = math.sqrt(du ** 2 + dv ** 2)
+                    du /= denom
+                    dv /= denom
+
+                    # Adjust the uv by the calculated offset
+                    u += du * -duv
+                    v += dv * -duv
+                break
+
+        return color('xyz-d65', util.xy_to_xyz(util.uv_1960_to_xy([u, v]), 1))
diff --git a/coloraide/util.py b/coloraide/util.py
index 6e5a674e2..e99350245 100644
--- a/coloraide/util.py
+++ b/coloraide/util.py
@@ -17,6 +17,9 @@
 DEF_HARMONY = "oklch"
 DEF_DELTA_E = "76"
 DEF_AVERAGE = 'srgb-linear'
+DEF_CHROMATIC_ADAPTATION = "bradford"
+DEF_CONTRAST = "wcag21"
+DEF_CCT = "ohno-2013"
 
 # Maximum luminance in PQ is 10,000 cd/m^2
 # Relative XYZ has Y=1 for media white
diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt
index 71234cfea..0c7ed4abd 100644
--- a/docs/src/dictionary/en-custom.txt
+++ b/docs/src/dictionary/en-custom.txt
@@ -15,15 +15,13 @@ Boronine
 Brettel
 Bz
 CATs
+CCT
 CIE
 CIECAM
 CIEDE
 CIELCh
-CIELCh's
 CIELab
-CIELab's
 CIELuv
-CIELuv's
 CMC
 CMCCAT
 CMF
@@ -40,7 +38,6 @@ Chromaticities
 Chromaticity
 Colaboratory
 ColorAide
-ColorAide's
 ColorHelper
 Colorimetry
 Colorio
@@ -55,6 +52,7 @@ Deutan
 Deuteranopia
 Dichromacy
 Dormont
+Duv
 EOTF
 Easings
 Ebner
@@ -78,7 +76,7 @@ HSV
 HSVish
 HUSL
 HWB
-Horner's
+Horner
 HyAB
 Hz
 ICtCp
@@ -100,16 +98,13 @@ JzCzhz
 Jzazbz
 Kries
 LCh
-LCh's
 LChish
 LChuv
 LMS
 Lab
-Lab's
 Labish
 Lilley
 Lstar
-Lstar's
 Luv
 MATLAB
 MERCHANTABILITY
@@ -126,12 +121,11 @@ NaN
 NaNs
 Numpy
 OETF
+Ohno
 OkLCh
-OkLCh's
 Okhsl
 Okhsv
 Oklab
-Oklab's
 Ottosson
 PQ
 Perceptibility
@@ -183,6 +177,7 @@ XD
 XYB
 XYZ
 YCbCr
+Yoshi
 ZD
 accessor
 accessors
@@ -199,7 +194,6 @@ broadcasted
 bz
 cd
 chroma
-chroma's
 chromaticity
 colorimetrically
 colorimetry
@@ -210,6 +204,7 @@ cz
 daltonlens
 deregister
 deregistered
+deregistering
 deregistration
 deuteranomaly
 deuteranopia
@@ -242,14 +237,16 @@ mixin
 mixins
 monochromacy
 monotonicity
+nm
 oRGB
-oRGB's
 opRGB
 opto
 performant
 piecewise
 pre
 precalculate
+precalculate
+precalculated
 precalculated
 precalculating
 preformatted
@@ -271,7 +268,6 @@ reflectance
 repurpose
 rgb
 sRGB
-sRGB's
 seperable
 subclassed
 subclasses
diff --git a/docs/src/markdown/.snippets/abbr.md b/docs/src/markdown/.snippets/abbr.md
index 4e443af5f..8a6c41554 100644
--- a/docs/src/markdown/.snippets/abbr.md
+++ b/docs/src/markdown/.snippets/abbr.md
@@ -12,5 +12,5 @@
 *[SDR]: Standard Dynamic Range
 *[UCS]: Uniform Chromaticity Space
 *[WCAG]: Web Content Accessibility Guidelines
-*[CMF]: color matching functions
+*[CCT]: correlated color temperature
 *[CMFs]: color matching functions
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index 5b66f33a9..4ab54f017 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -3,6 +3,8 @@
 ## 2.4
 
 - **NEW**: Add the 1960 UCS color space.
+- **NEW**: Add correlated color temperature support with new `cct()` and `blackbody()` methods.
+- **FIX**: Fix case where deregistering all plugins with `*` was not deregistering `Filter` plugins.
 
 ## 2.3
 
diff --git a/docs/src/markdown/api/index.md b/docs/src/markdown/api/index.md
index 14236ec3a..cf6922dea 100644
--- a/docs/src/markdown/api/index.md
+++ b/docs/src/markdown/api/index.md
@@ -720,7 +720,7 @@ Return
 -   Returns a float indicating the delta E distance between the two colors.
 ///
 
-## `#!py color.closest` {#closest}
+## `#!py Color.closest` {#closest}
 
 ```py
 def closest(
@@ -753,7 +753,7 @@ Return
     `#!py3 None` will be returned.
 ///
 
-## `#!py color.mask` {#mask}
+## `#!py Color.mask` {#mask}
 
 ```py
 def mask(
@@ -1460,6 +1460,73 @@ Return
 -   Returns a set of XYZ coordinates that align with the white point for the given color space.
 ///
 
+## `#!py Color.blackbody` {#blackbocy}
+
+```py
+@classmethod
+def blackbody(
+    cls,
+    temp: float,
+    duv: float = 0.0,
+    *,
+    space: str | None = 'srgb-linear',
+    out_space: str | None = None,
+    method: str | None = None,
+    **kwargs: Any
+) -> Color:
+```
+
+/// define
+Description
+
+-   Creates a color from a temperature in Kelvin and an optional ∆~uv~. The color will be normalized in the linear RGB
+    space provided via `space`. By default, the Ohno 2013 algorithm is used.
+
+Parameters
+
+- 
+    Parameters  | Defaults             | Description
+    ----------- | -------------------- | -----------
+    `temp`      |                      | A positive temperature in Kelvin. Accepted range of temperature is based on the algorithm.
+    `duv`       | `#!py 0.0`           | An optional ∆~uv~ specifying the distance from the black body curve.
+    `space`     | `#!py 'srgb-linear'` | An RGB color space in which the returned color should be normalized in. The color space should be a linear space for best results. It can be set to `#!py None` if no normalization is desired.
+    `out_space` | `#!py None`          | Color space that the new color should be in. If `#!py None`, the return color will be in the same color space as specified by `space` or `xyz-d65` if `space` is `#!py None`.
+    `method`    | `#!py None`          | A string specifying the algorithm to use. By default `ohno-2013` is used.
+
+Return
+
+-   Returns a reference to the current [`Color`](#color).
+///
+
+## `#!py Color.cct` {#cct}
+
+```py
+def cct(
+    self,
+    *,
+    method: str | None = None,
+    **kwargs: Any
+) -> Vector:
+```
+
+/// define
+Description
+
+-   Returns the associated CCT and ∆~uv~ for a given color. If the color is beyond an acceptable range for the
+    algorithm or the color is very far from the locus, the result may be surprising.
+
+Parameters
+
+- 
+    Parameters  | Defaults             | Description
+    ----------- | -------------------- | -----------
+    `method`    | `#!py None`          | A string specifying the algorithm to use. By default `ohno-2013` is used.
+
+Return
+
+-   Returns a list containing the correlated color temperature in Kelvin and the ∆~uv~.
+///
+
 ## `#!py Color.chromatic_adaptation`
 
 ```py
diff --git a/docs/src/markdown/color.md b/docs/src/markdown/color.md
index 8463bd1fc..1c9632070 100644
--- a/docs/src/markdown/color.md
+++ b/docs/src/markdown/color.md
@@ -321,6 +321,7 @@ Properties             | Defaults            | Description
 `HARMONY`              | `#!py "oklch"`      | Default color space to use for calculating color harmonies. This should be a cylindrical color space.
 `CONTRAST`             | `#!py "wcag21"`     | Default contrast algorithm.
 `AVERAGE`              | `#!py "average"`    | Default color space for averaging.
+`CCT`                  | `#!py "ohno-2013`   | Default CCT method.
 
 ### Plugins
 
@@ -369,7 +370,7 @@ Custom('red').delta_e('blue')
 If a deregistration is desired, the `deregister` method can be used. It takes a string that describes the plugin to
 deregister: `category:name`.
 
-Valid categories are `space`, `delta-e`, `cat`, `contrast`, `filter`, `interpolate`, and `fit`.
+Valid categories are `space`, `delta-e`, `cat`, `contrast`, `filter`, `interpolate`, `fit`, and `cct`.
 
 If the given plugin is not found, an error will be thrown, but if this notification is found to be unnecessary, `silent`
 can be enabled and the there will be no error thrown.
diff --git a/docs/src/markdown/images/blackbody.png b/docs/src/markdown/images/blackbody.png
new file mode 100644
index 000000000..f6b9ca3ed
Binary files /dev/null and b/docs/src/markdown/images/blackbody.png differ
diff --git a/docs/src/markdown/images/cct-gamut.png b/docs/src/markdown/images/cct-gamut.png
new file mode 100644
index 000000000..5fb992365
Binary files /dev/null and b/docs/src/markdown/images/cct-gamut.png differ
diff --git a/docs/src/markdown/images/isotherms.png b/docs/src/markdown/images/isotherms.png
new file mode 100644
index 000000000..a07115b46
Binary files /dev/null and b/docs/src/markdown/images/isotherms.png differ
diff --git a/docs/src/markdown/plugins/cct.md b/docs/src/markdown/plugins/cct.md
new file mode 100644
index 000000000..5095132a7
--- /dev/null
+++ b/docs/src/markdown/plugins/cct.md
@@ -0,0 +1,37 @@
+# CCT
+
+## Description
+
+CCT plugins allow you to calculate a color from a CCT and ∆~uv~ or calculate a CCT and ∆~uv~ from a color.
+
+## Plugin Class
+
+```py
+class CCT(Plugin, metaclass=ABCMeta):
+    """Delta E plugin class."""
+
+    NAME = ''
+
+    @abstractmethod
+    def to_cct(self, color: Color, **kwargs: Any) -> Vector:
+        """Calculate a color's CCT."""
+
+    @abstractmethod
+    def from_cct(self, color: type[Color], kelvin: float, duv: float = 0.0, **kwargs: Any) -> Color:
+        """Calculate a color that satisfies the CCT."""
+```
+
+Once registered, the plugin can then be used via the `cct()` or `blackbody()` methods by passing its `NAME` via the
+`method` parameter.
+
+```py
+Color('orange').cct(method=NAME, **kwargs)
+Color.blackbody(kelvin, duv, method=NAME, **kwargs)
+```
+
+If you'd like the user to configure the plugin on registration, you can define an `__init__` method. To register the
+plugin, just the `register()` method.
+
+```py
+Color.register(Plugin(**kwargs))
+```
diff --git a/docs/src/markdown/plugins/index.md b/docs/src/markdown/plugins/index.md
index 6a7adc573..153670d2e 100644
--- a/docs/src/markdown/plugins/index.md
+++ b/docs/src/markdown/plugins/index.md
@@ -10,6 +10,7 @@ methods or even new color spaces quite easy. Currently, ColorAide implements the
 - [Contrast](./contrast.md)
 - [Color spaces](./space.md)
 - [Interpolation](./interpolate.md)
+- [CCT](./cct.md)
 
 While these documents will touch on each plugin, looking at the source code will provide a better view on how plugins
 are actually used as all functionality for all of these categories are implemented as plugins in ColorAide.
diff --git a/docs/src/markdown/temperature.md b/docs/src/markdown/temperature.md
new file mode 100644
index 000000000..ceccf7a53
--- /dev/null
+++ b/docs/src/markdown/temperature.md
@@ -0,0 +1,178 @@
+# Correlated Color Temperature
+
+When anything gets warm enough it will start to give off light, and the hotter it gets, the more energetic the light
+is. As an object increases in temperature, it will shift from the red end of the spectrum to the blue end.
+
+```py play
+Steps([Color.blackbody(t, out_space='srgb') for t in range(1000, 15000, 50)])
+```
+
+Correlated color temperature (CCT) is a measurement of the average hue of light as it appears to the eye. It is
+expressed as the temperature (in Kelvins) something would need to be heated to glow at approximately the same color.
+
+This response can be modeled with a Planckian or black body locus/curve.
+
+//// html | figure
+![Black body curve](images/blackbody.png)
+
+///// html | figcaption
+1960 Chromaticity Diagram with black body curve in the range of 1,000K - 100,000K
+/////
+////
+
+## D~uv~
+
+In addition to CCT, there is also the concept of D~uv~ or ∆~uv~. In the following image, we've now drawn perpendicular
+lines that intersect the black body curve. These lines are called isotherms. An isotherm is simply a line connecting
+points having the same temperature at a given time or on average over a given period. In the case of colors, they
+connect a number of colors that are close to the locus with the same temperature.
+
+//// html | figure
+![Isotherms](images/isotherms.png)
+
+///// html | figcaption
+1960 Chromaticity Diagram with black body curve and isotherms.
+/////
+////
+
+We can calculate a color's associated temperature and its ∆~uv~ along the associated isotherm.
+
+```py play
+'CCT: {}K Duv: {}'.format(*Color('yellow').cct())
+```
+
+CCT and ∆~uv~ can also be used together to get a specific color that satisfies those requirements.
+
+```py play
+Color.blackbody(*Color('yellow').cct())
+```
+
+## Limitations
+
+All algorithms to calculate to and from CCT have some limitations and are approximations, some being more accurate than
+others. Many algorithms are only accurate up to a certain temperature range. Additionally, it is recommend that colors
+that exhibit a ∆~uv~ larger than |5e-2| should be considered inaccurate, and some algorithm's may not do as well out to
+even this limit.
+
+## Out of Gamut Temperatures
+
+It should be noted that `blackbody()` normalizes the returned colors by default as the colors are often much too bright
+initially, all having a max luminance. This normalization is usually done under a linear RGB color space, (linear sRGB
+being the default). Sometimes, depending on the range of temperature, the color can also fall outside the gamut under
+evaluation.
+
+//// html | figure
+![Out of gamut CCT](images/cct-gamut.png)
+
+///// html | figcaption
+CCT of 1200K in relation to the sRGB gamut.
+/////
+////
+
+Colors that are outside the gamut will only be approximations under the specified gamut and may not convert back to
+exactly the same temperature.
+
+```py play
+c = Color.blackbody(1200)
+c
+c.cct()
+```
+
+If a larger gamut is desired, to reduce out of gamut colors, one can be specified by changing `space`. Additionally,
+normalization can be turned off entirely by setting it to `None`. When specifying the space under evaluation, it is
+encouraged to use linear RGB spaces.
+
+```py play
+c1 = Color.blackbody(1200, space='display-p3-linear')
+c1
+c1.cct()
+c2 = Color.blackbody(1200, space=None)
+c2
+c2.cct()
+```
+
+## Algorithms
+
+There are quite a few approaches to calculating to and from CCT, each with their strengths and weaknesses. ColorAide
+currently only supports a few approaches which are implemented as plugins.
+
+Algorithm       | Key              | Description
+--------------- | ---------------- | -----------
+Robertson\ 1968 | `robertson-1968` | Uses the CIE 2˚ Standard Observer and can handle a range of 1667K - 100000K.
+Ohno\ 2013      | `ohno-2013`      | Utilizes a combined approach of a triangular and parabolic solver. Current implementation allows for a range of 1000K - 100000K.
+
+### Robertson 1968
+
+/// success | The Robertson 1968 CCT algorithm is registered in `Color` by default
+///
+
+An approach created by A. R. Robertson and is based on the CIE 2˚ Standard Observer with a range of 1667K - 100000K.
+This approach uses a look up table containing some precalculated points and approximates the points along the black
+body curve.
+
+It is not the most accurate method and can suffer from moderate errors in certain ranges, but is generally pretty fast
+and probably "good enough" for values within the 1667K - 100000K.
+
+```py play
+Color.blackbody(5000, duv=0.02, method='robertson-1968').cct(method='robertson-1968')
+```
+
+### Ohno 2013
+
+/// success | The Ohno 2013 CCT algorithm is registered in `Color` by default
+///
+
+This is an approach researched by Yoshi Ohno and aims to provide better accuracy. It uses a look up table similar to
+the Roberson method and employs a combined approach of a triangular solver and a parabolic solver. This can lead to
+high accuracy if the table is large enough.
+
+Additionally, an "automatic expansion" technique can be used that starts with a small table and expands the table on
+smaller intervals based on the first solution. This can allow for a high accuracy without having to keep a large table
+in memory.
+
+For good accuracy throughout the range of 1000K - 100000K, as ColorAide supports, a very large table would be needed. If
+the "automatic expansion" technique was used, without caching the data which would cause the table to balloon in memory,
+the process is much slower.
+
+To mitigate the downside of storing a massive table in memory and to reduce the performance issues when using "automatic
+expansion", ColorAide uses a moderately sized table and creates a spline to interpolate points in between. The expansion
+technique is used to get close to the target using the spline as the data table, and then more accurate values are
+calculated for use in the triangular and parabolic solver. This allows us to use a smaller table while mitigating the
+performance issues associated with the expansion technique, all while maintaining good accuracy. The technique is still
+slower than the [Roberson](#robertson-1968) approach, but it is more accurate.
+
+```py play
+Color.blackbody(5000, duv=0.02).cct()
+```
+
+If a more _pure_ approach is desired, `exact` can be used to directly use the "automatic expansion" without using the
+spline. The values for the table will explicitly be calculated on the fly. Performance will be affected.
+
+```py play
+Color.blackbody(5000, duv=0.02).cct(exact=True)
+```
+
+///  tip | Changing Observer
+If desired, the Ohno 2013 approach can use a different standard observer, for instance, the CIE 10˚ Standard Observer.
+ColorAide only provides the CIE 2˚ and 10˚ observer by default. If providing another set of CMFs, they should be at 5nm
+resolution or better as that is the current default step size.
+
+```py play
+from coloraide import cmfs
+from coloraide import cat
+from coloraide.temperature.ohno_2013 import Ohno2013, BlackBodyCurve
+
+
+class Custom(Color):
+    ...
+
+
+Custom.register(
+    Ohno2013(BlackBodyCurve(cmfs=cmfs.cie_1964_10deg, white=cat.WHITES['10deg']['D65'])),
+    overwrite=True
+)
+
+Steps([Color.blackbody(t) for t in range(1000, 15000, 50)])
+Steps([Custom.blackbody(t) for t in range(1000, 15000, 50)])
+```
+///
diff --git a/docs/src/mkdocs.yml b/docs/src/mkdocs.yml
index 7a4d2fe89..291540fae 100644
--- a/docs/src/mkdocs.yml
+++ b/docs/src/mkdocs.yml
@@ -48,6 +48,7 @@ nav:
       - Contrast: contrast.md
       - Color Harmonies: harmonies.md
       - Filters: filters.md
+      - Correlated Color Temperature: temperature.md
       - String Output: strings.md
       - Advanced Topics: advanced.md
 
@@ -133,6 +134,7 @@ nav:
       - Contrast: plugins/contrast.md
       - Color Spaces: plugins/space.md
       - Interpolation: plugins/interpolate.md
+      - CCT: plugins/cct.md
 
   - Demos:
     - demos/index.md
diff --git a/mkdocs.yml b/mkdocs.yml
index c438bea4e..30baa62fd 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -48,6 +48,7 @@ nav:
       - Contrast: contrast.md
       - Color Harmonies: harmonies.md
       - Filters: filters.md
+      - Correlated Color Temperature: temperature.md
       - String Output: strings.md
       - Advanced Topics: advanced.md
 
@@ -133,6 +134,7 @@ nav:
       - Contrast: plugins/contrast.md
       - Color Spaces: plugins/space.md
       - Interpolation: plugins/interpolate.md
+      - CCT: plugins/cct.md
 
   - Demos:
     - demos/index.md
diff --git a/tools/cie_diagrams.py b/tools/cie_diagrams.py
index 3481419c6..0ebfc23db 100644
--- a/tools/cie_diagrams.py
+++ b/tools/cie_diagrams.py
@@ -17,6 +17,8 @@
 from coloraide import util  # noqa: E402
 from coloraide.cat import WHITES  # noqa: E402
 from coloraide import algebra as alg  # noqa: E402
+from coloraide import temperature as thermal  # noqa: E402
+from coloraide import cmfs  # noqa: E402
 
 ALL_WHITES = copy.deepcopy(WHITES)
 ALL_WHITES['2deg']['D60'] = Color.CS_MAP['aces2065-1'].WHITE
@@ -24,208 +26,6 @@
 # How dense do we scatter plot the diagram background colors?
 RESOLUTION = 800
 
-CCT = [1000, 1500, 2000, 2500, 3000, 6000, 10000, 15000, 20000, 25000]
-
-# CIE 1931 2 degree observer
-# http://www-cvrl.ucsd.edu/cmfs.htm
-cie_2_deg_observer = {
-    360: (0.0001299, 0.000003917, 0.0006061),
-    365: (0.0002321, 0.000006965, 0.001086),
-    370: (0.0004149, 0.00001239, 0.001946),
-    375: (0.0007416, 0.00002202, 0.003486),
-    380: (0.001368, 0.000039, 0.006450001),
-    385: (0.002236, 0.000064, 0.01054999),
-    390: (0.004243, 0.00012, 0.02005001),
-    395: (0.00765, 0.000217, 0.03621),
-    400: (0.01431, 0.000396, 0.06785001),
-    405: (0.02319, 0.00064, 0.1102),
-    410: (0.04351, 0.00121, 0.2074),
-    415: (0.07763, 0.00218, 0.3713),
-    420: (0.13438, 0.004, 0.6456),
-    425: (0.21477, 0.0073, 1.0390501),
-    430: (0.2839, 0.0116, 1.3856),
-    435: (0.3285, 0.01684, 1.62296),
-    440: (0.34828, 0.023, 1.74706),
-    445: (0.34806, 0.0298, 1.7826),
-    450: (0.3362, 0.038, 1.77211),
-    455: (0.3187, 0.048, 1.7441),
-    460: (0.2908, 0.06, 1.6692),
-    465: (0.2511, 0.0739, 1.5281),
-    470: (0.19536, 0.09098, 1.28764),
-    475: (0.1421, 0.1126, 1.0419),
-    480: (0.09564, 0.13902, 0.8129501),
-    485: (0.05795001, 0.1693, 0.6162),
-    490: (0.03201, 0.20802, 0.46518),
-    495: (0.0147, 0.2586, 0.3533),
-    500: (0.0049, 0.323, 0.272),
-    505: (0.0024, 0.4073, 0.2123),
-    510: (0.0093, 0.503, 0.1582),
-    515: (0.0291, 0.6082, 0.1117),
-    520: (0.06327, 0.71, 0.07824999),
-    525: (0.1096, 0.7932, 0.05725001),
-    530: (0.1655, 0.862, 0.04216),
-    535: (0.2257499, 0.9148501, 0.02984),
-    540: (0.2904, 0.954, 0.0203),
-    545: (0.3597, 0.9803, 0.0134),
-    550: (0.4334499, 0.9949501, 0.008749999),
-    555: (0.5120501, 1, 0.005749999),
-    560: (0.5945, 0.995, 0.0039),
-    565: (0.6784, 0.9786, 0.002749999),
-    570: (0.7621, 0.952, 0.0021),
-    575: (0.8425, 0.9154, 0.0018),
-    580: (0.9163, 0.87, 0.001650001),
-    585: (0.9786, 0.8163, 0.0014),
-    590: (1.0263, 0.757, 0.0011),
-    595: (1.0567, 0.6949, 0.001),
-    600: (1.0622, 0.631, 0.0008),
-    605: (1.0456, 0.5668, 0.0006),
-    610: (1.0026, 0.503, 0.00034),
-    615: (0.9384, 0.4412, 0.00024),
-    620: (0.8544499, 0.381, 0.00019),
-    625: (0.7514, 0.321, 0.0001),
-    630: (0.6424, 0.265, 0.00004999999),
-    635: (0.5419, 0.217, 0.00003),
-    640: (0.4479, 0.175, 0.00002),
-    645: (0.3608, 0.1382, 0.00001),
-    650: (0.2835, 0.107, 0),
-    655: (0.2187, 0.0816, 0),
-    660: (0.1649, 0.061, 0),
-    665: (0.1212, 0.04458, 0),
-    670: (0.0874, 0.032, 0),
-    675: (0.0636, 0.0232, 0),
-    680: (0.04677, 0.017, 0),
-    685: (0.0329, 0.01192, 0),
-    690: (0.0227, 0.00821, 0),
-    695: (0.01584, 0.005723, 0),
-    700: (0.01135916, 0.004102, 0),
-    705: (0.008110916, 0.002929, 0),
-    710: (0.005790346, 0.002091, 0),
-    715: (0.004106457, 0.001484, 0),
-    720: (0.002899327, 0.001047, 0),
-    725: (0.00204919, 0.00074, 0),
-    730: (0.001439971, 0.00052, 0),
-    735: (0.0009999493, 0.0003611, 0),
-    740: (0.0006900786, 0.0002492, 0),
-    745: (0.0004760213, 0.0001719, 0),
-    750: (0.0003323011, 0.00012, 0),
-    755: (0.0002348261, 0.0000848, 0),
-    760: (0.0001661505, 0.00006, 0),
-    765: (0.000117413, 0.0000424, 0),
-    770: (0.00008307527, 0.00003, 0),
-    775: (0.00005870652, 0.0000212, 0),
-    780: (0.00004150994, 0.00001499, 0),
-    785: (0.00002935326, 0.0000106, 0),
-    790: (0.00002067383, 0.0000074657, 0),
-    795: (0.00001455977, 0.0000052578, 0),
-    800: (0.00001025398, 0.0000037029, 0),
-    805: (0.000007221456, 0.0000026078, 0),
-    810: (0.000005085868, 0.0000018366, 0),
-    815: (0.000003581652, 0.0000012934, 0),
-    820: (0.000002522525, 0.00000091093, 0),
-    825: (0.000001776509, 0.00000064153, 0),
-    830: (0.000001251141, 0.00000045181, 0)
-}
-
-# CIE 1931 2 degree observer
-# http://www-cvrl.ucsd.edu/cmfs.htm
-cie_10_deg_observer = {
-    360: (0.0000001222, 0.000000013398, 0.000000535027),
-    365: (0.00000091927, 0.00000010065, 0.0000040283),
-    370: (0.0000059586, 0.0000006511, 0.0000261437),
-    375: (0.000033266, 0.000003625, 0.00014622),
-    380: (0.000159952, 0.000017364, 0.000704776),
-    385: (0.00066244, 0.00007156, 0.0029278),
-    390: (0.0023616, 0.0002534, 0.0104822),
-    395: (0.0072423, 0.0007685, 0.032344),
-    400: (0.0191097, 0.0020044, 0.0860109),
-    405: (0.0434, 0.004509, 0.19712),
-    410: (0.084736, 0.008756, 0.389366),
-    415: (0.140638, 0.014456, 0.65676),
-    420: (0.204492, 0.021391, 0.972542),
-    425: (0.264737, 0.029497, 1.2825),
-    430: (0.314679, 0.038676, 1.55348),
-    435: (0.357719, 0.049602, 1.7985),
-    440: (0.383734, 0.062077, 1.96728),
-    445: (0.386726, 0.074704, 2.0273),
-    450: (0.370702, 0.089456, 1.9948),
-    455: (0.342957, 0.106256, 1.9007),
-    460: (0.302273, 0.128201, 1.74537),
-    465: (0.254085, 0.152761, 1.5549),
-    470: (0.195618, 0.18519, 1.31756),
-    475: (0.132349, 0.21994, 1.0302),
-    480: (0.080507, 0.253589, 0.772125),
-    485: (0.041072, 0.297665, 0.5706),
-    490: (0.016172, 0.339133, 0.415254),
-    495: (0.005132, 0.395379, 0.302356),
-    500: (0.003816, 0.460777, 0.218502),
-    505: (0.015444, 0.53136, 0.159249),
-    510: (0.037465, 0.606741, 0.112044),
-    515: (0.071358, 0.68566, 0.082248),
-    520: (0.117749, 0.761757, 0.060709),
-    525: (0.172953, 0.82333, 0.04305),
-    530: (0.236491, 0.875211, 0.030451),
-    535: (0.304213, 0.92381, 0.020584),
-    540: (0.376772, 0.961988, 0.013676),
-    545: (0.451584, 0.9822, 0.007918),
-    550: (0.529826, 0.991761, 0.003988),
-    555: (0.616053, 0.99911, 0.001091),
-    560: (0.705224, 0.99734, 0),
-    565: (0.793832, 0.98238, 0),
-    570: (0.878655, 0.955552, 0),
-    575: (0.951162, 0.915175, 0),
-    580: (1.01416, 0.868934, 0),
-    585: (1.0743, 0.825623, 0),
-    590: (1.11852, 0.777405, 0),
-    595: (1.1343, 0.720353, 0),
-    600: (1.12399, 0.658341, 0),
-    605: (1.0891, 0.593878, 0),
-    610: (1.03048, 0.527963, 0),
-    615: (0.95074, 0.461834, 0),
-    620: (0.856297, 0.398057, 0),
-    625: (0.75493, 0.339554, 0),
-    630: (0.647467, 0.283493, 0),
-    635: (0.53511, 0.228254, 0),
-    640: (0.431567, 0.179828, 0),
-    645: (0.34369, 0.140211, 0),
-    650: (0.268329, 0.107633, 0),
-    655: (0.2043, 0.081187, 0),
-    660: (0.152568, 0.060281, 0),
-    665: (0.11221, 0.044096, 0),
-    670: (0.0812606, 0.0318004, 0),
-    675: (0.05793, 0.0226017, 0),
-    680: (0.0408508, 0.0159051, 0),
-    685: (0.028623, 0.0111303, 0),
-    690: (0.0199413, 0.0077488, 0),
-    695: (0.013842, 0.0053751, 0),
-    700: (0.00957688, 0.00371774, 0),
-    705: (0.0066052, 0.00256456, 0),
-    710: (0.00455263, 0.00176847, 0),
-    715: (0.0031447, 0.00122239, 0),
-    720: (0.00217496, 0.00084619, 0),
-    725: (0.0015057, 0.00058644, 0),
-    730: (0.00104476, 0.00040741, 0),
-    735: (0.00072745, 0.000284041, 0),
-    740: (0.000508258, 0.00019873, 0),
-    745: (0.00035638, 0.00013955, 0),
-    750: (0.000250969, 0.000098428, 0),
-    755: (0.00017773, 0.000069819, 0),
-    760: (0.00012639, 0.000049737, 0),
-    765: (0.000090151, 0.0000355405, 0),
-    770: (0.0000645258, 0.000025486, 0),
-    775: (0.000046339, 0.0000183384, 0),
-    780: (0.0000334117, 0.000013249, 0),
-    785: (0.000024209, 0.0000096196, 0),
-    790: (0.0000176115, 0.0000070128, 0),
-    795: (0.000012855, 0.0000051298, 0),
-    800: (0.00000941363, 0.00000376473, 0),
-    805: (0.000006913, 0.00000277081, 0),
-    810: (0.00000509347, 0.00000204613, 0),
-    815: (0.0000037671, 0.00000151677, 0),
-    820: (0.00000279531, 0.00000112809, 0),
-    825: (0.000002082, 0.00000084216, 0),
-    830: (0.00000155314, 0.0000006297, 0)
-}
-
 # Pick some arbitrary labels to display.
 labels = {
     380,
@@ -248,6 +48,17 @@
     700
 }
 
+ISOTHERMS = {
+    100000: (0.025, '∞'),
+    10000: (0.025, '10000K'),
+    6000: (0.025, '6000K'),
+    4000: (0.025, '4000K'),
+    3000: (-0.040, '3000K'),
+    2000: (-0.034, '2000K'),
+    1500: (-0.040, '1500K'),
+    1000: (-0.040, '1000K')
+}
+
 
 def get_spline(x, y, steps=100):
     """Get spline."""
@@ -255,70 +66,6 @@ def get_spline(x, y, steps=100):
     return tuple([list(i) for i in zip(*alg.interpolate(list(zip(x, y)), method='monotone').steps(steps))])
 
 
-def black_body_curve_xy(t):
-    """
-    Calculate the black body curve for `xy` coordinates.
-
-    Good for between 1667K - 25000K.
-    """
-
-    if 1667 <= t <= 4000:
-        xc = (
-            -0.2661239 * (10 ** 9) / (t ** 3) - 0.2343589 * (10 ** 6) / (t ** 2) + 0.8776956 * (10 ** 3) / t + 0.179910
-        )
-    elif 4000 <= t <= 25000:
-        xc = (
-            -3.0258469 * (10 ** 9) / (t ** 3) + 2.1070379 * (10 ** 6) / (t ** 2) + 0.2226347 * (10 ** 3) / t + 0.240390
-        )
-    else:
-        raise ValueError('Cannot calculate a color for {:f}k'.format(t))
-
-    if 1667 <= t <= 2222:
-        yc = -1.1063814 * (xc ** 3) - 1.34811020 * (xc ** 2) + 2.18555832 * xc - 0.20219683
-    elif 2222 <= t <= 4000:
-        yc = -0.9549476 * (xc ** 3) - 1.37418593 * (xc ** 2) + 2.09137015 * xc - 0.16748867
-    elif 4000 <= t <= 25000:
-        yc = 3.0817580 * (xc ** 3) - 5.87338670 * (xc ** 2) + 3.75112997 * xc - 0.37001483
-
-    return xc, yc
-
-
-def black_body_curve(t, mode='1931'):
-    """
-    Calculate the black body curve for `uv` coordinates.
-
-    Good for between 1000K - 15000K.
-    """
-
-    if t > 15000:
-        x, y = black_body_curve_xy(t)
-        if mode == '1931':
-            return x, y
-
-        u, v = util.xy_to_uv_1960([x, y])
-        if mode == '1976':
-            return u, v * (3 / 2)
-
-        return u, v
-
-    u = (
-        (0.860117757 + 1.54118254 * (10 ** -4) * t + 1.28641212 * (10 ** -7) * (t ** 2)) /
-        (1 + 8.42420235 * (10 ** -4) * t + 7.08145163 * (10 ** -7) * (t ** 2))
-    )
-
-    v = (
-        (0.317398726 + 4.22806245 * (10 ** -5) * t + 4.20481691 * (10 ** -8) * (t ** 2)) /
-        (1 - 2.89741816 * (10 ** -5) * t + 1.61456053 * (10 ** -7) * (t ** 2))
-    )
-
-    if mode == '1931':
-        return util.uv_1960_to_xy([u, v])
-    if mode == '1976':
-        return u, v * (3 / 2)
-
-    return u, v
-
-
 def cie_xy_2_deg_offsets(wavelength):
     """
     Setup labels for CIE 2 deg for `xy` diagrams.
@@ -450,10 +197,11 @@ class DiagramOptions:
     def __init__(self, mode="1931", observer='2deg', theme="light", title=""):
         """Initialize."""
 
-        self.observer = cie_2_deg_observer
-        self.axis_labels = ('CIE x', 'CIE y')
+        self.observer = cmfs.cie_1931_2deg
+        self.white = ALL_WHITES['2deg']['D65']
         if observer == '10deg':
-            self.observer = cie_10_deg_observer
+            self.white = ALL_WHITES['10deg']['D65']
+            self.observer = cmfs.cie_1964_10deg
             self.axis_labels = ('CIE u', 'CIE v')
         elif observer != '2deg':
             raise ValueError("Unrecognized 'observer': {}".format(observer))
@@ -463,6 +211,7 @@ def __init__(self, mode="1931", observer='2deg', theme="light", title=""):
             raise ValueError("Unrecognized 'mode': {}".format(mode))
 
         if self.mode == "1931":
+            self.axis_labels = ('CIE x', 'CIE y')
             if observer == '2deg':
                 self.locus_labels = cie_xy_2_deg_offsets
                 self.title = "CIE 1931 Chromaticy Diagram - 2˚ Degree Standard Observer"
@@ -470,6 +219,7 @@ def __init__(self, mode="1931", observer='2deg', theme="light", title=""):
                 self.locus_labels = cie_xy_10_deg_offsets
                 self.title = "CIE 1931 Chromaticy Diagram - 10˚ Degree Standard Observer"
         elif self.mode == "1976":
+            self.axis_labels = ("CIE u'", "CIE v'")
             if observer == '2deg':
                 self.locus_labels = cie_uv_2_deg_offsets
                 self.title = "CIE 1976 UCS Chromaticity Diagram - 2˚ Degree Standard Observer"
@@ -477,6 +227,7 @@ def __init__(self, mode="1931", observer='2deg', theme="light", title=""):
                 self.locus_labels = cie_uv_10_deg_offsets
                 self.title = "CIE 1976 UCS Chromaticity Diagram - 10˚ Degree Standard Observer"
         else:
+            self.axis_labels = ('CIE u', 'CIE v')
             if observer == '2deg':
                 self.locus_labels = cie_uv_2_deg_offsets
                 self.title = "CIE 1960 UCS Chromaticity Diagram - 2˚ Degree Standard Observer"
@@ -506,7 +257,7 @@ def __init__(self, mode="1931", observer='2deg', theme="light", title=""):
 def cie_diagram(
     mode="1931", observer="2deg", colorize=True, opacity=1, rgb_spaces=None,
     white_points=None, theme='light', title='', show_labels=True, axis=True,
-    show_legend=True, black_body=False
+    show_legend=True, black_body=False, isotherms=False, cct=None
 ):
     """CIE diagram."""
 
@@ -524,6 +275,13 @@ def cie_diagram(
     if show_labels:
         plt.margins(0.15)
 
+    if isotherms:
+        black_body = True
+
+    bb = thermal.DEFAULT_BLACK_BODY
+    if (black_body or cct) and observer == '10deg':
+        bb = thermal.BlackBodyCurve(cmf=opt.observer, white=opt.white)
+
     xs = []
     ys = []
     annotations = []
@@ -531,7 +289,7 @@ def cie_diagram(
     # Get points for the spectral locus
     for k, v in opt.observer.items():
         # Get the XYZ values in the correct format
-        x, y = util.xyz_to_xyY(v, (0.31270, 0.32900))[:2]
+        x, y = util.xyz_to_xyY(v, opt.white)[:2]
         if opt.mode == "1976":
             x, y = util.xy_to_uv([x, y])
         elif opt.mode == "1960":
@@ -690,19 +448,86 @@ def cie_diagram(
                 ha='center'
             )
 
+    # Add any specified CCT points.
+    if cct:
+        bx = []
+        by = []
+        annot = []
+        for value in cct:
+            temp, duv = [float(v) for v in value.split(':')]
+            bu, bv = thermal.temp_to_uv_ohno_2013(temp, duv, blackbody=bb)
+            if opt.mode != '1960':
+                bu, bv = util.uv_1960_to_xy([bu, bv])
+                if opt.mode != '1931':
+                    bu, bv = util.xy_to_uv([bu, bv])
+            annot.append('({}, {})'.format(round(bu, 4), round(bv, 4)))
+            bx.append(bu)
+            by.append(bv)
+        plt.scatter(
+            bx,
+            by,
+            marker=".",
+            color=opt.default_colorized_color if colorize else opt.default_color
+        )
+        for pt, a in zip(zip(bx, by), annot):
+            plt.annotate(
+                a,
+                pt,
+                size=8,
+                color=opt.default_colorized_color if colorize else opt.default_color
+            )
+
     # Show the black body (Planckian locus) curve
     # Work in progress.
     if black_body:
         uaxis = []
         vaxis = []
-        bres = 40
-        boffset = 1000
-        brange = 24000
-        for cct in range(0, bres + 1):
-            t = cct / bres * brange + boffset
-            bu, bv = black_body_curve(t, opt.mode)
+        for kelvin in range(1000, 100001, 250):
+            t = kelvin
+            bu, bv = thermal.temp_to_uv_ohno_2013(t, blackbody=bb)
+            if opt.mode != '1960':
+                bu, bv = util.uv_1960_to_xy([bu, bv])
+                if opt.mode != '1931':
+                    bu, bv = util.xy_to_uv([bu, bv])
             uaxis.append(bu)
             vaxis.append(bv)
+
+            if isotherms and kelvin in ISOTHERMS:
+                duvx = []
+                duvy = []
+                for duv in (-0.02, 0.02) if kelvin < 100000 else (-0.01, 0.01):
+                    bu, bv = thermal.temp_to_uv_ohno_2013(kelvin, duv, blackbody=bb)
+                    if opt.mode != '1960':
+                        bu, bv = util.uv_1960_to_xy([bu, bv])
+                        if opt.mode != '1931':
+                            bu, bv = util.xy_to_uv([bu, bv])
+                    duvx.append(bu)
+                    duvy.append(bv)
+
+                offset, label = ISOTHERMS[kelvin]
+                bu, bv = thermal.temp_to_uv_ohno_2013(kelvin, offset, blackbody=bb)
+                if opt.mode != '1960':
+                    bu, bv = util.uv_1960_to_xy([bu, bv])
+                    if opt.mode != '1931':
+                        bu, bv = util.xy_to_uv([bu, bv])
+
+                plt.annotate(
+                    label,
+                    [bu, bv],
+                    size=6,
+                    color=opt.default_colorized_color if colorize else opt.default_color,
+                    ha='center'
+                )
+
+                plt.plot(
+                    duvx,
+                    duvy,
+                    color=opt.default_colorized_color if colorize else opt.default_color,
+                    marker="",
+                    linewidth=1,
+                    markersize=0,
+                    antialiased=True
+                )
         uaxis, vaxis = get_spline(uaxis, vaxis, len(uaxis) * 4)
         plt.plot(
             uaxis,
@@ -743,6 +568,7 @@ def main():
     parser.add_argument('--diagram', '-d', default='1931', help='Diagram to generate.')
     parser.add_argument('--cmfs', '-c', default="2deg", help="CMFS to use, e.g., '2deg' (default) or '10deg'.")
     parser.add_argument('--white-point', '-w', action='append', help="A white point to plot.")
+    parser.add_argument('--cct', '-C', action='append', help="A point specified by 'CCT:Duv'.")
     parser.add_argument('--rgb', '-r', action='append', help="An RGB space to show on diagram: 'space:color'.")
     parser.add_argument('--title', '-t', default='', help="Override title with your own.")
     parser.add_argument('--transparent', '-p', action="store_true", help="Export with transparent background.")
@@ -753,6 +579,7 @@ def main():
     parser.add_argument('--no-alpha', '-a', action='store_true', help="Disable diagram transparent background.")
     parser.add_argument('--dark', action="store_true", help="Use dark theme.")
     parser.add_argument('--black-body', '-k', action='store_true', help="Draw the black body curve (WIP).")
+    parser.add_argument('--isotherms', '-i', action='store_true', help="Show isotherms.")
     parser.add_argument('--dpi', default=200, type=int, help="DPI of image.")
     parser.add_argument('--output', '-o', default='', help='Output file.')
     args = parser.parse_args()
@@ -769,7 +596,9 @@ def main():
         show_legend=not args.no_legend,
         axis=not args.no_axis,
         title=args.title,
-        black_body=args.black_body
+        black_body=args.black_body,
+        isotherms=args.isotherms,
+        cct=args.cct
     )
 
     if args.output:


Test patch:
diff --git a/tests/test_cct.py b/tests/test_cct.py
new file mode 100644
index 000000000..66d0008f6
--- /dev/null
+++ b/tests/test_cct.py
@@ -0,0 +1,150 @@
+"""Test CCT."""
+import math
+from coloraide.everything import ColorAll as Color
+from coloraide import cmfs
+from coloraide import cat
+from . import util
+import pytest
+import unittest
+
+
+class TestOhno2013Temp(util.ColorAssertsPyTest):
+    """Test Ohno 2013."""
+
+    @pytest.mark.parametrize('cct', list(range(1000, 100000, 100)))
+    def test_cct(self, cct):
+        """
+        Test CCT methods.
+
+        We test at a high resolution to make sure the spline implementation is working as good as without.
+        """
+
+        for duv in (-0.03, -0.02, -0.01, 0.0, 0.01, 0.02, 0.03):
+            cct2, duv2 = Color.blackbody(cct, duv, space=None).cct()
+            assert math.isclose(cct2, cct, rel_tol=(0.00001 if cct < 96000 else 0.0001), abs_tol=0.000001)
+            assert math.isclose(duv2, duv, rel_tol=0.000001, abs_tol=0.000001)
+
+
+class TestRobertson1968(util.ColorAssertsPyTest):
+    """Test Robertson 1968."""
+
+    @pytest.mark.parametrize('cct', list(range(1000, 100000, 1000)))
+    def test_cct(self, cct):
+        """Test CCT methods."""
+
+        for duv in (-0.03, -0.02, -0.01, 0.0, 0.01, 0.02, 0.03):
+            cct2, duv2 = Color.blackbody(cct, duv, space=None, method='robertson-1968').cct(method='robertson-1968')
+            assert math.isclose(cct2, cct, rel_tol=0.0001, abs_tol=0.00001)
+            assert math.isclose(duv2, duv, rel_tol=0.00001, abs_tol=0.00001)
+
+
+class TestOhno2013Color(util.ColorAssertsPyTest):
+    """Test Ohno 2013."""
+
+    @pytest.mark.parametrize(
+        'color',
+        [
+            'green',
+            'yellow',
+            'orange',
+            'white',
+            'gray',
+            'pink',
+
+        ]
+    )
+    def test_cct(self, color):
+        """Test CCT methods."""
+
+        cct, duv = Color(color).cct()
+        c = Color.blackbody(cct, duv)
+        cct2, duv2 = c.cct()
+        assert math.isclose(cct, cct2, abs_tol=0.05)
+        assert math.isclose(duv, duv2, abs_tol=0.05)
+
+
+class TestRobertson1968Color(util.ColorAssertsPyTest):
+    """Test Robertson 1968."""
+
+    @pytest.mark.parametrize(
+        'color',
+        [
+            'green',
+            'yellow',
+            'orange',
+            'white',
+            'gray',
+            'pink',
+
+        ]
+    )
+    def test_cct(self, color):
+        """Test CCT methods."""
+
+        cct, duv = Color(color).cct(method='robertson-1968')
+        c = Color.blackbody(cct, duv, method='robertson-1968')
+        cct2, duv2 = c.cct(method='robertson-1968')
+        assert math.isclose(cct, cct2, abs_tol=0.05)
+        assert math.isclose(duv, duv2, abs_tol=0.05)
+
+
+class TestCCTSpecificCases(util.ColorAsserts, unittest.TestCase):
+    """Test CCT specific cases."""
+
+    def test_bad_algorithm(self):
+        """Test bad algorithm inputs."""
+
+        with self.assertRaises(ValueError):
+            Color.blackbody(2000, method='bad')
+
+        with self.assertRaises(ValueError):
+            Color('blue').cct(method='bad')
+
+    def test_output_space(self):
+        """Test output space."""
+
+        self.assertColorEqual(
+            Color.blackbody(2000, out_space='display-p3'),
+            Color('color(display-p3 0.93958 0.56696 0.22948)')
+        )
+
+    def test_normalization_space(self):
+        """Test normalization space."""
+
+        self.assertColorEqual(
+            Color.blackbody(2000, space='display-p3'),
+            Color('color(display-p3 1 0.6107 0.25811)')
+        )
+
+    def test_no_normalization_space(self):
+        """Test normalization space."""
+
+        self.assertColorEqual(
+            Color.blackbody(2000, space=None),
+            Color('color(xyz-d65 1.2743 1 0.14524)')
+        )
+
+    def test_ohno_alternate_cmfs(self):
+        """Test alternate CMFs."""
+
+        from coloraide.temperature.ohno_2013 import Ohno2013, BlackBodyCurve
+
+        class Custom(Color):
+            ...
+
+        Custom.register(
+            Ohno2013(BlackBodyCurve(cmfs=cmfs.cie_1964_10deg, white=cat.WHITES['10deg']['D65'])),
+            overwrite=True
+        )
+
+        srgbl = Custom.blackbody(5000)
+        self.assertColorEqual(srgbl, Color('color(srgb-linear 1 0.77909 0.61807)'))
+        self.assertEqual(srgbl.cct(), [5000.005435889316, 2.770957661628331e-08])
+
+    def test_ohno_exact(self):
+        """Test alternate CMFs."""
+
+        cct1 = Color('orange').cct()
+        self.assertEqual(cct1, [2424.0755046224285, 0.008069417819174918])
+        cct2 = Color('orange').cct(exact=True)
+        self.assertEqual(cct2, [2424.0755046224285, 0.008069417819174918])
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 57baf702b..6cd9d4530 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -271,6 +271,28 @@ class Custom(Color):
             color
         )
 
+    def test_plugin_registration_cct(self):
+        """Test plugin registration of `cct`."""
+
+        from coloraide.temperature.robertson_1968 import Robertson1968
+
+        # Deregistration should have taken place
+        class Custom(Color):
+            pass
+
+        Custom.deregister('cct:robertson-1968')
+        color = Color.blackbody(5000, method='robertson-1968')
+
+        with self.assertRaises(ValueError):
+            Custom.blackbody(5000, method='robertson-1968')
+
+        # Now it is registered again
+        Custom.register(Robertson1968())
+        self.assertColorEqual(
+            Custom.blackbody(5000, method='robertson-1968'),
+            color
+        )
+
     def test_deregister_all_category(self):
         """Test deregistration of all plugins in a category."""
 
@@ -291,6 +313,11 @@ class Custom(Color):
         self.assertEqual(Custom.FIT_MAP, {})
         self.assertEqual(Custom.CS_MAP, {})
         self.assertEqual(Custom.DE_MAP, {})
+        self.assertEqual(Custom.INTERPOLATE_MAP, {})
+        self.assertEqual(Custom.FILTER_MAP, {})
+        self.assertEqual(Custom.CAT_MAP, {})
+        self.assertEqual(Custom.CONTRAST_MAP, {})
+        self.assertEqual(Custom.CCT_MAP, {})
 
     def test_reserved_registration_fit(self):
         """Test override registration of reserved fit method."""