Skip to content

Further improve HCT conversion - #763

Open
facelessuser wants to merge 3 commits into
mainfrom
enhance/hct-convert
Open

facelessuser wants to merge 3 commits into
mainfrom
enhance/hct-convert

Conversation

@facelessuser

@facelessuser facelessuser commented Aug 7, 2026 •

Copy link
Copy Markdown
Collaborator
  1. Use an actually measured parameter that has been optimized for the majority of values in HCT. 1.832 allows faster and more precise convergence eliminating many cases were we in the past could not reach the target epsilon of 1e-12.
  2. Employ Ostrowski's method to refine the Newton correction step ensuring max number of calls to fromCam16 remains the same and cut the max number of iterations in half. In practice, we actually call fromCam16 less on average increasing performance as well as further ensuring we consistently reach or exceed the target epsilon of 1e-12.
  3. If derivative is too close to zero or the change between J and the corrected J is too small, exit the loop and return the best result we found so far.
  4. Loop iteration adjusted from <= to < allowing us to specify the number of loops instead of the number of loops - 1.
  5. Drop using the T -> J polynomial and just use the direct conversion of T -> Y and use that Y in a gray scale XYZ to CAM16 conversion to estimate the intial J

In short this gives us overall faster and more precise convergence. While the max iterations are cut in half, the max number of calls to fromCam16 in the worse case are still the same. Worst cases usually manifest outside the visible spectrum, e.g. ProPhoto RGB in the blue region, etc.

1. Use an actually measured parameter that has been optimized for the
   majority of values in HCT. 1.832 allows faster and more precise
   convergence eliminating many cases were we in the past could not
   reach the target epsilon of 1e-12.
2. Employ Ostrowski's method to refine the Newton correction step
   ensuring max number of calls to `fromCam16` remains the same and cut
   the max number of iterations in half. In practice, we actually call
   `fromCam16` less on average increasing performance as well as further
   ensuring we consistently reach or exceed the target epsilon of 1e-12.
3. If derivative is too close to zero or the change between J and
   the corrected J is too small, exit the loop and return the best
   result we found so far.
4. Loop iteration adjusted from <= to < allowing us to specify the
   number of loops instead of the number of loops - 1.

In short this gives us overall faster and more precise convergence.
While the max iterations are cut in half, the max number of calls to
`fromCam16` in the worse case are still the same. Worst cases usually
manifest outside the visible spectrum, e.g. ProPhoto RGB in the blue
region, etc.
@netlify

netlify Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for colorjs ready!

Name Link
🔨 Latest commit 1e5e35b
🔍 Latest deploy log https://app.netlify.com/projects/colorjs/deploys/6a7932016978100008d3a7a0
😎 Deploy Preview https://deploy-preview-763--colorjs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@facelessuser

Copy link
Copy Markdown
Collaborator Author

I had been annoyed that convergence was not as good in some places, even when we were in reasonable gamuts. Finally getting around to measuring the derivative of J in respect to Y really helped us pin down a much better J' approximation:

print("==== Estimate c for Approximate J' Derivative: (c * y) / J ====")
# To obtain the first derivative for `J'`, we manually measure the rate of change
# at various points for `J` in relation to `y` in a CAM16 with the same environment
# that HCT uses. From this, it can be noted that if `dy / dJ` is divided by `y / J`,
# we get a fairly constant value (roughly between 1.8 - 2). From this, we have a
# rough approximation of `J' ~= K * y / J`, where we set `K` to the measured average of
# `~1.832`.
#
# It can be noted that we do not just focus on achromatic values, but
# calculate the derivative on random colors in the Rec. 2020 gamut.
total = 0
count = 0
dy = 1e-12
for _ in range(200001):
    c = Color.random('rec2020').convert('xyy')
    y = c['Y']
    j = cam16.xyz_to_cam(c.convert('xyz-d65').coords(), env)[0]

    c['Y'] = y + dy
    j1 = cam16.xyz_to_cam(c.convert('xyz-d65').coords(), env)[0]
    c['Y'] = y - dy
    j2 = cam16.xyz_to_cam(c.convert('xyz-d65').coords(), env)[0]
    # Rate of change: `dy/dj`
    dydj = (2 * dy) / (j1 - j2)
    # If we assume this value is mainly dependent on J and Y,
    # and divide by the ratio of `Y / J`, we end up with a
    # constant `c` for an equation `c * Y / J`
    total += dydj * (j / y)
    count += 1

print(round(total / count, 3))

Using a better approximation improved results greatly, but because the first derivative is an approximation, there were a very small number of cases that converged a little slower, as 2 was a better result, but by adding something like Ostrowski's method (which allows us to not have to estimate a second derivative on top an already estimated first derivative) it refined the corrections such that even those cases were elliminated while also giving us overall quicker convergence.

@facelessuser

Copy link
Copy Markdown
Collaborator Author

Failing lint action seems to be unrelated to these changes.

Now that we have a better approximation algorithm, we can approximate
J by just using a gray scale XYZ using the direct conversion of T -> Y.

Also, if we are going to check if derivative is close to zero, don't
invert the derivative.
@facelessuser

Copy link
Copy Markdown
Collaborator Author

Ended up dropping the polynomial initial J guess.

With the better approximation, we can actually just use the actual Y value and calculate a J using the D65 white point.

This actually ends up working better now than the polynomial. Or I should say that it works better in low-light, high-chroma cases. They both work fine for in-gamut colors, but for tone mapping, this correction is required moving forward.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant