OpenColorIO icon indicating copy to clipboard operation
OpenColorIO copied to clipboard

[python][bug] `GradingPrimary.contrast` odd behavior.

Open MrLixm opened this issue 3 years ago • 3 comments

Hello, yes me again, I'm sorry but seems this poor GradingPrimary class didn't get as much love as it should have.

This time I'm simply modifying the contrast attribute but I can't manage to get a result I can reproduce in Nuke, nor understand the logic behind GradingRGBM.

As mentioned in issue #1640 , some attribute require a GradingRGBM instance and other a float. But I don't understand why do we need to specify a red/green/blue value when the output result observed doesn't change the overall "hue" at all but just act as a regular contrast on the 3 channels.

Even when modifying the master and attribute and leaving the r/g/b attributes at 1.0 (test_contrast_master), the result is still different than expected.

As always here are the following test suite I ran, expected values are computed from Nuke using the same 0.18 pivot. The 2 last tests are with exposure to try to get the logic, but once again r/g/b give unexpected result while this time the master value works as expected.

unittest.py (code not relevant anymore, check next comment)
# python>3
import unittest
from pathlib import Path
from typing import Tuple

import PyOpenColorIO as ocio
import numpy
import numpy.testing


def make_img(color: Tuple[float, float, float]):
    """Create a 64x64 RGB image with the given color."""
    return numpy.full((64, 64, 3), color, dtype=numpy.float32)


class TestGradingPrimaryTransform(unittest.TestCase):

    config_path = Path(
        r"YOURCONFIG\config.ocio"
    )

    def setUp(self) -> None:
        self.config: ocio.Config = ocio.Config().CreateFromFile(str(self.config_path))
        self.img1 = make_img((0.5, 0.1, 0.1))
        self.gp: ocio.GradingPrimary = ocio.GradingPrimary(ocio.GRADING_LIN)
        return

    def tearDown(self) -> None:
        self.config = None
        self.img1 = None
        self.gp = None
        return

    def _apply_gp_on_img(self):

        tsfm_gp = ocio.GradingPrimaryTransform(
            self.gp,
            ocio.GRADING_LIN,
            False,
        )

        proc: ocio.Processor = self.config.getProcessor(tsfm_gp)
        proc: ocio.CPUProcessor = proc.getDefaultCPUProcessor()

        proc.applyRGB(self.img1)

        return

    def test_contrast_r(self):

        self.gp.contrast = ocio.GradingRGBM(0.1, 1.0, 1.0, 1.0)

        self._apply_gp_on_img()

        expected = make_img((0.22305, 0.1, 0.1))
        numpy.testing.assert_almost_equal(
            self.img1,
            expected,
            4,
            f"img1 is actually {self.img1[1][1]} while expected is {expected[1][1]}",
        )

    def test_contrast_r_triggered(self):
        """
        Trying to see if contrast need to be manually triggered by another
        parameter change.
        """

        self.gp.contrast = ocio.GradingRGBM(0.1, 1.0, 1.0, 1.0)
        self.gp.clampBlack = 0.15

        self._apply_gp_on_img()

        expected = make_img((0.22305, 0.15, 0.15))
        numpy.testing.assert_almost_equal(
            self.img1,
            expected,
            4,
            f"img1 is actually {self.img1[1][1]} while expected is {expected[1][1]}",
        )

    def test_contrast_rg(self):

        self.gp.contrast = ocio.GradingRGBM(0.1, 0.2, 1.0, 1.0)

        self._apply_gp_on_img()

        expected = make_img((0.22305, 0.04880, 0.1))
        numpy.testing.assert_almost_equal(
            self.img1,
            expected,
            4,
            f"img1 is actually {self.img1[1][1]} while expected is {expected[1][1]}",
        )

    def test_contrast_rgb(self):

        self.gp.contrast = ocio.GradingRGBM(0.1, 0.2, 1.2, 1.0)

        self._apply_gp_on_img()

        expected = make_img((0.22305, 0.04880, 0.11965))
        numpy.testing.assert_almost_equal(
            self.img1,
            expected,
            4,
            f"img1 is actually {self.img1[1][1]} while expected is {expected[1][1]}",
        )

    def test_contrast_master(self):

        self.gp.contrast = ocio.GradingRGBM(1.0, 1.0, 1.0, 0.66)

        self._apply_gp_on_img()

        expected = make_img((0.36858, 0.07372, 0.07372))
        numpy.testing.assert_almost_equal(
            self.img1,
            expected,
            4,
            f"img1 is actually {self.img1[1][1]} while expected is {expected[1][1]}",
        )

    def test_exposure_rgb(self):

        self.gp.exposure = ocio.GradingRGBM(0.0, 0.2, 0.6, 1.0)

        self._apply_gp_on_img()

        expected = make_img((0.5, 0.11487, 0.15157))
        numpy.testing.assert_almost_equal(
            self.img1,
            expected,
            4,
            f"img1 is actually {self.img1[1][1]} while expected is {expected[1][1]}",
        )

    def test_exposure_master(self):

        self.gp.exposure = ocio.GradingRGBM(0.0, 0.0, 0.0, 0.5)

        self._apply_gp_on_img()

        expected = make_img((0.70711, 0.14142, 0.14142))
        numpy.testing.assert_almost_equal(
            self.img1,
            expected,
            4,
            f"img1 is actually {self.img1[1][1]} while expected is {expected[1][1]}",
        )


if __name__ == "__main__":
    unittest.main()

(Here only the last test test_exposure_master pass)

I'm looking forward to seeing if this is a user error on this one or some bug.

CONTEXT: Windows10, OCIO 2.1.0

Cheers. Liam.

Edit

Check last comment.

MrLixm avatar May 09 '22 21:05 MrLixm

Hey, I'm continuing investigating on this issue. I rebuild my test suite to check more cases :

# python>3
import unittest
from pathlib import Path
from typing import Tuple, Optional

import PyOpenColorIO as ocio
import numpy
import numpy.testing


def apply_contrast_linear(
    array: numpy.ndarray,
    contrast: Tuple[float, float, float, float],
    pivot: float,
) -> numpy.ndarray:
    r"""
    src\OpenColorIO\ops\gradingprimary\GradingPrimaryOpCPU.cpp#L180
    """
    c = numpy.asarray(contrast[:-1]) * contrast[-1]
    # src\OpenColorIO\ops\gradingprimary\GradingPrimary.cpp#L194
    pivot_actual = 0.18 * pow(2, pivot)
    return numpy.power(array / pivot_actual, c) * pivot_actual


def apply_contrast(
    array: numpy.ndarray,
    contrast: Tuple[float, float, float, float],
    pivot: float,
) -> numpy.ndarray:
    r"""
    src\OpenColorIO\ops\gradingprimary\GradingPrimaryOpCPU.cpp#L173
    """
    c = numpy.asarray(contrast[:-1]) * contrast[-1]
    # src\OpenColorIO\ops\gradingprimary\GradingPrimary.cpp#L151
    pivot_actual = 0.5 + pivot * 0.5
    return (array - pivot_actual) * c + pivot_actual


def make_img(color: Tuple[float, float, float]):
    """Create a 64x64 RGB image with the given color."""
    return numpy.full((3, 3, 3), color, dtype=numpy.float32)


class TestGradingPrimaryTransformContrast(unittest.TestCase):

    config_path = Path(
        r"G:\personal\code\LiamImageProcessing\workspace\v0001\ImageProcessing-Liam\src\OCIOexperiments\dev\data\configs\AgXc-v0.1.4\config.ocio"
    )

    def setUp(self) -> None:

        self.config: ocio.Config = ocio.Config().CreateFromFile(str(self.config_path))
        self.img1 = make_img((0.5, 0.1, 0.1))

        self.gp: ocio.GradingPrimary = None
        self.style: ocio.GradingStyle = None
        self.contrast: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)
        self.pivot: float = 0.18
        self.set_gp_pivot = True

        return

    def tearDown(self) -> None:

        assert self.gp
        assert self.style

        self.gp.contrast = ocio.GradingRGBM(*self.contrast)
        if self.set_gp_pivot:
            self.gp.pivot = self.pivot
        else:
            self.pivot = self.gp.pivot

        if self.style == ocio.GRADING_LIN:
            expected = apply_contrast_linear(self.img1, self.contrast, self.pivot)
        elif self.style == ocio.GRADING_LOG:
            expected = apply_contrast(self.img1, self.contrast, self.pivot)

        result = self._get_gp_result()

        print(f"{'-'*50}\n{self.id()}\n\n{self.gp}\n")
        print(
            f"c={self.contrast}, p={self.pivot}(set={self.set_gp_pivot}), "
            f"style={self.style}\n\n"
        )
        print(f"expected={expected[1][1]}, result={result[1][1]}")

        numpy.testing.assert_almost_equal(
            result,
            expected,
            4,
            f"result is actually {result[1][1]} while expected is {expected[1][1]}",
        )

        self.gp = None
        self.config = None
        self.img1 = None
        self.style = None
        self.contrast = None
        self.pivot = None
        return

    def _get_gp_result(self) -> numpy.ndarray:

        tsfm_gp = ocio.GradingPrimaryTransform(
            self.gp,
            self.style,
            False,
        )

        proc: ocio.Processor = self.config.getProcessor(tsfm_gp)
        proc: ocio.CPUProcessor = proc.getDefaultCPUProcessor()

        out = self.img1.copy()
        proc.applyRGB(out)
        return out

    def test_contrast_r_lin(self):

        self.style = ocio.GRADING_LIN
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (0.66, 1.0, 1.0, 1.0)
        self.pivot: float = 0.18

    def test_contrast_r_triggered_1_lin(self):
        """
        Trying to see if contrast need to be manually triggered by another
        parameter change.
        """

        self.style = ocio.GRADING_LIN
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (0.66, 1.0, 1.0, 1.0)
        self.pivot: float = 0.18
        self.gp.clampBlack = -150

    def test_contrast_r_triggered_2_lin(self):
        """
        Trying to see if contrast need to be manually triggered by another
        parameter change.
        """

        self.style = ocio.GRADING_LIN
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (0.66, 0.99, 0.99, 1.0)
        self.pivot: float = 0.18

    def test_contrast_r_lin_nop(self):

        self.style = ocio.GRADING_LIN
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (0.66, 1.0, 1.0, 1.0)
        self.pivot: float = 0.18
        self.set_gp_pivot = False

    def test_contrast_rg_lin(self):

        self.style = ocio.GRADING_LIN
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (0.1, 0.2, 1.0, 1.0)
        self.pivot: float = 0.18

    def test_contrast_rgb_lin(self):

        self.style = ocio.GRADING_LIN
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (0.1, 0.2, 1.2, 1.0)
        self.pivot: float = 0.18

    def test_contrast_rgb_lin_nop(self):

        self.style = ocio.GRADING_LIN
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (0.1, 0.2, 1.2, 1.0)
        # self.pivot: float = 0.18
        self.set_gp_pivot = False

    def test_contrast_rgb_log(self):

        self.style = ocio.GRADING_LOG
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (0.1, 0.2, 1.2, 1.0)
        self.pivot: float = 0.18

    def test_contrast_rgb_log_nop(self):

        self.style = ocio.GRADING_LOG
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (0.1, 0.2, 1.2, 1.0)
        # self.pivot: float = 0.18
        self.set_gp_pivot = False

    def test_contrast_master_lin(self):

        self.style = ocio.GRADING_LIN
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (1.0, 1.0, 1.0, 0.66)
        self.pivot: float = 0.18

    def test_contrast_master_lin_nop(self):

        self.style = ocio.GRADING_LIN
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (1.0, 1.0, 1.0, 0.66)
        # self.pivot: float = 0.18
        self.set_gp_pivot = False

    def test_contrast_master_log(self):

        self.style = ocio.GRADING_LOG
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (1.0, 1.0, 1.0, 0.66)
        self.pivot: float = 0.18

    def test_contrast_master_log_nop(self):

        self.style = ocio.GRADING_LOG
        self.gp = ocio.GradingPrimary(self.style)
        self.contrast = (1.0, 1.0, 1.0, 0.66)
        # self.pivot: float = 0.18
        self.set_gp_pivot = False


if __name__ == "__main__":
    unittest.main()

Where the results are the following :

❌ test_contrast_r_lin ❌ test_contrast_r_triggered_1_lin ✅ test_contrast_r_triggered_2_lin ❌ test_contrast_r_lin_nop ❌ test_contrast_rg_lin ✅ test_contrast_rgb_lin ✅ test_contrast_rgb_lin_nop ✅ test_contrast_rgb_log ✅ test_contrast_rgb_log_nop ✅ test_contrast_master_lin ✅ test_contrast_master_lin_nop ✅ test_contrast_master_log ✅ test_contrast_master_log_nop

My hypothesis for now are:

  • specifying something other than the 3 RGB channels different from the default value doesn't trigger a change.
  • ~~linear contrast formula is working but log one doesn't work : my implementation is wrong OR something buggy on OCIO side~~

I still need to figure out:

  • Why log pivot is by default at -0.2
  • Why log contrast formula is different from linear formula

But I have no more time for tonight. I will get back to it tomorrow and hopefully, I found something. Stay tuned for ep03, cheers, Liam.

MrLixm avatar May 11 '22 21:05 MrLixm

Updated my code in the above comment. I indeed had some implementations issue of the contrast formula. Right now it should be a perfect copy/paste from the cpp implementation. This change my conclusions:

The issue

I can now suppose that there is indeed an issue in the implementation, I'm just not sure if this is intended or a mistake :

https://github.com/AcademySoftwareFoundation/OpenColorIO/blob/b00877959f4497f9b8e5a08859d0ef8059fdd8e5/src/OpenColorIO/ops/gradingprimary/GradingPrimary.cpp#L192-L193

I assume in the above that it should be a logical AND and not an OR ? Because right now, as the unittest show if any of the GradingRGBM R-G-B value is default, it is considered as an identity.

This issue can be found in the 3 Grading style.

Questions Left

This is not an issue but rather some knowledge missing on my side, but why does the contrast implementation is different between GRADING_LOG and GRADING_LIN ?

Is there some papers I can read that detail this implementation ?

Cheers. Liam.

MrLixm avatar May 12 '22 12:05 MrLixm

I assume in the above that it should be a logical AND and not an OR ? Because right now, as the unittest show if any of the GradingRGBM R-G-B value is default, it is considered as an identity.

I think you found the bug here.

This is not an issue but rather some knowledge missing on my side, but why does the contrast implementation is different between GRADING_LOG and GRADING_LIN ?

I think this is simply that a multiplication in linear maps to an addition in log space, same for the power and multiplication respectively. The contrast operator basically center the space on the pivot value then adjust the slope (gamma) around that point and finally undo the initial step. That is from my naive understanding on the implementation :)

remia avatar May 20 '22 20:05 remia