Skip to content

Make Rec. 709 display-referred and provide old scene-referred variant - #490

Merged
facelessuser merged 4 commits into
mainfrom
feature/rec709-gamma-2.4
Sep 8, 2025
Merged

Make Rec. 709 display-referred and provide old scene-referred variant#490
facelessuser merged 4 commits into
mainfrom
feature/rec709-gamma-2.4

Conversation

@facelessuser

Copy link
Copy Markdown
Owner

No description provided.

@gir-bot gir-bot added S: needs-review Needs to be reviewed and/or approved. C: docs Related to documentation. C: infrastructure Related to project infrastructure. C: source Related to source code. C: tests Related to testing. labels Sep 6, 2025
@facelessuser

Copy link
Copy Markdown
Owner Author

Much like Rec. 2020, Rec. 709 is often used with the BT.1886 transfer function for a display-referred space. This makes the change to Rec. 709, but provides the old spaces as rec709-oetf.

It should be noted that round trip with HCT finds a single case where the round trip back from HCT is only at 4 decimal's of precision. This isn't a surprise, and if we tested more cases, I'm sure we'd find a few more here and there. Anyway, we can keep the test the same if we bump the HCT tolerance to 1e-13 instead of 1e-12, but we might be able to gain that back some other way...or we can just set HCT round trip testing to only require 4 decimals of precision. Nothing is broken, and nothing is wrong, it's more just a reality of HCT approximation back to XYZ, and when paired with Rec. 709 with a 2.4 gamma, it just happened to land on a case that exposes this.

@facelessuser

Copy link
Copy Markdown
Owner Author

For now, we'll bump the HCT epsilon from 2e-12 to 2e-13 and we'll run some performance testing. Requiring a little tighter threshold will obviously cause at least an additional iteration on occasions, so we'll try to measure overall impact.

@facelessuser

Copy link
Copy Markdown
Owner Author

The cost of making the HCT threshold smaller affects all conversions. This makes sense as it is targeting greater precision on the XYZ conversion, which we would normally kick out of with less precision. This doesn't target our specific case, but helps it indirectly. An additional decimal of precision in HCT -> XYZ conversion ensures the XYZ -> Rec709 (gamma 2.4) conversion gives us 6 decimals of precision (at least in this case).

But is the increase of 2 - 3 microseconds per conversion worth the price for the rare drop to 4 decimals of precision during conversion?

@facelessuser
facelessuser force-pushed the feature/rec709-gamma-2.4 branch from a02eb67 to 9825493 Compare September 8, 2025 01:55
@facelessuser
facelessuser force-pushed the feature/rec709-gamma-2.4 branch from 078589b to 2a9f6aa Compare September 8, 2025 02:23
@facelessuser

Copy link
Copy Markdown
Owner Author

As a sanity check, here is the current "failing" case that does not get enough precision.

We can see that the XYZ conversion does, in fact, give us 11 decimals of precision:

>>> Color('indigo').convert('rec709').convert('xyz-d65').coords()
[0.06930421383061716, 0.031076643893895917, 0.21354546896536397]
>>> Color('indigo').convert('rec709').convert('hct').convert('xyz-d65').coords()
[0.06930421383171137, 0.031076643894622946, 0.21354546896769142]

And we can see that those values give us 11 decimals of precision in all channels but the green channel in Rec. 709, the one close to zero.

>>> Color('indigo').convert('rec709').coords()
[0.3309172010036242, 0.0, 0.535359167363628]
>>> Color('indigo').convert('rec709').convert('hct').convert('rec709')
color(--rec709 0.33092 0.00001 0.53536 / 1)
>>> Color('indigo').convert('rec709').convert('hct').convert('rec709').coords()
[0.3309172010061091, 6.826777574939795e-06, 0.5353591673659989]

It actually seems both cases where we use the new BT.1886 transfer function, for both Rec. 2020 and Rec. 709 give lower round trip in the values close to zero:

>>> Color('rec709', [0.29411764705882354, 0.0, 0.5098039215686274]).convert('hct').convert('rec709').coords()
[0.29411764705887256, 1.300429410043695e-06, 0.5098039215686734]
>>> Color('rec2020', [0.29411764705882354, 0.0, 0.5098039215686274]).convert('hct').convert('rec2020').coords()
[0.29411764705885607, 1.1058130928692618e-06, 0.5098039215686583]

As a matter of fact, this happens when doing conversion straight through XYZ only:

>>> Color('rec2020', [0.29411764705882354, 0.0, 0.5098039215686274]).convert('xyz-d65').convert('rec2020').coords()
[0.2941176470588236, -5.8275714934138606e-08, 0.5098039215686274]

I would have expected the zero channel to be at least 1e-12 or something. So, maybe it's just the sqr root and inverse sqr root back forth using 2.4 gamma with values close to zero.

@facelessuser

Copy link
Copy Markdown
Owner Author

So, yeah, floating point error coupled with the power function is what causes this issue:

This shows linear space to gamma corrected Rec. 709 and back to linear. Conversion is perfect.

===> inverse
[0.29411764705882354, 0.0, 0.5098039215686274]
[0.6005517699069883, 0.0, 0.755239428529458]
===> forward
[0.6005517699069883, 0.0, 0.755239428529458]
[0.2941176470588236, 0.0, 0.5098039215686274]

When we pass through XYZ, and come back as linear, we can note the zero value is very, very close to zero, but when we pass through the EOTF, the power function gives us something around Xe-8.

===> inverse
[0.2941176470588236, 1.0408340855860843e-17, 0.5098039215686273]
[0.6005517699069883, 8.392840329713184e-08, 0.7552394285294579]

This is probably why many EOTF and OETF when close to zero, do something different. I think this is just how it works, and the HCT conversion can't compensate for that; it just emphasizes the conversion weakness as it is a little less accurate.

def eotf_srgb(rgb: Vector) -> Vector:
    """
    Convert an array of sRGB values in the range 0.0 - 1.0 to linear light (un-corrected) form.

    https://en.wikipedia.org/wiki/SRGB
    """

    result = []
    for i in rgb:
        # Mirror linear nature of algorithm on the negative axis
        abs_i = abs(i)
        if abs_i > 0.04045:
            result.append(math.copysign(((abs_i + 0.055) / 1.055) ** 2.4, i))
        else:
            result.append(i / 12.92)
    return result

@facelessuser

Copy link
Copy Markdown
Owner Author

Yep, Adobe 98 RGB does the same thing and is why we bumped precision earlier from 1e-8 to 1e-12. It's likely it can still fall under our current threshold if the right color was picked with a channel super close to zero. We'll provide more nuanced test options to can target HCT with specific color spaces so we can ensure all other spaces have higher accuracy.

@facelessuser
facelessuser force-pushed the feature/rec709-gamma-2.4 branch from ce22e30 to af304cf Compare September 8, 2025 15:04
@facelessuser
facelessuser force-pushed the feature/rec709-gamma-2.4 branch from af304cf to ba28581 Compare September 8, 2025 15:08
@facelessuser
facelessuser marked this pull request as ready for review September 8, 2025 15:22
@facelessuser

Copy link
Copy Markdown
Owner Author

@gir-bot lgtm

@gir-bot gir-bot added S: approved The pull request is ready to be merged. and removed S: needs-review Needs to be reviewed and/or approved. labels Sep 8, 2025
@facelessuser
facelessuser merged commit d63051d into main Sep 8, 2025
18 checks passed
@facelessuser
facelessuser deleted the feature/rec709-gamma-2.4 branch September 8, 2025 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C: docs Related to documentation. C: infrastructure Related to project infrastructure. C: source Related to source code. C: tests Related to testing. S: approved The pull request is ready to be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants