Skip to content

Instantly share code, notes, and snippets.

@remia
Created September 27, 2022 18:02
Show Gist options
  • Select an option

  • Save remia/4cab0fb18a794d699d8ae2d6b77091a4 to your computer and use it in GitHub Desktop.

Select an option

Save remia/4cab0fb18a794d699d8ae2d6b77091a4 to your computer and use it in GitHub Desktop.
ICC and ParametricCurve tests in Python
import os
import re
import subprocess as sp
import tempfile
from warnings import warn
import matplotlib
matplotlib.use('Qt5Agg')
import matplotlib.pyplot as plt
import numpy as np
import colour
import PyOpenColorIO as OCIO
# print(f"OCIO {OCIO.__version__}")
#
# ICC Utilities
#
# Conversions to / from ICC s15Fixed16Number
def float_to_s15f16(x):
return np.around(x * 2**16).astype(np.int32)
def s15f16_to_float(x):
return x / 2**16
def s15f16_roundtrip(x):
return s15f16_to_float(float_to_s15f16(x))
def quantize_params(params):
""" For Python side computation, make sure we simulate imprecisions due
to the s15f16 encoding used for paraCurve parameters.
"""
for name, value in params.items():
if name != "t":
params[name] = s15f16_roundtrip(value)
return params
# The code below uses IccFromXML and IccApplyNamedCmm
# Requires DemoICCMax command line tools which you can download at:
# https://www.color.org/iccmax/index.xalter (Implementation section)
# Alternatively, build from https://github.com/InternationalColorConsortium/DemoIccMAX
# Expect some difficulties, including some RPATH issues when using CMake and macOS.
# DEMOICCMAX_BINS = "/Users/remi/ColorCode/DemoIccMAX/TestingBins-2.1.10"
DEMOICCMAX_BINS = "/Users/remi/ColorCode/DemoIccMAX/cmake_build/myinstall/bin"
def make_para_icc_profile(icc_profile_path, params):
""" Generate a MTX+TRC ICC profile with the requested ParametricCurve."""
para_type = params.get("t")
para_params = " ".join([str(p) for p in list(params.values())[1:]])
icc_profile = f"""<?xml version="1.0" encoding="UTF-8"?>
<IccProfile>
<Header>
<ProfileVersion>4.4</ProfileVersion>
<ProfileDeviceClass>mntr</ProfileDeviceClass>
<DataColourSpace>RGB</DataColourSpace>
<PCS>XYZ</PCS>
<CreationDateTime>2022-01-01T00:00:00</CreationDateTime>
<RenderingIntent>Perceptual</RenderingIntent>
<PCSIlluminant>
<XYZNumber X="0.96420288" Y="1.00000000" Z="0.82490540"/>
</PCSIlluminant>
<ProfileCreator>OCIO Project</ProfileCreator>
</Header>
<Tags>
<copyrightTag>
<textType>
<TextData><![CDATA[Copyright Contributors to the OpenColorIO Project. Generated by DemoICCMax's IccFromXML.]]></TextData>
</textType>
</copyrightTag>
<mediaWhitePointTag>
<XYZArrayType>
<XYZNumber X="0.95045471" Y="1.00000000" Z="1.08905029"/>
</XYZArrayType>
</mediaWhitePointTag>
<mediaBlackPointTag>
<XYZArrayType>
<XYZNumber X="0.00000000" Y="0.00000000" Z="0.00000000"/>
</XYZArrayType>
</mediaBlackPointTag>
<redColorantTag>
<XYZArrayType>
<XYZNumber X="0.43606567" Y="0.22248840" Z="0.01391602"/>
</XYZArrayType>
</redColorantTag>
<greenColorantTag>
<XYZArrayType>
<XYZNumber X="0.38514709" Y="0.71687317" Z="0.09707642"/>
</XYZArrayType>
</greenColorantTag>
<blueColorantTag>
<XYZArrayType>
<XYZNumber X="0.14306641" Y="0.06060791" Z="0.71409607"/>
</XYZArrayType>
</blueColorantTag>
<redTRCTag>
<parametricCurveType>
<ParametricCurve FunctionType="{para_type}">
{para_params}
</ParametricCurve>
</parametricCurveType>
</redTRCTag>
<greenTRCTag SameAs="redTRCTag"/>
<blueTRCTag SameAs="redTRCTag"/>
</Tags>
</IccProfile>
"""
# Create a new ICC profile with iccFromXML
with tempfile.NamedTemporaryFile(suffix=".xml", mode="w") as xml_path:
xml_path.writelines(icc_profile)
xml_path.flush()
try:
stdout = sp.check_output([
f"{DEMOICCMAX_BINS}/IccFromXML",
xml_path.name, # XML input file
icc_profile_path, # ICC output file
], text=True)
except sp.CalledProcessError as e:
print("Error", e.stdout)
CCS_D50 = colour.CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"]["D50"]
CCS_D65 = colour.CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"]["D65"]
def apply_icc_profile(x, icc_profile, invert=False, intent=1):
""" Apply the input ICC profile to the input ndarray.
The data will be interpreted as RGB device triplets for the forward
direction and XYZ (D50) PCS triplets otherwise. One dimensional input
arrays will be interpreted as grayscale R=G=B or D50 Y depending on the
direction requested.
Rendering intent parameter follow IccApplyNamedCmm enumeration.
"""
FP = 15 # Float precision for text data interchange
in_dims = len(x.shape)
with tempfile.NamedTemporaryFile(mode='w') as f:
# PCS to Device direction.
if invert == True:
# Lab or XYZ are supported as input, Lab seems to introduce a slight
# increase in error relative compared to Python.
# Just change the Data Format header from XYZ to Lab and apply
# the required conversion when choosing to input Lab triplets.
f.write("'XYZ ' ; Data Format\n")
f.write("icEncodeValue ; Encoding\n")
for i in range(x.shape[0]):
if in_dims == 2:
XYZ = x[i, :]
else:
XYZ = colour.xyY_to_XYZ([CCS_D50[0], CCS_D50[1], x[i]])
f.write(f"{(XYZ[0]):.{FP}f} {(XYZ[1]):.{FP}f} {(XYZ[2]):.{FP}f}\n")
# Device to PCS direction.
else:
f.write("'RGB ' ; Data Format\n")
f.write("icEncode8bit ; Encoding\n")
for i in range(x.shape[0]):
if in_dims == 2:
RGB = np.rint(x[i, :] * 255)
else:
RGB = np.tile(np.rint(x[i] * 255), 3)
f.write(f"{RGB[0]} {RGB[1]} {RGB[2]}\n")
f.flush()
# Print input data (debugging only)
# with open(f.name, "r") as f:
# print(f.read())
try:
stdout = sp.check_output([
f"{DEMOICCMAX_BINS}/IccApplyNamedCmm",
f.name, # Input data
f"0:{FP}", # Final data encoding : Precision (icEncodeValue)
"0", # Interpolation (linear)
os.path.abspath(icc_profile), # ICC profile
f"{intent}" # Rendering intent (relative)
], text=True)
# Print output data (debugging only)
# print(stdout)
d = r"\s*(-?\d+\.\d+)"
matchs = re.findall(fr"^{d}{d}{d}\s*;{d}{d}{d}\s*$", stdout, re.MULTILINE)
results = np.zeros((len(matchs), 6))
for idx, match in enumerate(matchs):
results[idx, :] = match[:]
return results[:, :3]
except sp.CalledProcessError as e:
print(e.stdout)
#
# ParametricCurve - OCIO implementation
#
def apply_icc_para_ocio(x, invert=False, **kw):
""" Apply the ParametricCurve using OCIO processing.
Construct an OCIO config with the corresponding transforms on the fly.
"""
# Increasing the LUT size seems to cause loss of precision in the inverse
# direction, could be related to the gamma curve being too flat near 0 and
# hitting float precision errors?
OCIO_LUT_SIZE = 2**10
with tempfile.NamedTemporaryFile(suffix=".spi1d") as lut_path:
config = OCIO.Config.CreateRaw()
config.addColorSpace(OCIO.ColorSpace(name="Linear"))
# Simple ExponentTransform for type 0
if kw.get("t", None) == 0:
g = kw.get("g")
config.addColorSpace(OCIO.ColorSpace(
name="ParaCurve",
toReference=OCIO.ExponentTransform(value=[g, g, g, 1])))
# Generate 1DLUT for type 1-4
else:
lut = colour.LUT1D(size=OCIO_LUT_SIZE)
lut.table = apply_iccpara_python(lut.table, **kw)
colour.write_LUT(lut, lut_path.name)
config.addColorSpace(OCIO.ColorSpace(
name="ParaCurve",
toReference=OCIO.FileTransform(src=lut_path.name)))
# Pixel processing
if invert:
proc = config.getProcessor("Linear", "ParaCurve")
else:
proc = config.getProcessor("ParaCurve", "Linear")
x_rgb = np.tile(x[..., np.newaxis], (1, 3)).astype(np.float32)
proc.getDefaultCPUProcessor().applyRGB(x_rgb)
return x_rgb[..., 1]
#
# ParametricCurve - Python implementation
#
def qb(v, n=8):
""" Quantize the input float ndarray to n bits."""
return np.around(v * (2**n - 1)).astype(np.int32) / (2**n - 1)
def check_paracurve_params(params):
""" ParametricCurve specification does not impose constraints on parameters,
some combinations can produce ill-defined curves with a range of issues.
Implements the checks recommended by the ICC (p.15):
https://www.color.org/whitepapers/ICC_White_Paper35-Use_of_the_parametricCurveType.pdf
"""
t = params.get("t")
g = params.get("g")
a = params.get("a")
b = params.get("b")
c = params.get("c")
d = params.get("d")
e = params.get("e")
f = params.get("f")
###########################################################################
# Expected number of parameters per paraCurve type
###########################################################################
num_params = {
0: 1,
1: 3,
2: 4,
3: 5,
4: 7,
}
if len(params) - 1 != num_params[t]:
raise ValueError("Invalid number of parameter for paraCurve")
###########################################################################
# Monotically non-decreasing (flat segments permitted) check
###########################################################################
# g > 0 force the power law to be monotically non-decreasing
if g <= 0.0:
raise ValueError(f"ParaCurve {t} expect 0 < x <= 1 but got {g}")
# a > 0 force the argument to the power law to be an increasing function of x
if (t != 0) and a <= 0.0:
raise ValueError(f"ParaCurve {t} expect a > 0 but got {a}")
# c >= 0 force the linear segment to be flat or increasing
if (t == 3 or t == 4) and c < 0:
raise ValueError(f"ParaCurve {t} expect c >= 0 but got {c}")
# check for negative discontinuity at the linear segment boundary
if (t == 3) and not qb(c * d) <= qb(np.power(a * d + b, g)):
raise ValueError(f"ParaCurve {t} expect cd <= (ad + b)^g")
# check for negative discontinuity at the linear segment boundary
if (t == 4) and not qb(c * d + f) <= qb(np.power(a * d + b, g) + e):
raise ValueError(f"ParaCurve {t} expect cd + f <= (ad+b)^g + e")
###########################################################################
# No complex / imaginary numbers check
###########################################################################
# negative arguments to the power can produce imaginary numbers
if (t == 3 or t == 4) and (a * d + b) < 0:
raise ValueError(f"ParaCurve {t} invalid parameters (imaginary numbers not allowed)")
###########################################################################
# Boundary warnings
###########################################################################
# Breakpoint is at x = -b/a, assuming a > 0, b should be negative so that
# the breakpoint occurs at positive x values.
if (t == 1 or t == 2) and b >= 0.0:
warn(f"ParaCurve {t} expect b >= 0 but got {b}")
if t == 1 and qb(a + b) != 1:
warn(f"ParaCurve {t} does not reach maximum at (1,1)")
if t == 2 and qb(np.power(a + b, g) + c) != 1:
print((np.power(a + b, g) + c))
warn(f"ParaCurve {t} does not reach maximum at (1,1)")
###########################################################################
# Continuity warnings
###########################################################################
# Note that type 0, 1, 2 are continuous by definition
if (t == 3) and not qb(c * d) == qb(np.power(a * d + b, g)):
warn(f"ParaCurve {t} is not continuous")
if (t == 4) and not qb(c * d + f) == qb(np.power(a * d + b, g) + e):
warn(f"ParaCurve {t} is not continuous")
###########################################################################
# Smoothness warnings
###########################################################################
# Note that type 0 is smooth by definition
if (t == 1 or t == 2) and g <= 1 and -b / a > 0:
warn(f"ParaCurve {t} is not smooth")
if (t == 3 or t == 4) and not qb(c) == qb(a * g * np.power(a * d + b, g - 1)):
print(qb(c), qb(a * g * np.power(a * d + b, g - 1)))
warn(f"ParaCurve {t} is not smooth")
def subsitute_paracurve_params(params):
""" ParametricCurve specification does not impose constraints on parameters,
some combinations can produce ill-defined curves with a range of issues.
Implements the subsitutions recommended by the ICC (p.16):
https://www.color.org/whitepapers/ICC_White_Paper35-Use_of_the_parametricCurveType.pdf
"""
t = params.get("t")
g = params.get("g")
a = params.get("a")
b = params.get("b")
c = params.get("c")
d = params.get("d")
e = params.get("e")
f = params.get("f")
if g <= 0:
g = 1
if t != 0 and a <= 0:
a = 1
if (t == 3 or t == 4) and (a * d + b) < 0:
d = -b / a
if 0 < d < 1:
if t == 3 and (c * d) > np.power(a * d + b, g):
c = (1 / d) * np.power(a * d + b, g)
elif t == 4 and (c * d + f) > (np.power(a * d + b, g) + e):
if f > (np.power(a * d + b, g) + e):
f = np.power(a * d + b, g) + e
c = (1 / d) * (np.power(a * d + b, g) + e - f)
for key, val in zip(params.keys(), [t,g,a,b,c,d,e,f]):
if params[key] != val:
params[key] = val
return params
def apply_iccpara_python(x, invert=False, simulate_s15f16n=False, **kw):
""" Apply the ParametricCurve using Numpy.
Clipping is performed on both input / output side as recommended by the
ICC, even when not strictly needed.
"""
t = kw.get("t")
g = kw.get("g")
a = kw.get("a")
b = kw.get("b")
c = kw.get("c")
d = kw.get("d")
e = kw.get("e")
f = kw.get("f")
# Para 0
if t == 0 and not invert:
x = np.clip(x, 0, 1)
return np.clip(np.power(x, g), 0, 1)
elif t == 0 and invert:
x = np.clip(x, 0, 1)
return np.clip(np.power(x, 1 / g), 0, 1)
# Para 1
elif t == 1 and not invert:
x = np.clip(x, 0, 1)
return np.clip(
np.where(
x >= -b / a,
np.power(a * x + b, g),
np.zeros_like(x)
)
, 0, 1
)
# Flat segment inversion is ambigous, any value in the range [0, -b/a] is
# valid, here we match OCIO 1DLUT behaviour by returning -b/a. Note that
# DemoICCMax's IccApplyNamedCmm returns 0 here.
elif t == 1 and invert:
x = np.clip(x, 0, 1)
return np.clip((np.power(x, 1 / g) - b) / a, 0, 1)
# Para 2
elif t == 2 and not invert:
x = np.clip(x, 0, 1)
return np.clip(
np.where(
x >= -b / a,
np.power(a * x + b, g) + c,
c
)
, 0, 1
)
elif t == 2 and invert:
x = np.clip(x, 0, 1)
return np.clip(
np.where(
x > c,
(np.power(x - c, 1 / g) - b) / a,
c
)
, 0, 1
)
# Para 3
elif t == 3 and not invert:
x = np.clip(x, 0, 1)
return np.clip(
np.where(
x >= d,
np.power(a * x + b, g),
c * x
)
, 0, 1
)
elif t == 3 and invert:
x = np.clip(x, 0, 1)
p = np.power(a * d + b, g)
return np.clip(
np.where(
x >= p,
(np.power(x, 1 / g) - b) / a,
x / c
)
, 0, 1
)
# Para 4
elif t == 4 and not invert:
x = np.clip(x, 0, 1)
return np.clip(
np.where(
x >= d,
np.power(a * x + b, g) + e,
c * x + f
)
, 0, 1
)
elif t == 4 and invert:
x = np.clip(x, 0, 1)
p = np.power(a * d + b, g) + e
return np.clip(
np.where(
x >= p,
(np.power(x - e, 1 / g) - b) / a,
(x - f) / c
)
, 0, 1
)
raise NotImplementedError
#
# Testing
#
def check_python_roundtrip(params):
x = np.linspace(0, 1, 256)
y = apply_iccpara_python(apply_iccpara_python(x, **params), invert=True, **params)
np.testing.assert_allclose(x, y, rtol=1e-15, atol=1e-15)
def check_python_icc_match(params):
with tempfile.NamedTemporaryFile(suffix=".icc", mode="w") as icc_path:
make_para_icc_profile(icc_path.name, params)
# Apply with apply_icc
x = np.linspace(0, 1, 256)
atol = 1e-4
rtol = 1e-4
# Test the PCS to device direction
y_python = apply_iccpara_python(x, **params, invert=True)
y_icc = apply_icc_profile(x, icc_path.name, invert=True)[:, 1]
np.testing.assert_allclose(y_python, y_icc, rtol=rtol, atol=atol)
# Test the Device to PCS direction
# Note that we get XYZ back here because it is our profile PCS
y_python = apply_iccpara_python(x, **params, invert=False)
y_icc = apply_icc_profile(x, icc_path.name, invert=False)[:, 1]
np.testing.assert_allclose(y_python, y_icc, rtol=rtol, atol=atol)
def check_python_ocio_match(params):
x = np.linspace(0, 1, 256)
atol = 1e-4
rtol = 1e-4
y_python = apply_iccpara_python(x, **params, invert=True)
y_ocio = apply_icc_para_ocio(x, **params, invert=True)
np.testing.assert_allclose(y_python, y_ocio, rtol=rtol, atol=atol)
y_python = apply_iccpara_python(x, **params, invert=False)
y_ocio = apply_icc_para_ocio(x, **params, invert=False)
np.testing.assert_allclose(y_python, y_ocio, rtol=rtol, atol=atol)
def plot_curve(title, params):
plt.title(title)
x = np.linspace(0, 1, 2**14)
y = apply_iccpara_python(x, **params)
y_inv = apply_iccpara_python(x, invert=True, **params)
y_ocio = apply_icc_para_ocio(x, **params)
y_ocio_inv = apply_icc_para_ocio(x, invert=True, **params)
plt.grid(True)
plt.plot(x, y, label="Python")
plt.plot(x, y_inv, '--', label="Python Inv")
plt.plot(x, y_ocio, label="OCIO")
plt.plot(x, y_ocio_inv, '--', label="OCIO Inv")
plt.legend()
plt.show()
def ocio_unittest_helper(profile_name, params):
with np.printoptions(edgeitems=30, linewidth=100000):
make_para_icc_profile(profile_name, params)
x = np.array([-1.0, 0.0, 0.18, 0.5, 0.75, 1.0, 2.0])
y = apply_icc_profile(x, profile_name)[:, 1]
y_py = apply_iccpara_python(x, **params)
y_inv = apply_icc_profile(x, profile_name, invert=True)[:, 1]
y_py_inv = apply_iccpara_python(x, invert=True, **params)
y_py_bck = apply_iccpara_python(y_py_inv, **params)
# For type 1 and 2, OCIO match python behviour for flat segment inversion
print(profile_name, "FWD: ", y)
print(profile_name, "INV: ", y_inv)
print(profile_name, "FWD (py):", y_py)
print(profile_name, "INV (py):", y_py_inv)
print(profile_name, "BCK (py):", y_py_bck)
#
# Script entry point
#
if __name__ == "__main__":
# Check para type 0
type0_params = {"t": 0, "g": 2.4}
# plot_curve("Type 0", type0_params)
check_paracurve_params(type0_params)
check_python_roundtrip(type0_params)
check_python_ocio_match(type0_params)
check_python_icc_match(type0_params)
# Check para type 1
type1_params = {"t": 1, "g": 2.4, "a": 1.1, "b": -0.1}
# plot_curve("Type 1", type1_params)
check_paracurve_params(type1_params)
# Cannot roundtrip with flat segment
# check_python_roundtrip(type1_params)
check_python_ocio_match(type1_params)
# Inverting flat segment ambiguity, must be commented
# check_python_icc_match(type1_params)
ocio_unittest_helper("icc-test-pc1.icc", type1_params)
# Check para type 2
type2_params = {"t": 2, "g": 2.4, "a": 1.057049452, "b": -0.1, "c": 0.1}
# plot_curve("Type 2", type2_params)
check_paracurve_params(type2_params)
# Cannot roundtrip with flat segment
# check_python_roundtrip(type2_params)
# Inverting flat segment ambiguity, must be commented
# OCIO 1DLUT inversion returns a value that seem to be not matching either
# our Python nor ICC implementation
# check_python_ocio_match(type2_params)
# Inverting flat segment ambiguity, must be commented
# check_python_icc_match(type2_params)
ocio_unittest_helper("icc-test-pc2.icc", type2_params)
# Check para type 3
# rec709_params = {"t": 3, "g": 1/0.45, "a": 1/1.099, "b": 0.099/1.099, "c": 1/4.5, "d": 0.081}
srgb_params = {"t": 3, "g": 2.4, "a": 1/1.055, "b": 0.055/1.055, "c": 1/12.92, "d": 0.04045}
check_paracurve_params(srgb_params)
check_python_roundtrip(srgb_params)
check_python_ocio_match(srgb_params)
check_python_icc_match(srgb_params)
# plot_curve("Type 3 sRGB", srgb_params)
ocio_unittest_helper("icc-test-pc3.icc", srgb_params)
# Check para type 4
# Naive attempt to get continuous and smooth curve parameters (starting from sRGB parameters)
g = 2.4
b = 0.055/1.055
d = 0.04045
e = 0.1
a = np.power(1 - e, 1 / g) - b
c = a * g * np.power(a * d + b, g - 1)
f = np.power(a * d + b, g) + e - c * d
type4_params = {"t": 4, "g": g, "a": a, "b": b, "c": c, "d": d, "e": e, "f": f}
# plot_curve("Type 4", type4_params)
check_paracurve_params(type4_params)
check_python_roundtrip(type4_params)
check_python_ocio_match(type4_params)
check_python_icc_match(type4_params)
ocio_unittest_helper("icc-test-pc4.icc", type4_params)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment