[python][bug] `GradingPrimary.contrast` odd behavior.
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.
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.
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.
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 :)