MrLixm / AgXc

Fork of Troy.S AgX, a display rendering transform available via OCIO and more
92 stars 8 forks source link

[OCIO] Handling of negatives values #11

Closed MrLixm closed 1 year ago

MrLixm commented 1 year ago

Following #9, I discovered that the ocio config is not handling negative properly in the AgX Log transform.

Initially I was using this kind of combinaison of transform to create a negative clamp :

- !<AllocationTransform> { allocation: lg2, vars: [ -10, 7, 0.0056065625 ] }
- !<FileTransform> { src: dummy.spi1d, interpolation: linear }
- !<AllocationTransform> { allocation: lg2, vars: [ -10, 7, 0.0056065625 ], direction: inverse }
...

The dummy LUT clipping to 0-1 range, but this actually doesn't work and introduce more negatives because of the <AllocationTransform>.

As such this has been removed in #10 .

Objective

Find a way to clamp negative values before the Matrix + Log2 transform are applied

Comparison

test_log after ↳ img1 current behavior

test_log ref ↳ img2 original AgX on OCIOv2

test_log reference negatives ↳ img3 reference image (unprocessed) - negatives mask

You can see some hard clipping on the small ColorsBars that initially have a lot of negatives.

sobotka commented 1 year ago

Given that the only recourse appears to be the dummy LUT, surely there is a mechanism to apply this pre-log?

I am still wondering why a simple clip post log, at the appropriate value range, is not working here, so perhaps you can elaborate?

At any rate, the allocation transform can also serve up a uniform distribution such that the desired range is normalized to the range specified. I would think that a reasonable range could work without introducing tremendous quantisation errors?

MrLixm commented 1 year ago

Hey Troy,

Here is why I am applying the clip post-log : image ↳ white = negatives corresponding transform :

    from_reference: !<GroupTransform>
      children:
        - !<MatrixTransform> { matrix: [ 0.842479062253094, 0.0784335999999992, 0.0792237451477643, 0, 0.0423282422610123, 0.878468636469772, 0.0791661274605434, 0, 0.0423756549057051, 0.0784336, 0.879142973793104, 0, 0, 0, 0, 1 ] }
        - !<AllocationTransform> { allocation: lg2, vars: [ -12.47393, 4.026069] }

As you can see the lg2 transform introduced negative on all pure black values and leave the original negatives value that were not clip. So by clipping I' making sure that at least the initially pure black value, get back to (0.0,0.0,0.0). Inital negatives are still fucked up but that is the reason of this ticket.

Also nice tip for the uniform transform, didn't even thought of it. I think using it in the pre-clip might actually fix my issue. Let me try.

MrLixm commented 1 year ago

Alright quick test of your proposition but no success. Using :

  - !<ColorSpace>
    name: AgX Log (Kraken)
    family: AgX
    equalitygroup: ""
    bitdepth: 32f
    description: AgX Log (Kraken)
    isdata: false
    allocation: uniform
    allocationvars: [ -12.47393, 4.026069 ]
    from_reference: !<GroupTransform>
      children:
        - !<AllocationTransform> { allocation: uniform, vars: [ -12.47393, 4.026069 ] }
        - !<FileTransform> { src: dummy.spi1d, interpolation: linear }
        - !<AllocationTransform> { allocation: uniform, vars: [ -12.47393, 4.026069 ], direction: inverse }

so with an uniform transform this time, no luck same issue and I don't know why.

The negatives are not clipped at all :

image

AllocationTransform still looks like black magic to me and I don't understand what's going on, because if the LUT hack is working prost-log, why it isn't working pre-log ?

sobotka commented 1 year ago

The transform itself could be a cause of error potentially? Are the allocation_vars set here? With V1 it was dark magic, and apparently not required with V2.

Logs of course produce negative values in the log encoding with less than one to zero values if they are just logs, but my understanding has always been that the AllocationTransform was normalized log.

Worth some experimenting to see if it can get sorted, or ask on the Slack.

MrLixm commented 1 year ago

Oi, I got something working ! https://github.com/MrLixm/AgXc/blob/1137d57b8b78a3d7ff86da59ded707fdd6d27469/ocio/config.ocio#L78-L81 Did a quick search on the OCIO slack using the word "clamp" with not much hope as it has the 30days restrictions .but ... I got something, someone mentioned that the CDLTransform was updated in v2 because it had an inconsistent negative clamp behaviour ! Exactly what I want.

So I just dropped 2 CDLTransform that cancel each other and ... Of course doesn't work cause OCIO smart enough to detect a no-op and remove the transforms. So I get even deeper into the hack and added a very small offset so it still register.

And success ! the link I send above is a working version ! I got negatives properly clamped and keep all the dynamic range.

Of course the only issue is that the data is slightly modified by the small increment in the last CDLTransform. It's really minimal but it can be visible by eye when zoomed in. This annoys me but I'm not sure I got a better choice.

Also this works on v1 and v2 (because the behaviour was kept for v1 config.) So for now it's a big win.

My only question left is :

should I clamp negatives produces BY the lg2 transform now ?

Because I clamp negatives before, but of course the [0,0,0] values produce negatives after the log transform. Should I clip them using the same hack ? Or are they expected (I guess not). Though on original AgX config running on OCIOv2, they are here.

MrLixm commented 1 year ago

Anyway, added the post-log negative clamp : https://github.com/MrLixm/AgXc/blob/589157178fb5a002dca5326a4606479c8a208b82/ocio/config.ocio#L77-L86

sobotka commented 1 year ago

but of course the [0,0,0] values produce negatives after the log transform.

I am not sure “of course” is logical here.

The clip at BT.709 relative tristimulus asserts that everything is within the chromaticity footprint, and that there is no way to escape the footprint.

Converting to a normalized log2 should also result in 0.0, representing the low value, to 1.0, representing the high value relative to the normalized log2.

The only issue is that when we apply a curve on a per channel basis, we in fact increase the purity of the tristimulus in a range of output triplets. Given we have an outset to restore the values to BT.709 positions, effectively stretching the values back outwards, we induce negatives based on those aforementioned mixtures and the curve interaction.

As best as I can tell, until the alpha of AgX drops, the clips are required at two positions:

  1. Just after the BT.709 relative encoding, prior to the logs, asserting that all values are valid prior to the image formation.
  2. Just after the outset to assert that all values are valid in terms of colourimetry down the wire. This would happen at the display medium anyways, but is a good practice to hold meanings in place.

If you are getting negatives anywhere else, I would be concerned.

MrLixm commented 1 year ago

Thanks for the explanation. My course made reference to the fact that a log transform cannot represent 0 ?

>>> import math
>>> math.log2(0.0)
ValueError: math domain error

So it make sense that instead we got negatives out. This is the behavior describe in the documentation :

https://opencolorio.readthedocs.io/en/latest/guides/authoring/allocation_vars.html one downside of this approach is that it can’t represent 0.0, which is why we optionally allow a 3d allocation var, a black point offset.

Which is what I used initially, but was producing offset black as pointed out in issue #9.

And that's why I'm now clipping those negatives.

sobotka commented 1 year ago

Yes, but I believe the AllocationTransform is a normalized log encoding.

That is, the range covered is normalized to the 0.0 to 1.0 range. This is also the technique that was used for zero to one shaders as a compression technique. The original Filmic configuration used the AllocationTransform as a mirror of the normalized log2 in the scripts, and it worked.

So negatives I do not believe represent the classic less than one range of a pure log, but rather values that are offset below the range.

I believe it mirrors something like the function below:

def open_domain_to_normalized_log2(
    in_od,
    in_middle_grey=0.18,
    minimum_ev=-7.0,
    maximum_ev=+7.0
):
    total_exposure = maximum_ev - minimum_ev

    in_od = numpy.asarray(in_od)
    in_od[in_od <= 0.0] = numpy.finfo(float).eps

    output_log = numpy.clip(
        numpy.log2(in_od / in_middle_grey),
        minimum_ev,
        maximum_ev
    )

So while logs indeed cannot represent true zero, a normalized log is a slightly different beast I believe, and this normalized log is the form in OpenColorIO.

MrLixm commented 1 year ago

Alrrright I got it thanks ! So I should not clip post-log cause those negatives are not a mistake.

sobotka commented 1 year ago

Post log I think makes sense? Let me outline:

  1. Open domain tristimulus projected to BT.709. Values are zero to infinity, relative to BT.709.
  2. Log. Values are zero to one, with low value being the minimum down. Clipping here is fine. We want a constrained zero to one range, ideally, as that represents low log2 to high log2 of the values that represent the totality of the value range that end up in the picture.
  3. Display medium encoded. Assuming we’ve done 2. correctly, there should never be a negative here. All values, if encoding for BT.709, are zero to one hundred percent.

Any negatives in the log, after the open domain clip, suggest that the values are within BT.709 but beyond the floor of the lower log2 range that forms the clay of the picture.

In the normalized log2, the 0.0 mark is the -log2 floor, and the 1.0 mark is the +log2 ceiling we choose.

Assuming the log2 normalization is correct in fact, you can clip zero to one here, and it should be valid!

MrLixm commented 1 year ago

Post log I think makes sense ?

That what I though first, then your explanation made me though of the opposite. And there is a few point that validate it :

Then also the log normalize everything as you mentioned. But it normalize everything that is in the range you gave it. If the input data has a maximum value higher than the maximum value of the log range, then it got above 1 and clipping result would result in loss of data.

Here is an example in Nuke :

https://user-images.githubusercontent.com/64362465/207937695-ca9459af-72c6-4f49-968c-f966f99f67d2.mp4

But anyway my goal was not to clip positives values above 1, only negatives. But if positives above 1 are still useful data, maybe negatives are also ?

Not gonna lie I'm kind of lost here. Something tells me that it makes sense to just clip negatives after the log transform, but as mentioned before, I have other arguments that make me think that this is not the approach to take.

sobotka commented 1 year ago

and here you are clipping negatives under -7, so there is still negatives.

There are no negatives in the pre-log. And none post log.

The source at BT.709 depends heavily on all values being zero or greater, otherwise the inset technique will not work; the inset footprint must be “clean”.

your AgX implementation on v2 actually produce negatives like when I don't use the post-clip, so it again make sense to remove it.

The inset will increase the purity of some values based on the curve. That is, the footprint has values that expand beyond it. When the outset puts the chromaticities back at the origin tristimulus, those values become negative.

If we try to order the operations, using regular language:

  1. We take everything relative to BT.709. Negatives are nonsensical, so here we clip. All values are greater than or equal to zero.
  2. We inset the primaries. Now we have effectively dechromaed the values, adding complimentary tristimulus magnitudes to the channels that had zero.
  3. We log the result. This distorts the values further. The log zero value must be limited at the low side log2 range, and the log one value is the high side. Nothing beyond that makes it out of the display medium.
  4. We curve the log. This sigmoid will force some ratios to reduce complimentary values, pushing the complimentary tristimulus lower. But remember, we are inset relative to BT.709, so while we are still legal BT.709, we have values that may get very close to the footprint edge.
  5. We outset. This is where a problem manifests; those values that were pushed out in tristimulus purity are now negative! They will get clipped at the display medium, but should be clipped for encoding certainty.

So we can see a few places where the relative domains will result in negatives.

MrLixm commented 1 year ago

Thanks for taking the time to put out a detailed explanation, I think I got it. I see you are talking about outset and display since few messages and I feel like I was maybe not clear enough. Until now I was focused only on the AgX Log "colourspace", that only includes the inset+log2 transform. That is the only thing that was problematic here.

Also I don't see the log as only an intermediate space for display, where if I understand well, indeed log limit give the maximum that reach display. But what about when the artist simply want to use it as a grading space ? I.e. workspace -> AgX Log, grading, AgX Log -> workspace. In that case we doesn't want to clip the data to the log limits yet.

your AgX implementation on v2 actually produce negatives like when I don't use the post-clip, so it again make sense to remove it.

Again I should have been more specific. Here I was talking about the AgX Log. Here is your original config used in Nuke, where I'm only applying a scene_linear -> AgX Log "colorspace" transform.

image

As you can see it spit out negatives on everything that was pure black initially.

Does those negatives matters ? I don't know, they will not make it out to the display anyway as you mentioned.

MrLixm commented 1 year ago

Deployed the new version 0.4.2 that adress the negative issue pre-log. It has been tested on various software and seems to works fine. The log "colorspace" transform still produces negatives in the output, and I still don't know if I should keep them (even though thanks Troy for all the explanations), but this seems to work fine and make the behaviour similar to the v2 config.