In general, Python doesn't have a cube root function. Numpy does, but that is a heavy package that we've been trying to not require.
So, generally, people might suggest "cube roots are easy! Just use n ** (1/3)
.". Well, this is what we've been using, and yes, we are in the ballpark, but it introduces errors for some things that should just be exact.
>>> 64 ** (1/3)
3.9999999999999996
>>> (64 ** (1/3)) ** 3
63.99999999999998
Not great...we should get an even 4.
So what can we do? Just use Numpy? Well, not exactly. We can actually rework ours to be a little fancier, and a little more accurate. How are we going to do this? Using Newton's Method.
Now, keep in mind, not every value has a perfect root, and not every value can be perfectly calculated with a perfect cube root, so sometimes, we are going to have an approximation.
But here we go, now we've implemented a nth_root
function using Newton's Method.
>>> (64 ** (1/3)) ** 3
63.99999999999998
>>> nth_root(64, 3) ** 3
64.0
Much better. Now, how does it work with decimals?
>>> nth_root(64, 1.8) ** 1.8
63.999999999999986
>>> (64 ** (1 / 1.8)) ** 1.8
64.00000000000001
Not bad, which is better, going over or going under? ๐คท but we are in the ballpark.
>>> (1.2 ** (1 / 1.8)) ** 1.8
1.2
>>> nth_root(1.2, 1.8) ** 1.8
1.2
And we generally handle negative values to avoid imaginary results:
>>> nth_root(-64, 1.8)
-10.079368399158984
>>> nth_root(64, 1.8)
10.079368399158984
Any requested root below 1 will be handled the old-fashioned way as the algorithm won't handle powers less than 1, and anything less than 0 will throw an error.
Now, what does this do? The hope was generally just to make translations a little more stable. Beyond that, I had no real goals, it was more an evaluation. But looking at things, we can see things are a bit closer and better:
>>> Color('white').convert('lch').convert('hsl')
color(hsl 0 0% 100% / 1)
>>> Color('white').convert('lch-d65').convert('hsl')
color(hsl 0 0% 100% / 1)
>>> Color('white').convert('lab').convert('srgb').coords()
[0.9999999999999997, 1.0000000000000002, 0.9999999999999997]
>>> Color('white').convert('lab-d65').convert('srgb').coords()
[0.9999999999999997, 1.0000000000000002, 0.9999999999999997]
This is just strictly tweaking nth_root calculations to get them more accurate (cube root and others). No tweaking or optimization transform matrices or playing around with rounding, just a better root calculation.
It's never going to be perfect, and none of this was to force perfect Lab results, those are just a bonus. Does it do this for ICtCp, OKLab, or Jzazbz? :laugh: nope! And there was no expectation it would, but all things considering, our results are pretty good.
>>> Color('white').convert('jzazbz').convert('srgb').coords()
[1.0000000000000067, 1.0000000000000038, 0.9999999999999908]
>>> Color('white').convert('oklab').convert('srgb').coords()
[1.0000000000000009, 0.999999999999999, 1.0000000000000007]
>>> Color('white').convert('ictcp').convert('srgb').coords()
[0.9999999999999402, 1.000000000000015, 1.0000000000000204]
OKLab can squeeze out a decent white HSL value, but not OKLch:
>>> Color('white').convert('oklab').convert('hsl').coords()
[nan, 0.0, 100.0]
>>> Color('white').convert('oklch').convert('hsl').coords()
[16.193122576085646, -720.3736700421233, 100.00412133090708]