From 98e10e6d5a16219039bf40624268454792b4d466 Mon Sep 17 00:00:00 2001 From: Zach Lewis Date: Tue, 6 Oct 2026 16:40:28 -0400 Subject: [PATCH 1/2] feat(color): derive primaries and gamma for spaces without an ID ColorConfig::get_chromaticities() and get_transfer_function_gamma() now analyze the transforms of a config color space with no known Color Interop ID. If its conversion to the config's scene or display interchange role is one per-channel curve shared by R, G and B followed by a matrix, they report the matrix's primaries and white, named as one of the published sets in the ColorInteropID table (reached with no chromatic adaptation, Bradford or CAT02), and a pure-power or linear transfer function, as an exponent the transforms state. Anything else, such as a 3D LUT, establishes nothing. A declared linear encoding still reports gamma 1.0. Each color space is analyzed once per ColorConfig. The new global attribute "colorconfig:analyze_transforms" (default 1) turns the analysis off, so the queries answer only from what the config declares and the names OpenImageIO knows. The analysis lives in its own file, color_derive.cpp. Assisted-by: Claude Code / Claude Opus 5.5 Signed-off-by: Zach Lewis --- src/doc/pythonbindings.md | 5 +- src/include/OpenImageIO/color.h | 12 +- src/include/OpenImageIO/imageio.h | 8 + src/include/imageio_pvt.h | 1 + src/libOpenImageIO/CMakeLists.txt | 5 +- src/libOpenImageIO/color_derive.cpp | 269 ++++++++++++++++++++++++++++ src/libOpenImageIO/color_derive.h | 29 +++ src/libOpenImageIO/color_ocio.cpp | 42 +++++ src/libOpenImageIO/color_test.cpp | 124 +++++++++++++ src/libOpenImageIO/imageio.cpp | 9 + 10 files changed, 496 insertions(+), 8 deletions(-) create mode 100644 src/libOpenImageIO/color_derive.cpp create mode 100644 src/libOpenImageIO/color_derive.h diff --git a/src/doc/pythonbindings.md b/src/doc/pythonbindings.md index 85c59e0d40..bc267e8eb4 100644 --- a/src/doc/pythonbindings.md +++ b/src/doc/pythonbindings.md @@ -4044,6 +4044,8 @@ is provided for minimal color support. Get the RGB primaries and white point of a color space with a known Color Interop ID, as `get_color_interop_id()` finds it: a sequence of eight floats (Rx, Ry, Gx, Gy, Bx, By, Wx, Wy, CIE 1931 xy), or None if they are unknown. +A color space of the config without one has them derived by analyzing its +transforms, unless the `colorconfig:analyze_transforms` attribute is 0. Example: @@ -4059,7 +4061,8 @@ This function was added in OpenImageIO 3.2. :::{py:method} get_transfer_function_gamma (colorspace) Get the exponent of a color space's transfer function if it decodes as a pure power, 1.0 if it is linear, or 0 if it is neither or is unknown. Piecewise -curves such as sRGB's report 0. +curves such as sRGB's report 0. Like `get_chromaticities()`, it may analyze +the color space's transforms. Example: diff --git a/src/include/OpenImageIO/color.h b/src/include/OpenImageIO/color.h index 91be441bcc..aebb4ccce1 100644 --- a/src/include/OpenImageIO/color.h +++ b/src/include/OpenImageIO/color.h @@ -453,8 +453,10 @@ class OIIO_API ColorConfig { /// Color Interop ID /// (https://github.com/AcademySoftwareFoundation/ColorInterop), as /// `get_color_interop_id()` finds it: eight floats (Rx, Ry, Gx, Gy, Bx, - /// By, Wx, Wy, CIE 1931 xy), or an empty span if they are unknown. The - /// span is valid until this ColorConfig is reset or destroyed. + /// By, Wx, Wy, CIE 1931 xy), or an empty span if they are unknown. A + /// color space of the config without one has them derived by analyzing + /// its transforms, unless the `colorconfig:analyze_transforms` attribute + /// is 0. The span is valid until this ColorConfig is reset or destroyed. /// /// @version 3.2 OIIO_NODISCARD cspan get_chromaticities(string_view colorspace) const; @@ -462,9 +464,9 @@ class OIIO_API ColorConfig { /// Get the exponent of a color space's transfer function if it decodes /// as a pure power, 1.0 if it is linear, or 0 if it is neither or is /// unknown. Piecewise curves such as sRGB's report 0. Like - /// `get_chromaticities()`, it answers for a known Color Interop ID, and - /// it reports 1.0 for any color space whose config declares a linear - /// encoding. + /// `get_chromaticities()`, it answers for a known Color Interop ID or by + /// analyzing transforms, and it reports 1.0 for any color space whose + /// config declares a linear encoding. /// /// @version 3.2 OIIO_NODISCARD float diff --git a/src/include/OpenImageIO/imageio.h b/src/include/OpenImageIO/imageio.h index 0c1e245536..142d1be6e0 100644 --- a/src/include/OpenImageIO/imageio.h +++ b/src/include/OpenImageIO/imageio.h @@ -4064,6 +4064,14 @@ OIIO_API std::string geterror(bool clear = true); /// enable globally in an environment where security is a higher priority /// than being tolerant of partially broken image files. /// +/// - `colorconfig:analyze_transforms` (int: 1) +/// +/// If nonzero (the default), `ColorConfig::get_chromaticities()` and +/// `get_transfer_function_gamma()` may analyze the transforms of a color +/// space with no known Color Interop ID to derive its primaries and +/// transfer function. If zero, they answer only from what the config +/// declares and the names OpenImageIO knows. +/// /// - `ustring:cleanup` (int: 0) /// /// If nonzero, upon exit, do a thorough (and possibly expensive) teardown diff --git a/src/include/imageio_pvt.h b/src/include/imageio_pvt.h index 28cbc25253..1a788df0a3 100644 --- a/src/include/imageio_pvt.h +++ b/src/include/imageio_pvt.h @@ -50,6 +50,7 @@ extern int limit_resolution; extern int imagebuf_print_uncaught_errors; extern int imagebuf_use_imagecache; extern int imageinput_strict; +extern int colorconfig_analyze_transforms; extern atomic_ll IB_local_mem_current; extern atomic_ll IB_local_mem_peak; extern std::atomic IB_total_open_time; diff --git a/src/libOpenImageIO/CMakeLists.txt b/src/libOpenImageIO/CMakeLists.txt index b7eaa011ce..c916fc9d20 100644 --- a/src/libOpenImageIO/CMakeLists.txt +++ b/src/libOpenImageIO/CMakeLists.txt @@ -58,7 +58,7 @@ set (libOpenImageIO_srcs imageinput.cpp imageio.cpp imageioplugin.cpp imageoutput.cpp iptc.cpp xmp.cpp - color_ocio.cpp + color_ocio.cpp color_derive.cpp maketexture.cpp bluenoise.cpp printinfo.cpp @@ -209,7 +209,8 @@ if (CMAKE_UNITY_BUILD) # ${libOpenImageIO_srcs} deepdata.cpp exif.cpp exif-canon.cpp formatspec.cpp imagebuf.cpp imageinput.cpp imageio.cpp imageioplugin.cpp imageoutput.cpp - iptc.cpp xmp.cpp color_ocio.cpp maketexture.cpp bluenoise.cpp + iptc.cpp xmp.cpp color_ocio.cpp color_derive.cpp maketexture.cpp + bluenoise.cpp PROPERTIES UNITY_GROUP oiiolib) foreach (plugin_dir ${all_format_plugin_dirs} ../libtexture) diff --git a/src/libOpenImageIO/color_derive.cpp b/src/libOpenImageIO/color_derive.cpp new file mode 100644 index 0000000000..e1be638f12 --- /dev/null +++ b/src/libOpenImageIO/color_derive.cpp @@ -0,0 +1,269 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Deriving a color space's properties from what its transforms do, for a +// color space that no known Color Interop ID describes. + +#include +#include +#include + +#include "color_derive.h" + +namespace OCIO = OCIO_NAMESPACE; + + +OIIO_NAMESPACE_BEGIN + +namespace { + +using Mat3 = std::array; // Row-major, applied to column vectors +using Vec3 = std::array; + +Mat3 +mat3_mul(const Mat3& a, const Mat3& b) +{ + Mat3 m {}; + for (int r = 0; r < 3; ++r) + for (int c = 0; c < 3; ++c) + for (int k = 0; k < 3; ++k) + m[3 * r + c] += a[3 * r + k] * b[3 * k + c]; + return m; +} + +Vec3 +mat3_apply(const Mat3& m, const Vec3& v) +{ + return { m[0] * v[0] + m[1] * v[1] + m[2] * v[2], + m[3] * v[0] + m[4] * v[1] + m[5] * v[2], + m[6] * v[0] + m[7] * v[1] + m[8] * v[2] }; +} + +Mat3 +mat3_inverse(const Mat3& m) +{ + const Mat3 adjugate { m[4] * m[8] - m[5] * m[7], m[2] * m[7] - m[1] * m[8], + m[1] * m[5] - m[2] * m[4], m[5] * m[6] - m[3] * m[8], + m[0] * m[8] - m[2] * m[6], m[2] * m[3] - m[0] * m[5], + m[3] * m[7] - m[4] * m[6], m[1] * m[6] - m[0] * m[7], + m[0] * m[4] - m[1] * m[3] }; + const double det = m[0] * adjugate[0] + m[1] * adjugate[3] + + m[2] * adjugate[6]; + Mat3 inverse; + for (int i = 0; i < 9; ++i) + inverse[i] = adjugate[i] / det; + return inverse; +} + +// CIE XYZ, with Y = 1, of the CIE xy chromaticity (x, y). +Vec3 +xy_to_XYZ(double x, double y) +{ + return { x / y, 1.0, (1.0 - x - y) / y }; +} + +// The RGB to CIE XYZ matrix of primaries and white given as CIE xy. +Mat3 +rgb_to_XYZ(const float* xy) +{ + Mat3 m; + for (int c = 0; c < 3; ++c) { + const Vec3 primary = xy_to_XYZ(xy[2 * c], xy[2 * c + 1]); + for (int r = 0; r < 3; ++r) + m[3 * r + c] = primary[r]; + } + const Vec3 scale = mat3_apply(mat3_inverse(m), xy_to_XYZ(xy[6], xy[7])); + for (int r = 0; r < 3; ++r) + for (int c = 0; c < 3; ++c) + m[3 * r + c] *= scale[c]; + return m; +} + +// Chromatic adaptation of CIE XYZ from white `from` to white `to`, scaling +// the `cone` responses (von Kries). +Mat3 +adaptation(const Mat3& cone, const Vec3& from, const Vec3& to) +{ + const Vec3 f = mat3_apply(cone, from), t = mat3_apply(cone, to); + const Mat3 scale { t[0] / f[0], 0, 0, 0, t[1] / f[1], 0, 0, 0, t[2] / f[2] }; + return mat3_mul(mat3_inverse(cone), mat3_mul(scale, cone)); +} + +const Mat3 bradford_cone { 0.8951, 0.2664, -0.1614, -0.7502, 1.7135, + 0.0367, 0.0389, -0.0685, 1.0296 }; +const Mat3 cat02_cone { 0.7328, 0.4296, -0.1624, -0.7036, 1.6975, + 0.0061, 0.0030, 0.0136, 0.9834 }; + +// Whether two matrices are the same up to a positive scale, which the gain +// of a curve ahead of a matrix introduces. +bool +same_up_to_scale(const Mat3& a, const Mat3& b) +{ + double norm_a = 0.0, norm_b = 0.0; + for (int i = 0; i < 9; ++i) { + norm_a += a[i] * a[i]; + norm_b += b[i] * b[i]; + } + norm_a = std::sqrt(norm_a); + norm_b = std::sqrt(norm_b); + if (!(norm_a > 0.0 && norm_b > 0.0)) + return false; + for (int i = 0; i < 9; ++i) + if (!(std::abs(a[i] / norm_a - b[i] / norm_b) < 1.0e-5)) + return false; + return true; +} + +double +vec3_length(const Vec3& v) +{ + return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +} // namespace + + + +void +pvt::derive_color_space_properties(const OCIO::Config& config, + const OCIO::ColorSpace& cs, + cspan gamuts, const float* ap0, + const float*& xy, float& gamma) +{ + if (cs.isData()) + return; + const bool display = cs.getReferenceSpaceType() + == OCIO::REFERENCE_SPACE_DISPLAY; + const char* role = display ? OCIO::ROLE_INTERCHANGE_DISPLAY + : OCIO::ROLE_INTERCHANGE_SCENE; + OCIO::ConstProcessorRcPtr decode; + OCIO::ConstCPUProcessorRcPtr cpu; + try { + if (!config.hasRole(role)) + return; + decode = config.getProcessor(cs.getName(), role); + // No approximate powers and no fused operations, so a power curve + // reads back as its exponent. + cpu = decode->getOptimizedCPUProcessor(OCIO::OPTIMIZATION_NONE); + } catch (const std::exception&) { + return; + } + auto decoded = [&](const Vec3& rgb) { + float pixel[3] = { float(rgb[0]), float(rgb[1]), float(rgb[2]) }; + cpu->applyRGB(pixel); + return Vec3 { pixel[0], pixel[1], pixel[2] }; + }; + // A shared per-channel curve f followed by a matrix M decodes white less + // white with one channel lowered to `t` as (f(1) - f(t)) times that + // channel's column of M: each such probe is a multiple of its column, by + // the same factor in all three channels, and the columns add up to white + // less black. Probing down from white keeps every decoded value positive, + // so a decoder that clamps negatives still reads as a matrix. Anything + // else, such as a 3D LUT or a rendering, establishes nothing. + const Vec3 black = decoded({ 0.0, 0.0, 0.0 }); + const Vec3 white = decoded({ 1.0, 1.0, 1.0 }); + auto lowered = [&](int channel, double t) { + Vec3 rgb { 1.0, 1.0, 1.0 }; + rgb[channel] = t; + const Vec3 value = decoded(rgb); + return Vec3 { white[0] - value[0], white[1] - value[1], + white[2] - value[2] }; + }; + Mat3 matrix; + Vec3 excess { white[0] - black[0], white[1] - black[1], + white[2] - black[2] }; + double scale = 0.0; + for (int c = 0; c < 3; ++c) { + const Vec3 column = lowered(c, 0.0); + for (int r = 0; r < 3; ++r) { + matrix[3 * r + c] = column[r]; + excess[r] -= column[r]; + } + scale = std::max(scale, vec3_length(column)); + } + if (!(scale > 0.0) || !std::isfinite(scale)) + return; + const double tolerance = 1.0e-5 * scale; + if (!(vec3_length(excess) <= tolerance)) + return; + // How far f(t) has risen from f(0) toward f(1), at two probes. + const double probes[2] = { 0.18, 0.5 }; + double rises[2] = {}; + for (int p = 0; p < 2; ++p) { + for (int c = 0; c < 3; ++c) { + Vec3 value = lowered(c, probes[p]); + double dot = 0.0; + double norm2 = 0.0; + for (int r = 0; r < 3; ++r) { + dot += value[r] * matrix[3 * r + c]; + norm2 += matrix[3 * r + c] * matrix[3 * r + c]; + } + const double factor = dot / norm2; + for (int r = 0; r < 3; ++r) + value[r] -= factor * matrix[3 * r + c]; + if (!(vec3_length(value) <= tolerance) + || (c > 0 && !(std::abs(1.0 - factor - rises[p]) <= 1.0e-5))) + return; + rises[p] = 1.0 - factor; + } + } + + // Name the matrix as a published gamut, reached through the interchange + // with no chromatic adaptation, Bradford or CAT02. + const Vec3 interchange_white = display ? xy_to_XYZ(0.3127, 0.3290) + : xy_to_XYZ(ap0[6], ap0[7]); + const Mat3 from_ap0 = mat3_inverse(rgb_to_XYZ(ap0)); + for (const float* gamut : gamuts) { + if (!gamut[1] || !gamut[3] || !gamut[5] || !gamut[7]) + continue; // CIE XYZ, which has no RGB primaries + const Mat3 to_XYZ = rgb_to_XYZ(gamut); + const Vec3 gamut_white = xy_to_XYZ(gamut[6], gamut[7]); + for (const Mat3* cone : + { (const Mat3*)nullptr, &bradford_cone, &cat02_cone }) { + Mat3 expected = cone ? mat3_mul(adaptation(*cone, gamut_white, + interchange_white), + to_XYZ) + : to_XYZ; + if (!display) + expected = mat3_mul(from_ap0, expected); + if (same_up_to_scale(matrix, expected)) { + xy = gamut; + break; + } + } + if (xy) + break; + } + + // A pure power passes through black and scales each probe by its power. + // It is reported only as an exponent the transforms state, so exactly. + if (!(vec3_length(black) <= tolerance && rises[0] > 0.0 && rises[1] > 0.0)) + return; + const double power = std::log(rises[0]) / std::log(probes[0]); + if (!(std::abs(std::log(rises[1]) / std::log(probes[1]) - power) + <= 1.0e-4 * power)) + return; + if (std::abs(power - 1.0) <= 1.0e-4) { + gamma = 1.0f; + return; + } + auto ops = decode->createGroupTransform(); + for (int i = 0, n = ops->getNumTransforms(); i < n; ++i) { + auto exponent = OCIO::DynamicPtrCast( + ops->getTransform(i)); + if (!exponent) + continue; + double value[4]; + exponent->getValue(value); + if (value[0] != value[1] || value[0] != value[2] || !(value[0] > 0.0)) + continue; + for (double stated : { value[0], 1.0 / value[0] }) + if (std::abs(stated - power) <= 1.0e-4 * power) { + gamma = float(stated); + return; + } + } +} + +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_derive.h b/src/libOpenImageIO/color_derive.h new file mode 100644 index 0000000000..43417fa118 --- /dev/null +++ b/src/libOpenImageIO/color_derive.h @@ -0,0 +1,29 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include + +#include + + +OIIO_NAMESPACE_BEGIN +namespace pvt { + +/// Derive what `cs`'s transforms establish about it: its RGB primaries and +/// white (CIE xy, R G B W), as `xy` pointing to one of `gamuts`, and the +/// exponent of a pure-power transfer function (1 for a linear one). It reads +/// the conversion to `config`'s scene or display interchange role, which +/// must be one shared per-channel curve followed by a matrix; anything else +/// establishes nothing. `ap0` is ACES2065-1's primaries and white, which the +/// scene interchange is. Properties that are not established are left +/// unchanged. +void derive_color_space_properties(const OCIO_NAMESPACE::Config& config, + const OCIO_NAMESPACE::ColorSpace& cs, + cspan gamuts, const float* ap0, + const float*& xy, float& gamma); + +} // namespace pvt +OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_ocio.cpp b/src/libOpenImageIO/color_ocio.cpp index 7ce81d487d..2a871d0a10 100644 --- a/src/libOpenImageIO/color_ocio.cpp +++ b/src/libOpenImageIO/color_ocio.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include +#include "color_derive.h" #include "imageio_pvt.h" #include "interop_config.h" @@ -240,6 +242,7 @@ class ColorConfig::Impl { ColorProcessorMap colorprocmap; // cache of ColorProcessors atomic_int colorprocs_requested; atomic_int colorprocs_created; + mutable std::map> m_derived; std::string m_configname; ColorConfig* m_self = nullptr; bool m_config_is_built_in = false; @@ -347,6 +350,10 @@ class ColorConfig::Impl { // report. Properties properties(string_view colorspace) const; + // What the transforms of a color space of this config establish about + // it, worked out once per color space. + Properties derived(const OCIO::ColorSpace& cs) const; + int getNumColorSpaces() const { return (int)colorspaces.size(); } const char* getColorSpaceNameByIndex(int index) const @@ -2864,6 +2871,37 @@ ColorConfig::get_cicp(string_view colorspace) const +ColorConfig::Impl::Properties +ColorConfig::Impl::derived(const OCIO::ColorSpace& cs) const +{ + const string_view name(cs.getName()); + { + spin_rw_read_lock lock(m_mutex); + auto found = m_derived.find(name); + if (found != m_derived.end()) + return found->second; + } + // Name the space's primaries as one of the table's. + static const std::vector gamuts = [] { + std::vector unique; + for (const ColorInteropID& interop : color_interop_ids) + if (interop.chromaticities + && std::find(unique.begin(), unique.end(), + interop.chromaticities) + == unique.end()) + unique.push_back(interop.chromaticities); + return unique; + }(); + Properties result; + OIIO::pvt::derive_color_space_properties(*config_, cs, gamuts, xy_ap0, + result.xy, result.gamma); + spin_rw_write_lock lock(m_mutex); + m_derived.emplace(name, result); + return result; +} + + + ColorConfig::Impl::Properties ColorConfig::Impl::properties(string_view colorspace) const { @@ -2892,6 +2930,10 @@ ColorConfig::Impl::properties(string_view colorspace) const break; } } + if (cs && interop_id.empty() && OIIO::pvt::colorconfig_analyze_transforms) { + // No known ID, so derive what the space's transforms establish. + result = derived(*cs); + } if (linear) result.gamma = 1.0f; return result; diff --git a/src/libOpenImageIO/color_test.cpp b/src/libOpenImageIO/color_test.cpp index 06d448dd91..42f16331ce 100644 --- a/src/libOpenImageIO/color_test.cpp +++ b/src/libOpenImageIO/color_test.cpp @@ -482,6 +482,129 @@ test_color_space_properties() +static void +test_derived_color_space_properties() +{ + // Color spaces with no Color Interop ID, written with OCIO's built-in + // transforms, report what their transforms establish. + const std::string filename = Filesystem::temp_directory_path() + "/" + + Filesystem::unique_path() + ".ocio"; + // A 3D LUT that is the identity except at black, which lowering one + // channel from white never reaches. + const std::string cube = Filesystem::temp_directory_path() + "/" + + Filesystem::unique_path() + ".cube"; + OIIO_CHECK_ASSERT(Filesystem::write_text_file( + cube, "LUT_3D_SIZE 2\n0.1 0.1 0.1\n1 0 0\n0 1 0\n1 1 0\n" + "0 0 1\n1 0 1\n0 1 1\n1 1 1\n")); + OIIO_CHECK_ASSERT(Filesystem::write_text_file( + filename, + std::string( + "ocio_profile_version: 2.3\n" + "roles: {aces_interchange: ACES2065-1, " + "cie_xyz_d65_interchange: CIE-XYZ-D65, default: ACES2065-1}\n" + "file_rules:\n - ! {name: Default, colorspace: default}\n" + "colorspaces:\n" + " - ! {name: ACES2065-1}\n" + " - !\n" + " name: Power AP1\n" + " to_scene_reference: ! {children: [\n" + " ! {value: 2.2},\n" + " ! {style: ACEScg_to_ACES2065-1}]}\n" + " - !\n" + " name: Log AP1\n" + " to_scene_reference: ! " + "{style: ACEScct_to_ACES2065-1}\n" + // P3-D65 reaching ACES2065-1 through OCIO's Bradford adaptation. + " - !\n" + " name: Plain P3\n" + " to_scene_reference: ! {children: [\n" + " ! {matrix: [0.4865709486482162, " + "0.2656676931690931, 0.1982172852343625, 0, 0.2289745640697488, " + "0.6917385218365064, 0.0792869140937450, 0, 0, 0.0451133818589026, " + "1.0439443689009760, 0, 0, 0, 0, 1]},\n" + " ! {style: UTILITY - ACES-AP0_to_CIE-XYZ-D65_BFD, " + "direction: inverse}]}\n" + // A curve after the matrix is not a curve and then a matrix. + " - !\n" + " name: Curve Last\n" + " to_scene_reference: ! {children: [\n" + " ! {style: ACEScg_to_ACES2065-1},\n" + " ! {value: 2.2}]}\n" + // A declared linear encoding is honored over the measured power. + " - !\n" + " name: Said Linear\n" + " encoding: scene-linear\n" + " to_scene_reference: ! {children: [\n" + " ! {value: 2.2},\n" + " ! {style: ACEScg_to_ACES2065-1}]}\n" + " - !\n" + " name: Cube\n" + " to_scene_reference: ! {children: [\n" + " ! {src: \"") + + cube + + "\", interpolation: linear},\n" + " ! {style: ACEScg_to_ACES2065-1}]}\n" + "display_colorspaces:\n" + " - ! {name: CIE-XYZ-D65}\n" + " - !\n" + " name: Monitor 1886\n" + " from_display_reference: ! " + "{style: DISPLAY - CIE-XYZ-D65_to_REC.1886-REC.709}\n" + " - !\n" + " name: Monitor P3\n" + " from_display_reference: ! " + "{style: DISPLAY - CIE-XYZ-D65_to_DisplayP3}\n")); + const float ap0[] = { .7347f, .2653f, 0.0f, 1.0f, + .0001f, -.077f, .32168f, .33767f }; + const float ap1[] = { .713f, .293f, .165f, .83f, + .128f, .044f, .32168f, .33767f }; + const float rec709[] = { .64f, .33f, .30f, .60f, .15f, .06f, .3127f, .329f }; + const float p3d65[] = { .68f, .32f, .265f, .69f, .15f, .06f, .3127f, .329f }; + { + ColorConfig config(filename); + OIIO_CHECK_FALSE(config.has_error()); + struct Expected { + const char* name; + const float* xy; + float gamma; + }; + for (const Expected& e : { + Expected { "ACES2065-1", ap0, 1.0f }, + Expected { "Power AP1", ap1, 2.2f }, + Expected { "Log AP1", ap1, 0.0f }, + Expected { "Plain P3", p3d65, 1.0f }, + Expected { "Curve Last", nullptr, 0.0f }, + Expected { "Cube", nullptr, 0.0f }, + Expected { "Said Linear", ap1, 1.0f }, + Expected { "Monitor 1886", rec709, 2.4f }, + Expected { "Monitor P3", p3d65, 0.0f }, + }) { + OIIO_CHECK_EQUAL(config.get_color_interop_id(e.name), ""); + // Compared as text, so a failure names the color space. + auto describe = [&](cspan xy, float gamma) { + return Strutil::fmt::format("{}: [{}] {}", e.name, + Strutil::join(xy, " "), gamma); + }; + OIIO_CHECK_EQUAL( + describe(config.get_chromaticities(e.name), + config.get_transfer_function_gamma(e.name)), + describe(e.xy ? cspan(e.xy, 8) : cspan(), + e.gamma)); + } + + // With analysis off, only declarations and known names answer, even + // for a space already analyzed. + OIIO::attribute("colorconfig:analyze_transforms", 0); + OIIO_CHECK_ASSERT(config.get_chromaticities("Power AP1").empty()); + OIIO_CHECK_EQUAL(config.get_transfer_function_gamma("Power AP1"), 0.0f); + OIIO::attribute("colorconfig:analyze_transforms", 1); + } + Filesystem::remove(filename); + Filesystem::remove(cube); +} + + + int main(int argc, char* argv[]) { @@ -504,6 +627,7 @@ main(int argc, char* argv[]) test_interop_identities_conversion(); test_gamma_pair_conversion(); test_color_space_properties(); + test_derived_color_space_properties(); return unit_test_failures != 0; } diff --git a/src/libOpenImageIO/imageio.cpp b/src/libOpenImageIO/imageio.cpp index 8fc67d3057..dfb4e1df19 100644 --- a/src/libOpenImageIO/imageio.cpp +++ b/src/libOpenImageIO/imageio.cpp @@ -67,6 +67,7 @@ int limit_imagesize_MB(std::min(32 * 1024, int(Sysutil::physical_memory() >> 20))); int limit_resolution(1 << 20); // max pixels along any single dimension int imageinput_strict(0); +int colorconfig_analyze_transforms(1); ustring font_searchpath(Sysutil::getenv("OPENIMAGEIO_FONTS")); ustring plugin_searchpath(OIIO_DEFAULT_PLUGIN_SEARCHPATH); std::string format_list; // comma-separated list of all formats @@ -454,6 +455,10 @@ attribute(string_view name, TypeDesc type, const void* val) imageinput_strict = *(const int*)val; return true; } + if (name == "colorconfig:analyze_transforms" && type == TypeInt) { + colorconfig_analyze_transforms = *(const int*)val; + return true; + } if (name == "use_tbb" && type == TypeInt) { oiio_use_tbb = *(const int*)val; return true; @@ -664,6 +669,10 @@ getattribute(string_view name, TypeDesc type, void* val) *(int*)val = imageinput_strict; return true; } + if (name == "colorconfig:analyze_transforms" && type == TypeInt) { + *(int*)val = colorconfig_analyze_transforms; + return true; + } if (name == "use_tbb" && type == TypeInt) { *(int*)val = oiio_use_tbb; return true; From e25bd19720473e23692b44387442aa9bdadfc35a Mon Sep 17 00:00:00 2001 From: Zach Lewis Date: Tue, 13 Oct 2026 21:20:00 -0400 Subject: [PATCH 2/2] feat(color): read derived primaries from the conversion's matrices Rework how a color space's primaries are derived: - They come from the matrices that lead the conversion from the interchange space, read from OpenColorIO's optimized processor: clamps are skipped and the first curve ends them. Sampling the decoder instead failed for ACEScc, whose decoder clamps negatives after its matrix. - The matrices are named as a published gamut when they match one under Bradford, CAT02 or no chromatic adaptation, within 1e-5. Otherwise the primaries are read under the usual white points (D65, D60, DCI, D50) and adaptations, keeping the reading with the most exact coordinates, so gamuts OpenImageIO doesn't list are reported too. - The ColorInteropID table gains OpenColorIO's camera IDs (ARRI, Blackmagic, DaVinci, Canon, DJI, Panasonic, RED, Sony, Apple) with their published primaries. P3-D60, P3-DCI and Rec.601 are recognized as well. - A color space whose transforms aren't simple, but end in another color space, answers as that one. - An ID gives the primaries even when the encoding contradicts its transfer function. An ID the table lacks takes them from OpenImageIO's built-in definition of it, and one it doesn't define either (a custom namespaced ID, say) has them derived. - The transfer function is the space's own. A declared encoding is linear only for the space's own reference type; without one, a space the table doesn't describe is linear if OpenColorIO's isColorSpaceLinear() says so. A pure-power gamma is no longer derived; that waits for matching transfer functions against known curves. - Imath types replace the hand-written matrices. Assisted-by: Claude Code / Claude Opus 5.5 Signed-off-by: Zach Lewis --- src/doc/pythonbindings.md | 11 +- src/include/OpenImageIO/color.h | 15 +- src/include/OpenImageIO/imageio.h | 2 +- src/libOpenImageIO/color_derive.cpp | 634 +++++++++++++++++++--------- src/libOpenImageIO/color_derive.h | 27 +- src/libOpenImageIO/color_ocio.cpp | 218 ++++++++-- src/libOpenImageIO/color_test.cpp | 229 +++++++--- 7 files changed, 803 insertions(+), 333 deletions(-) diff --git a/src/doc/pythonbindings.md b/src/doc/pythonbindings.md index bc267e8eb4..85be59e7d1 100644 --- a/src/doc/pythonbindings.md +++ b/src/doc/pythonbindings.md @@ -4044,8 +4044,9 @@ is provided for minimal color support. Get the RGB primaries and white point of a color space with a known Color Interop ID, as `get_color_interop_id()` finds it: a sequence of eight floats (Rx, Ry, Gx, Gy, Bx, By, Wx, Wy, CIE 1931 xy), or None if they are unknown. -A color space of the config without one has them derived by analyzing its -transforms, unless the `colorconfig:analyze_transforms` attribute is 0. +A color space of the config without such an ID, or whose ID OpenImageIO has no +primaries for, has them derived by analyzing its transforms, unless the +`colorconfig:analyze_transforms` attribute is 0. Example: @@ -4061,8 +4062,10 @@ This function was added in OpenImageIO 3.2. :::{py:method} get_transfer_function_gamma (colorspace) Get the exponent of a color space's transfer function if it decodes as a pure power, 1.0 if it is linear, or 0 if it is neither or is unknown. Piecewise -curves such as sRGB's report 0. Like `get_chromaticities()`, it may analyze -the color space's transforms. +curves such as sRGB's report 0. A color space whose config declares an +encoding is linear only if that encoding is linear for its reference type. +Otherwise it answers for a known Color Interop ID, or, like +`get_chromaticities()`, by analyzing transforms. Example: diff --git a/src/include/OpenImageIO/color.h b/src/include/OpenImageIO/color.h index aebb4ccce1..6cedc2e918 100644 --- a/src/include/OpenImageIO/color.h +++ b/src/include/OpenImageIO/color.h @@ -454,19 +454,20 @@ class OIIO_API ColorConfig { /// (https://github.com/AcademySoftwareFoundation/ColorInterop), as /// `get_color_interop_id()` finds it: eight floats (Rx, Ry, Gx, Gy, Bx, /// By, Wx, Wy, CIE 1931 xy), or an empty span if they are unknown. A - /// color space of the config without one has them derived by analyzing - /// its transforms, unless the `colorconfig:analyze_transforms` attribute - /// is 0. The span is valid until this ColorConfig is reset or destroyed. + /// color space of the config without such an ID, or whose ID OpenImageIO + /// has no primaries for, has them derived by analyzing its transforms, + /// unless the `colorconfig:analyze_transforms` attribute is 0. The span + /// is valid until this ColorConfig is reset or destroyed. /// /// @version 3.2 OIIO_NODISCARD cspan get_chromaticities(string_view colorspace) const; /// Get the exponent of a color space's transfer function if it decodes /// as a pure power, 1.0 if it is linear, or 0 if it is neither or is - /// unknown. Piecewise curves such as sRGB's report 0. Like - /// `get_chromaticities()`, it answers for a known Color Interop ID or by - /// analyzing transforms, and it reports 1.0 for any color space whose - /// config declares a linear encoding. + /// unknown. Piecewise curves such as sRGB's report 0. A color space whose + /// config declares an encoding is linear only if that encoding is linear + /// for its reference type. Otherwise it answers for a known Color Interop + /// ID, or, like `get_chromaticities()`, by analyzing transforms. /// /// @version 3.2 OIIO_NODISCARD float diff --git a/src/include/OpenImageIO/imageio.h b/src/include/OpenImageIO/imageio.h index 142d1be6e0..1b8df31709 100644 --- a/src/include/OpenImageIO/imageio.h +++ b/src/include/OpenImageIO/imageio.h @@ -4069,7 +4069,7 @@ OIIO_API std::string geterror(bool clear = true); /// If nonzero (the default), `ColorConfig::get_chromaticities()` and /// `get_transfer_function_gamma()` may analyze the transforms of a color /// space with no known Color Interop ID to derive its primaries and -/// transfer function. If zero, they answer only from what the config +/// whether it is linear. If zero, they answer only from what the config /// declares and the names OpenImageIO knows. /// /// - `ustring:cleanup` (int: 0) diff --git a/src/libOpenImageIO/color_derive.cpp b/src/libOpenImageIO/color_derive.cpp index e1be638f12..0145e63f82 100644 --- a/src/libOpenImageIO/color_derive.cpp +++ b/src/libOpenImageIO/color_derive.cpp @@ -2,12 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 // https://github.com/AcademySoftwareFoundation/OpenImageIO -// Deriving a color space's properties from what its transforms do, for a -// color space that no known Color Interop ID describes. +// Deriving the primaries of a color space from what its transforms do. #include -#include #include +#include +#include +#include +#include + +#include #include "color_derive.h" @@ -18,252 +22,470 @@ OIIO_NAMESPACE_BEGIN namespace { -using Mat3 = std::array; // Row-major, applied to column vectors -using Vec3 = std::array; +using Imath::M33d; +using Imath::V3d; -Mat3 -mat3_mul(const Mat3& a, const Mat3& b) +// Matrices here apply to column vectors. +V3d +apply(const M33d& m, const V3d& v) { - Mat3 m {}; - for (int r = 0; r < 3; ++r) - for (int c = 0; c < 3; ++c) - for (int k = 0; k < 3; ++k) - m[3 * r + c] += a[3 * r + k] * b[3 * k + c]; - return m; + return V3d(m[0][0] * v.x + m[0][1] * v.y + m[0][2] * v.z, + m[1][0] * v.x + m[1][1] * v.y + m[1][2] * v.z, + m[2][0] * v.x + m[2][1] * v.y + m[2][2] * v.z); } -Vec3 -mat3_apply(const Mat3& m, const Vec3& v) + + +V3d +xy_to_XYZ(double x, double y) { - return { m[0] * v[0] + m[1] * v[1] + m[2] * v[2], - m[3] * v[0] + m[4] * v[1] + m[5] * v[2], - m[6] * v[0] + m[7] * v[1] + m[8] * v[2] }; + return V3d(x / y, 1.0, (1.0 - x - y) / y); } -Mat3 -mat3_inverse(const Mat3& m) + + +// The RGB to CIE XYZ matrix of primaries and white given as CIE xy. +M33d +rgb_to_XYZ(const double xy[8]) { - const Mat3 adjugate { m[4] * m[8] - m[5] * m[7], m[2] * m[7] - m[1] * m[8], - m[1] * m[5] - m[2] * m[4], m[5] * m[6] - m[3] * m[8], - m[0] * m[8] - m[2] * m[6], m[2] * m[3] - m[0] * m[5], - m[3] * m[7] - m[4] * m[6], m[1] * m[6] - m[0] * m[7], - m[0] * m[4] - m[1] * m[3] }; - const double det = m[0] * adjugate[0] + m[1] * adjugate[3] - + m[2] * adjugate[6]; - Mat3 inverse; - for (int i = 0; i < 9; ++i) - inverse[i] = adjugate[i] / det; - return inverse; + const V3d r = xy_to_XYZ(xy[0], xy[1]); + const V3d g = xy_to_XYZ(xy[2], xy[3]); + const V3d b = xy_to_XYZ(xy[4], xy[5]); + const M33d primaries(r.x, g.x, b.x, r.y, g.y, b.y, r.z, g.z, b.z); + const V3d s = apply(primaries.inverse(), xy_to_XYZ(xy[6], xy[7])); + return primaries * M33d(s.x, 0.0, 0.0, 0.0, s.y, 0.0, 0.0, 0.0, s.z); } -// CIE XYZ, with Y = 1, of the CIE xy chromaticity (x, y). -Vec3 -xy_to_XYZ(double x, double y) + + +// CIE XYZ of the white points a reading is tried under. +const V3d white_D65(0.95045592705167, 1.0, 1.08905775075988); +const V3d white_D60(0.95264607456985, 1.0, 1.00882518435159); // ACES +const V3d white_DCI(0.89458689458689, 1.0, 0.95441595441595); +const V3d white_D50(0.96429567642956771, 1.0, 0.82510460251046047); + +enum class Adaptation { Bradford, CAT02, None }; + +// Chromatic adaptation of CIE XYZ from white `from` to white `to`. +M33d +adapt(const V3d& from, const V3d& to, Adaptation method) { - return { x / y, 1.0, (1.0 - x - y) / y }; + static const M33d bradford(0.8951, 0.2664, -0.1614, -0.7502, 1.7135, 0.0367, + 0.0389, -0.0685, 1.0296); + static const M33d cat02(0.7328, 0.4296, -0.1624, -0.7036, 1.6975, 0.0061, + 0.0030, 0.0136, 0.9834); + if (method == Adaptation::None + || (std::abs(from.x - to.x) <= 1e-12 && std::abs(from.y - to.y) <= 1e-12 + && std::abs(from.z - to.z) <= 1e-12)) + return M33d(); + const M33d& cone = method == Adaptation::Bradford ? bradford : cat02; + const V3d response = apply(cone, from); + const V3d target = apply(cone, to); + return cone.inverse() + * M33d(target.x / response.x, 0.0, 0.0, 0.0, target.y / response.y, + 0.0, 0.0, 0.0, target.z / response.z) + * cone; } -// The RGB to CIE XYZ matrix of primaries and white given as CIE xy. -Mat3 -rgb_to_XYZ(const float* xy) + + +// The interchange space a color space's matrices are read against: +// ACES2065-1 for a scene-referred space, CIE XYZ with a D65 white for a +// display-referred one. +struct Reference { + M33d to_XYZ; + M33d from_XYZ; + V3d white; // CIE XYZ +}; + +const Reference& +reference(bool display) { - Mat3 m; - for (int c = 0; c < 3; ++c) { - const Vec3 primary = xy_to_XYZ(xy[2 * c], xy[2 * c + 1]); - for (int r = 0; r < 3; ++r) - m[3 * r + c] = primary[r]; - } - const Vec3 scale = mat3_apply(mat3_inverse(m), xy_to_XYZ(xy[6], xy[7])); - for (int r = 0; r < 3; ++r) - for (int c = 0; c < 3; ++c) - m[3 * r + c] *= scale[c]; - return m; + static const Reference aces = [] { + const double ap0[8] = { .7347, .2653, 0.0, 1.0, + .0001, -.077, .32168, .33767 }; + Reference r; + r.to_XYZ = rgb_to_XYZ(ap0); + r.from_XYZ = r.to_XYZ.inverse(); + r.white = apply(r.to_XYZ, V3d(1.0, 1.0, 1.0)); + return r; + }(); + static const Reference xyz = [] { + Reference r; + r.white = white_D65; + return r; + }(); + return display ? xyz : aces; } -// Chromatic adaptation of CIE XYZ from white `from` to white `to`, scaling -// the `cone` responses (von Kries). -Mat3 -adaptation(const Mat3& cone, const Vec3& from, const Vec3& to) + + +// The coordinate rounded to six decimals, or to fewer when it is within 2e-7 +// of that, so that equal primaries read through different matrices compare +// equal. +double +round_coordinate(double value) { - const Vec3 f = mat3_apply(cone, from), t = mat3_apply(cone, to); - const Mat3 scale { t[0] / f[0], 0, 0, 0, t[1] / f[1], 0, 0, 0, t[2] / f[2] }; - return mat3_mul(mat3_inverse(cone), mat3_mul(scale, cone)); + const double rounded = std::round(value * 1e6) / 1e6; + for (double scale : { 1e5, 1e4, 1e3, 1e2 }) { + const double coarser = std::round(rounded * scale) / scale; + if (std::abs(rounded - coarser) <= 2e-7) + return coarser + 0.0; // No negative zero + } + return rounded + 0.0; } -const Mat3 bradford_cone { 0.8951, 0.2664, -0.1614, -0.7502, 1.7135, - 0.0367, 0.0389, -0.0685, 1.0296 }; -const Mat3 cat02_cone { 0.7328, 0.4296, -0.1624, -0.7036, 1.6975, - 0.0061, 0.0030, 0.0136, 0.9834 }; -// Whether two matrices are the same up to a positive scale, which the gain -// of a curve ahead of a matrix introduces. -bool -same_up_to_scale(const Mat3& a, const Mat3& b) + +// The chromaticities (R G B W) that a linear RGB to CIE XYZ matrix gives. +std::array +chromaticities(const M33d& rgb_to_xyz) { - double norm_a = 0.0, norm_b = 0.0; - for (int i = 0; i < 9; ++i) { - norm_a += a[i] * a[i]; - norm_b += b[i] * b[i]; + std::array xy; + const V3d basis[4] = { V3d(1.0, 0.0, 0.0), V3d(0.0, 1.0, 0.0), + V3d(0.0, 0.0, 1.0), V3d(1.0, 1.0, 1.0) }; + for (int i = 0; i < 4; ++i) { + const V3d XYZ = apply(rgb_to_xyz, basis[i]); + const double sum = XYZ.x + XYZ.y + XYZ.z; + xy[2 * i] = round_coordinate(XYZ.x / sum); + xy[2 * i + 1] = round_coordinate(XYZ.y / sum); } - norm_a = std::sqrt(norm_a); - norm_b = std::sqrt(norm_b); - if (!(norm_a > 0.0 && norm_b > 0.0)) - return false; - for (int i = 0; i < 9; ++i) - if (!(std::abs(a[i] / norm_a - b[i] / norm_b) < 1.0e-5)) - return false; - return true; + // A white with x equal to y is CIE XYZ's own, illuminant E. + if (xy[6] == xy[7]) + xy[6] = xy[7] = 1.0 / 3.0; + return xy; } -double -vec3_length(const Vec3& v) + + +// Decimal places of a coordinate, up to nine. +int +decimal_places(double value) { - return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + long long scaled = std::llround(std::abs(value) * 1e9); + if (scaled == 0) + return 0; + int places = 9; + for (; places > 0 && scaled % 10 == 0; --places) + scaled /= 10; + return places; } -} // namespace + + +// How few decimals the primaries' coordinates have: 1 when none has any, 0 +// when each has `cap` or more. +double +exactness(const std::array& xy, int cap) +{ + int total = 0; + for (int i = 0; i < 6; ++i) + total += std::min(decimal_places(xy[i]), cap); + return 1.0 - double(total) / double(cap * 6); +} -void -pvt::derive_color_space_properties(const OCIO::Config& config, - const OCIO::ColorSpace& cs, - cspan gamuts, const float* ap0, - const float*& xy, float& gamma) +// The gamut among `gamuts` that the matrix reproduces within 1e-5, reached +// under Bradford, CAT02 or no chromatic adaptation. +const float* +known_gamut(const M33d& rgb_to_reference, const Reference& ref, + cspan gamuts) { - if (cs.isData()) - return; - const bool display = cs.getReferenceSpaceType() - == OCIO::REFERENCE_SPACE_DISPLAY; - const char* role = display ? OCIO::ROLE_INTERCHANGE_DISPLAY - : OCIO::ROLE_INTERCHANGE_SCENE; - OCIO::ConstProcessorRcPtr decode; - OCIO::ConstCPUProcessorRcPtr cpu; - try { - if (!config.hasRole(role)) - return; - decode = config.getProcessor(cs.getName(), role); - // No approximate powers and no fused operations, so a power curve - // reads back as its exponent. - cpu = decode->getOptimizedCPUProcessor(OCIO::OPTIMIZATION_NONE); - } catch (const std::exception&) { - return; - } - auto decoded = [&](const Vec3& rgb) { - float pixel[3] = { float(rgb[0]), float(rgb[1]), float(rgb[2]) }; - cpu->applyRGB(pixel); - return Vec3 { pixel[0], pixel[1], pixel[2] }; - }; - // A shared per-channel curve f followed by a matrix M decodes white less - // white with one channel lowered to `t` as (f(1) - f(t)) times that - // channel's column of M: each such probe is a multiple of its column, by - // the same factor in all three channels, and the columns add up to white - // less black. Probing down from white keeps every decoded value positive, - // so a decoder that clamps negatives still reads as a matrix. Anything - // else, such as a 3D LUT or a rendering, establishes nothing. - const Vec3 black = decoded({ 0.0, 0.0, 0.0 }); - const Vec3 white = decoded({ 1.0, 1.0, 1.0 }); - auto lowered = [&](int channel, double t) { - Vec3 rgb { 1.0, 1.0, 1.0 }; - rgb[channel] = t; - const Vec3 value = decoded(rgb); - return Vec3 { white[0] - value[0], white[1] - value[1], - white[2] - value[2] }; - }; - Mat3 matrix; - Vec3 excess { white[0] - black[0], white[1] - black[1], - white[2] - black[2] }; - double scale = 0.0; - for (int c = 0; c < 3; ++c) { - const Vec3 column = lowered(c, 0.0); - for (int r = 0; r < 3; ++r) { - matrix[3 * r + c] = column[r]; - excess[r] -= column[r]; - } - scale = std::max(scale, vec3_length(column)); - } - if (!(scale > 0.0) || !std::isfinite(scale)) - return; - const double tolerance = 1.0e-5 * scale; - if (!(vec3_length(excess) <= tolerance)) - return; - // How far f(t) has risen from f(0) toward f(1), at two probes. - const double probes[2] = { 0.18, 0.5 }; - double rises[2] = {}; - for (int p = 0; p < 2; ++p) { - for (int c = 0; c < 3; ++c) { - Vec3 value = lowered(c, probes[p]); - double dot = 0.0; - double norm2 = 0.0; - for (int r = 0; r < 3; ++r) { - dot += value[r] * matrix[3 * r + c]; - norm2 += matrix[3 * r + c] * matrix[3 * r + c]; - } - const double factor = dot / norm2; + const float* found = nullptr; + double closest = 1e-5; + for (const float* gamut : gamuts) { + // CIE XYZ taken as RGB has primaries on y = 0, and no such matrix. + if (gamut[1] == 0.0f || gamut[3] == 0.0f || gamut[5] == 0.0f) + continue; + double xy[8]; + std::copy(gamut, gamut + 8, xy); + const M33d to_XYZ = rgb_to_XYZ(xy); + const V3d white = apply(to_XYZ, V3d(1.0, 1.0, 1.0)); + for (Adaptation method : + { Adaptation::Bradford, Adaptation::CAT02, Adaptation::None }) { + const M33d candidate = ref.from_XYZ + * adapt(white, ref.white, method) * to_XYZ; + double residual = 0.0; for (int r = 0; r < 3; ++r) - value[r] -= factor * matrix[3 * r + c]; - if (!(vec3_length(value) <= tolerance) - || (c > 0 && !(std::abs(1.0 - factor - rises[p]) <= 1.0e-5))) - return; - rises[p] = 1.0 - factor; + for (int c = 0; c < 3; ++c) + residual = std::max(residual, + std::abs(candidate[r][c] + - rgb_to_reference[r][c])); + if (residual < closest) { + closest = residual; + found = gamut; + } } } + return found; +} - // Name the matrix as a published gamut, reached through the interchange - // with no chromatic adaptation, Bradford or CAT02. - const Vec3 interchange_white = display ? xy_to_XYZ(0.3127, 0.3290) - : xy_to_XYZ(ap0[6], ap0[7]); - const Mat3 from_ap0 = mat3_inverse(rgb_to_XYZ(ap0)); - for (const float* gamut : gamuts) { - if (!gamut[1] || !gamut[3] || !gamut[5] || !gamut[7]) - continue; // CIE XYZ, which has no RGB primaries - const Mat3 to_XYZ = rgb_to_XYZ(gamut); - const Vec3 gamut_white = xy_to_XYZ(gamut[6], gamut[7]); - for (const Mat3* cone : - { (const Mat3*)nullptr, &bradford_cone, &cat02_cone }) { - Mat3 expected = cone ? mat3_mul(adaptation(*cone, gamut_white, - interchange_white), - to_XYZ) - : to_XYZ; - if (!display) - expected = mat3_mul(from_ap0, expected); - if (same_up_to_scale(matrix, expected)) { - xy = gamut; - break; + + +// For primaries no known gamut matches: read them under each usual white +// point and adaptation, take the first reading whose coordinates are round +// enough, and otherwise the roundest, or failing that the first (D65, +// Bradford). +std::array +roundest_reading(const M33d& rgb_to_reference, const Reference& ref) +{ + const M33d rgb_to_XYZ = ref.to_XYZ * rgb_to_reference; + std::array best; + double best_rank = 0.0; + bool first = true; + for (const V3d& white : { white_D65, white_D60, white_DCI, white_D50 }) { + for (Adaptation method : { Adaptation::Bradford, Adaptation::CAT02 }) { + const std::array xy = chromaticities( + adapt(ref.white, white, method) * rgb_to_XYZ); + if (first) { + best = xy; + first = false; + } + if (exactness(xy, 6) > 0.6) + return xy; + const double rank = exactness(xy, 9); + if (rank > best_rank && rank > 0.2) { + best_rank = rank; + best = xy; } } - if (xy) - break; } + return best; +} + + - // A pure power passes through black and scales each probe by its power. - // It is reported only as an exponent the transforms state, so exactly. - if (!(vec3_length(black) <= tolerance && rises[0] > 0.0 && rises[1] > 0.0)) - return; - const double power = std::log(rises[0]) / std::log(probes[0]); - if (!(std::abs(std::log(rises[1]) / std::log(probes[1]) - power) - <= 1.0e-4 * power)) - return; - if (std::abs(power - 1.0) <= 1.0e-4) { - gamma = 1.0f; - return; +// The matrices that lead the conversion from the interchange space `role` to +// the color space `name`, multiplied into one: clamps are skipped, so are +// matrices that only scale, and the first curve ends them. +bool +leading_matrices(const OCIO::Config& config, const char* role, const char* name, + M33d& matrices) +{ + OCIO::ConstGroupTransformRcPtr group; + try { + // Lossless optimization folds and cancels matrices without composing + // LUTs, which only the curves after the matrices would use; over + // OCIO's built-in configs it reads the same matrices as any heavier + // optimization, much faster. + group = config.getProcessor(role, name) + ->getOptimizedProcessor(OCIO::OptimizationFlags( + OCIO::OPTIMIZATION_LOSSLESS + & ~OCIO::OPTIMIZATION_SIMPLIFY_OPS)) + ->createGroupTransform(); + } catch (const std::exception&) { + return false; } - auto ops = decode->createGroupTransform(); - for (int i = 0, n = ops->getNumTransforms(); i < n; ++i) { - auto exponent = OCIO::DynamicPtrCast( - ops->getTransform(i)); - if (!exponent) + matrices.makeIdentity(); + for (int i = 0, n = group->getNumTransforms(); i < n; ++i) { + const OCIO::ConstTransformRcPtr transform = group->getTransform(i); + if (transform->getTransformType() == OCIO::TRANSFORM_TYPE_RANGE) continue; - double value[4]; - exponent->getValue(value); - if (value[0] != value[1] || value[0] != value[2] || !(value[0] > 0.0)) + auto matrix = OCIO::DynamicPtrCast( + transform); + if (!matrix) + break; + double m[16]; + matrix->getMatrix(m); + M33d step(m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]); + bool mixes = false; + for (int r = 0; r < 3; ++r) + for (int c = 0; c < 3; ++c) + mixes |= r != c && std::abs(step[r][c]) > 0.1; + if (!mixes) continue; - for (double stated : { value[0], 1.0 / value[0] }) - if (std::abs(stated - power) <= 1.0e-4 * power) { - gamma = float(stated); - return; - } + if (matrix->getDirection() == OCIO::TRANSFORM_DIR_INVERSE) + step.invert(); + matrices = step * matrices; } + return true; +} + + + +// Whether a transform, other than a group, file or color space reference, is +// one whose matrices and curves describe a color encoding: not a 3D LUT, +// grade, look, view or rendering. +bool +is_simple_transform(const OCIO::ConstTransformRcPtr& transform) +{ + switch (transform->getTransformType()) { + case OCIO::TRANSFORM_TYPE_BUILTIN: { + auto builtin = OCIO::DynamicPtrCast( + transform); + const std::string style(builtin ? builtin->getStyle() : ""); + return style.rfind("ACES-OUTPUT", 0) != 0 + && style.rfind("ACES-LMT", 0) != 0; + } + case OCIO::TRANSFORM_TYPE_FIXED_FUNCTION: { +#if OCIO_VERSION_HEX > 0x02040000 + auto ff = OCIO::DynamicPtrCast( + transform); + return ff + && (ff->getStyle() == OCIO::FIXED_FUNCTION_LIN_TO_DOUBLE_LOG + || ff->getStyle() == OCIO::FIXED_FUNCTION_LIN_TO_GAMMA_LOG); +#else + return false; +#endif + } + case OCIO::TRANSFORM_TYPE_MATRIX: + case OCIO::TRANSFORM_TYPE_RANGE: + case OCIO::TRANSFORM_TYPE_EXPONENT: + case OCIO::TRANSFORM_TYPE_EXPONENT_WITH_LINEAR: + case OCIO::TRANSFORM_TYPE_LOG: + case OCIO::TRANSFORM_TYPE_LOG_AFFINE: + case OCIO::TRANSFORM_TYPE_LOG_CAMERA: + case OCIO::TRANSFORM_TYPE_ALLOCATION: + case OCIO::TRANSFORM_TYPE_LUT1D: + case OCIO::TRANSFORM_TYPE_GRADING_RGB_CURVE: return true; + default: return false; + } +} + + + +bool +has_context_variable(const char* s) +{ + return s && (std::strchr(s, '$') || std::strchr(s, '%')); +} + + + +// Whether a color space's transforms (from the reference if it has them, +// otherwise to it) include anything but simple ones, following color space +// references. Data and is-unique spaces count as complex, and so does a +// reference cycle. +bool +is_complex(const OCIO::Config& config, const OCIO::ConstColorSpaceRcPtr& cs, + std::unordered_set& visiting) +{ + if (!cs || cs->isData() || cs->hasCategory("is-unique") + || has_context_variable(cs->getName())) + return true; + if (!visiting.insert(cs->getName()).second) + return true; + std::function complex_transform; + complex_transform = [&](const OCIO::ConstTransformRcPtr& transform) { + if (!transform) + return false; + switch (transform->getTransformType()) { + // Files are taken as simple, whatever they hold. + case OCIO::TRANSFORM_TYPE_FILE: return false; + case OCIO::TRANSFORM_TYPE_GROUP: { + auto group = OCIO::DynamicPtrCast( + transform); + for (int i = 0, n = group ? group->getNumTransforms() : 0; i < n; + ++i) + if (complex_transform(group->getTransform(i))) + return true; + return !group; + } + case OCIO::TRANSFORM_TYPE_COLORSPACE: { + auto cst = OCIO::DynamicPtrCast( + transform); + if (!cst) + return true; + if (has_context_variable(cst->getSrc()) + || has_context_variable(cst->getDst())) + return false; + return is_complex(config, config.getColorSpace(cst->getSrc()), + visiting) + || is_complex(config, config.getColorSpace(cst->getDst()), + visiting); + } + default: return !is_simple_transform(transform); + } + }; + auto from = cs->getTransform(OCIO::COLORSPACE_DIR_FROM_REFERENCE); + const bool complex = complex_transform( + from ? from : cs->getTransform(OCIO::COLORSPACE_DIR_TO_REFERENCE)); + visiting.erase(cs->getName()); + return complex; +} + + + +// The color space a transform chain ends in, when its last step is a +// ColorSpaceTransform, following groups and their inversion. +std::string +trailing_color_space(const OCIO::ConstTransformRcPtr& transform, + OCIO::TransformDirection direction) +{ + if (!transform) + return {}; + const bool inverse = (direction == OCIO::TRANSFORM_DIR_INVERSE) + != (transform->getDirection() + == OCIO::TRANSFORM_DIR_INVERSE); + if (auto cst = OCIO::DynamicPtrCast( + transform)) { + const char* end = inverse ? cst->getSrc() : cst->getDst(); + return end ? end : ""; + } + auto group = OCIO::DynamicPtrCast(transform); + if (!group || group->getNumTransforms() == 0) + return {}; + return trailing_color_space( + group->getTransform(inverse ? 0 : group->getNumTransforms() - 1), + inverse ? OCIO::TRANSFORM_DIR_INVERSE : OCIO::TRANSFORM_DIR_FORWARD); +} + + + +bool +derive(const OCIO::Config& config, const OCIO::ColorSpace& cs, + cspan gamuts, std::array& xy, int depth) +{ + if (cs.isData()) + return false; + const bool display = cs.getReferenceSpaceType() + == OCIO::REFERENCE_SPACE_DISPLAY; + const char* role = display ? OCIO::ROLE_INTERCHANGE_DISPLAY + : OCIO::ROLE_INTERCHANGE_SCENE; + if (!config.hasRole(role)) + return false; + + // A space whose transforms are not simple but that ends in another + // color space answers as that one. + std::unordered_set visiting; + if (depth < 8 + && is_complex(config, config.getColorSpace(cs.getName()), visiting)) { + const std::string target = trailing_color_space( + cs.getTransform(OCIO::COLORSPACE_DIR_FROM_REFERENCE), + OCIO::TRANSFORM_DIR_FORWARD); + if (!target.empty() && target != cs.getName()) + if (auto other = config.getColorSpace(target.c_str())) + if (derive(config, *other, gamuts, xy, depth + 1)) + return true; + } + + M33d reference_to_rgb; + if (!leading_matrices(config, role, cs.getName(), reference_to_rgb) + || std::abs(reference_to_rgb.determinant()) < 1e-12) + return false; + const M33d rgb_to_reference = reference_to_rgb.inverse(); + const Reference& ref = reference(display); + if (const float* gamut = known_gamut(rgb_to_reference, ref, gamuts)) { + std::copy(gamut, gamut + 8, xy.begin()); + return true; + } + const std::array read = roundest_reading(rgb_to_reference, ref); + for (int i = 0; i < 8; ++i) + xy[i] = float(read[i]); + return true; +} + +} // namespace + + + +bool +pvt::derive_chromaticities(const OCIO::Config& config, + const OCIO::ColorSpace& cs, + cspan gamuts, std::array& xy) +{ + return derive(config, cs, gamuts, xy, 0); } OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_derive.h b/src/libOpenImageIO/color_derive.h index 43417fa118..28f83a78d2 100644 --- a/src/libOpenImageIO/color_derive.h +++ b/src/libOpenImageIO/color_derive.h @@ -4,6 +4,8 @@ #pragma once +#include + #include #include @@ -12,18 +14,19 @@ OIIO_NAMESPACE_BEGIN namespace pvt { -/// Derive what `cs`'s transforms establish about it: its RGB primaries and -/// white (CIE xy, R G B W), as `xy` pointing to one of `gamuts`, and the -/// exponent of a pure-power transfer function (1 for a linear one). It reads -/// the conversion to `config`'s scene or display interchange role, which -/// must be one shared per-channel curve followed by a matrix; anything else -/// establishes nothing. `ap0` is ACES2065-1's primaries and white, which the -/// scene interchange is. Properties that are not established are left -/// unchanged. -void derive_color_space_properties(const OCIO_NAMESPACE::Config& config, - const OCIO_NAMESPACE::ColorSpace& cs, - cspan gamuts, const float* ap0, - const float*& xy, float& gamma); +/// The RGB primaries and white point (Rx, Ry, Gx, Gy, Bx, By, Wx, Wy, CIE +/// 1931 xy) that the transforms of `cs` establish. They come from the +/// matrices that lead the conversion from `config`'s interchange space for +/// the reference type of `cs` (ACES2065-1 or CIE XYZ-D65): named as one of +/// `gamuts` when the matrices match it under Bradford, CAT02 or no chromatic +/// adaptation, and otherwise under the white point and adaptation that give +/// the most exact coordinates. A space whose transforms are not simple but +/// end in a ColorSpaceTransform answers as that transform's color space. +/// Returns false, leaving `xy` alone, if nothing is established. +bool derive_chromaticities(const OCIO_NAMESPACE::Config& config, + const OCIO_NAMESPACE::ColorSpace& cs, + cspan gamuts, + std::array& xy); } // namespace pvt OIIO_NAMESPACE_END diff --git a/src/libOpenImageIO/color_ocio.cpp b/src/libOpenImageIO/color_ocio.cpp index 2a871d0a10..158b74133e 100644 --- a/src/libOpenImageIO/color_ocio.cpp +++ b/src/libOpenImageIO/color_ocio.cpp @@ -220,13 +220,20 @@ class ColorConfig::Impl { OCIO::ConfigRcPtr config_; OCIO::ConfigRcPtr builtinconfig_; - // A color space's chromaticities (in the ColorInteropID table, or null) - // and transfer function gamma. + // A color space's chromaticities (in the ColorInteropID table, or derived + // and held below, or null) and transfer function gamma. struct Properties { const float* xy = nullptr; float gamma = 0.0f; }; + // What a color space's transforms establish about it. + struct Derived { + std::array xy; + bool has_xy = false; + bool linear = false; + }; + private: std::vector colorspaces; std::string scene_linear_alias; // Alias for a scene-linear color space @@ -242,7 +249,8 @@ class ColorConfig::Impl { ColorProcessorMap colorprocmap; // cache of ColorProcessors atomic_int colorprocs_requested; atomic_int colorprocs_created; - mutable std::map> m_derived; + // Node-based, so the chromaticities stay where they are as it grows. + mutable std::map> m_derived; std::string m_configname; ColorConfig* m_self = nullptr; bool m_config_is_built_in = false; @@ -352,7 +360,7 @@ class ColorConfig::Impl { // What the transforms of a color space of this config establish about // it, worked out once per color space. - Properties derived(const OCIO::ColorSpace& cs) const; + const Derived& derived(const OCIO::ColorSpace& cs) const; int getNumColorSpaces() const { return (int)colorspaces.size(); } @@ -2666,6 +2674,45 @@ constexpr float xy_ap1[] = { .713f, .293f, .165f, .83f, // CIE XYZ taken as RGB, so R = G = B is illuminant E. constexpr float xy_xyz[] = { 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f / 3, 1.0f / 3 }; +// Camera gamuts, as their makers publish them. +constexpr float xy_awg3[] = { .684f, .313f, .221f, .848f, + .0861f, -.102f, .3127f, .329f }; +constexpr float xy_awg4[] = { .7347f, .2653f, .1424f, .8576f, + .0991f, -.0308f, .3127f, .329f }; +constexpr float xy_bmdwg5[] = { .7177215f, .3171181f, .228041f, .861569f, + .1005841f, -.0820452f, .312717f, .3290312f }; +constexpr float xy_cgamut[] = { .74f, .27f, .17f, 1.14f, + .08f, -.1f, .3127f, .329f }; +constexpr float xy_dgamut[] = { .71f, .31f, .21f, .88f, + .09f, -.08f, .3127f, .329f }; +constexpr float xy_dwg[] = { .8f, .313f, .1682f, .9877f, + .079f, -.1155f, .3127f, .329f }; +constexpr float xy_rwg[] = { .780308f, .304253f, .121595f, 1.493994f, + .095612f, -.084589f, .3127f, .329f }; +constexpr float xy_sgamut3[] = { .73f, .28f, .14f, .855f, + .1f, -.05f, .3127f, .329f }; +constexpr float xy_sgamut3cine[] = { .766f, .275f, .225f, .8f, + .089f, -.087f, .3127f, .329f }; +constexpr float xy_sgamut3venice[] = { .74046426f, .27936437f, .08924115f, + .89380953f, .11048824f, -.05257933f, + .3127f, .329f }; +constexpr float xy_sgamut3cinevenice[] = { .77590187f, .27450239f, .1886829f, + .82868494f, .10133738f, -.08918752f, + .3127f, .329f }; +constexpr float xy_vgamut[] = { .73f, .28f, .165f, .84f, + .1f, -.03f, .3127f, .329f }; +constexpr float xy_applewg[] = { .725f, .301f, .221f, .814f, + .068f, -.076f, .3127f, .329f }; +// Published gamuts that no Color Interop ID OpenImageIO knows uses yet, which +// derivation still recognizes. +constexpr float xy_p3d60[] = { .68f, .32f, .265f, .69f, + .15f, .06f, .32168f, .33767f }; +constexpr float xy_p3dci[] + = { .68f, .32f, .265f, .69f, .15f, .06f, .314f, .351f }; +constexpr float xy_rec601[] = { .63f, .34f, .31f, .595f, + .155f, .07f, .3127f, .329f }; +constexpr float xy_rec601pal[] = { .64f, .33f, .29f, .60f, + .15f, .06f, .3127f, .329f }; // A color interop ID with what OIIO knows about it: a legacy alias for older // configs, its CICP code if it has one, and its chromaticities (Rx, Ry, Gx, Gy, @@ -2785,7 +2832,58 @@ constexpr ColorInteropID color_interop_ids[] = { // priority when converting a CICP to an interop ID. { "ocio:lin_ciexyzd65_display", "cie_xyz_d65", CICPPrimaries::XYZD65, CICPTransfer::Linear, CICPMatrix::Unspecified }, + { "ocio:acescc_ap1_scene", nullptr, xy_ap1, 0.0f }, + { "ocio:acescct_ap1_scene", nullptr, xy_ap1, 0.0f }, + { "ocio:itu709_rec709_scene", nullptr, xy_rec709, 0.0f }, + { "ocio:arrilogc3_awg3_scene", nullptr, xy_awg3, 0.0f }, + { "ocio:lin_awg3_scene", nullptr, xy_awg3, 1.0f }, + { "ocio:arrilogc4_awg4_scene", nullptr, xy_awg4, 0.0f }, + { "ocio:lin_awg4_scene", nullptr, xy_awg4, 1.0f }, + { "ocio:bmdfilm5_wg5_scene", nullptr, xy_bmdwg5, 0.0f }, + { "ocio:lin_bmdwg5_scene", nullptr, xy_bmdwg5, 1.0f }, + { "ocio:davinci_dwg_scene", nullptr, xy_dwg, 0.0f }, + { "ocio:lin_dwg_scene", nullptr, xy_dwg, 1.0f }, + { "ocio:canonlog2_cgamutd55_scene", nullptr, xy_cgamut, 0.0f }, + { "ocio:canonlog3_cgamutd55_scene", nullptr, xy_cgamut, 0.0f }, + { "ocio:lin_cgamutd55_scene", nullptr, xy_cgamut, 1.0f }, + { "ocio:djilog_dgamut_scene", nullptr, xy_dgamut, 0.0f }, + { "ocio:lin_dgamut_scene", nullptr, xy_dgamut, 1.0f }, + { "ocio:vlog_vgamut_scene", nullptr, xy_vgamut, 0.0f }, + { "ocio:lin_vgamut_scene", nullptr, xy_vgamut, 1.0f }, + { "ocio:redlog3g10_rwg_scene", nullptr, xy_rwg, 0.0f }, + { "ocio:lin_rwg_scene", nullptr, xy_rwg, 1.0f }, + { "ocio:slog3_sgamut3_scene", nullptr, xy_sgamut3, 0.0f }, + { "ocio:lin_sgamut3_scene", nullptr, xy_sgamut3, 1.0f }, + { "ocio:slog3_sgamut3cine_scene", nullptr, xy_sgamut3cine, 0.0f }, + { "ocio:lin_sgamut3cine_scene", nullptr, xy_sgamut3cine, 1.0f }, + { "ocio:slog3_sgamut3venice_scene", nullptr, xy_sgamut3venice, 0.0f }, + { "ocio:lin_sgamut3venice_scene", nullptr, xy_sgamut3venice, 1.0f }, + { "ocio:slog3_sgamut3cinevenice_scene", nullptr, xy_sgamut3cinevenice, + 0.0f }, + { "ocio:lin_sgamut3cinevenice_scene", nullptr, xy_sgamut3cinevenice, 1.0f }, + { "ocio:lin_applewg_scene", nullptr, xy_applewg, 1.0f }, }; + +// The gamuts derivation names a color space's primaries as: the table's, then +// those no ID uses yet. +const std::vector& +known_gamuts() +{ + static const std::vector gamuts = [] { + std::vector unique; + for (const ColorInteropID& interop : color_interop_ids) + if (interop.chromaticities + && std::find(unique.begin(), unique.end(), + interop.chromaticities) + == unique.end()) + unique.push_back(interop.chromaticities); + for (const float* extra : + { xy_p3d60, xy_p3dci, xy_rec601, xy_rec601pal }) + unique.push_back(extra); + return unique; + }(); + return gamuts; +} } // namespace const std::vector>& @@ -2871,7 +2969,7 @@ ColorConfig::get_cicp(string_view colorspace) const -ColorConfig::Impl::Properties +const ColorConfig::Impl::Derived& ColorConfig::Impl::derived(const OCIO::ColorSpace& cs) const { const string_view name(cs.getName()); @@ -2881,23 +2979,53 @@ ColorConfig::Impl::derived(const OCIO::ColorSpace& cs) const if (found != m_derived.end()) return found->second; } - // Name the space's primaries as one of the table's. - static const std::vector gamuts = [] { - std::vector unique; - for (const ColorInteropID& interop : color_interop_ids) - if (interop.chromaticities - && std::find(unique.begin(), unique.end(), - interop.chromaticities) - == unique.end()) - unique.push_back(interop.chromaticities); - return unique; - }(); - Properties result; - OIIO::pvt::derive_color_space_properties(*config_, cs, gamuts, xy_ap0, - result.xy, result.gamma); + // Worked out without the lock; the first answer stored is kept. + Derived result; + result.has_xy = OIIO::pvt::derive_chromaticities(*config_, cs, + known_gamuts(), result.xy); + try { + result.linear = config_->isColorSpaceLinear(cs.getName(), + cs.getReferenceSpaceType()); + } catch (const std::exception& e) { + DBG("OCIO exception checking linearity of {}: {}\n", name, e.what()); + } spin_rw_write_lock lock(m_mutex); - m_derived.emplace(name, result); - return result; + return m_derived.emplace(name, result).first->second; +} + + + +// The chromaticities that OpenImageIO's built-in definition of a Color +// Interop ID establishes, worked out once per process, or null if it has +// none or doesn't define the ID. +static const float* +builtin_id_chromaticities(string_view interop_id) +{ + // Node-based, so the chromaticities stay where they are as it grows. + using Found = std::pair>; + static spin_rw_mutex mutex; + static std::map> found; + { + spin_rw_read_lock lock(mutex); + auto f = found.find(interop_id); + if (f != found.end()) + return f->second.first ? f->second.second.data() : nullptr; + } + // Worked out without the lock; the first answer stored is kept. + Found result { false, {} }; + try { + auto identities = interop_identities_config(); + auto cs = identities->getColorSpace(std::string(interop_id).c_str()); + result.first = cs && Strutil::iequals(interop_id, cs->getName()) + && OIIO::pvt::derive_chromaticities(*identities, *cs, + known_gamuts(), + result.second); + } catch (const std::exception& e) { + DBG("OCIO exception deriving {}: {}\n", interop_id, e.what()); + } + spin_rw_write_lock lock(mutex); + const Found& stored = found.emplace(interop_id, result).first->second; + return stored.first ? stored.second.data() : nullptr; } @@ -2918,24 +3046,46 @@ ColorConfig::Impl::properties(string_view colorspace) const } if (cs && cs->isData()) return {}; - const string_view encoding(cs ? cs->getEncoding() : ""); - const bool linear = encoding == "scene-linear" - || encoding == "display-linear"; - Properties result; + const bool analyze = cs && OIIO::pvt::colorconfig_analyze_transforms; + const ColorInteropID* row = nullptr; for (const ColorInteropID& interop : color_interop_ids) { - if (interop.chromaticities && interop_id == interop.interop_id) { - // An ID contradicting a declared linear encoding is ignored. - if (!(linear && interop.gamma != 1.0f)) - result = { interop.chromaticities, interop.gamma }; + if (interop_id == interop.interop_id) { + row = &interop; break; } } - if (cs && interop_id.empty() && OIIO::pvt::colorconfig_analyze_transforms) { - // No known ID, so derive what the space's transforms establish. - result = derived(*cs); - } - if (linear) + + // The primaries are the ID's: the table's (a row without them says the + // ID has none), or those of OpenImageIO's own definition of an ID the + // table lacks. A space with neither has them derived from its transforms. + const Derived* own = analyze && !row ? &derived(*cs) : nullptr; + Properties result; + if (row) { + result.xy = row->chromaticities; + } else if (own) { + if (!interop_id.empty() && !disable_builtin_configs) + result.xy = builtin_id_chromaticities(interop_id); + if (!result.xy && own->has_xy) + result.xy = own->xy.data(); + } + + // The transfer function is the space's own, never its ID's. A declared + // encoding decides, linear only when it names the space's own reference + // type; without one, a space no row describes is linear if its + // transforms are. Until transfer functions are matched against known + // curves, a row still gives its gamma. + result.gamma = row ? row->gamma : 0.0f; + const std::string encoding = Strutil::lower(cs ? cs->getEncoding() : ""); + if (!encoding.empty()) { + const bool display = cs->getReferenceSpaceType() + == OCIO::REFERENCE_SPACE_DISPLAY; + if (encoding == (display ? "display-linear" : "scene-linear")) + result.gamma = 1.0f; + else if (result.gamma == 1.0f) + result.gamma = 0.0f; + } else if (own && own->linear) { result.gamma = 1.0f; + } return result; } diff --git a/src/libOpenImageIO/color_test.cpp b/src/libOpenImageIO/color_test.cpp index 42f16331ce..b260f499b5 100644 --- a/src/libOpenImageIO/color_test.cpp +++ b/src/libOpenImageIO/color_test.cpp @@ -430,10 +430,15 @@ test_color_space_properties() OIIO_CHECK_EQUAL(config.get_chromaticities("sRGB").size(), 8); OIIO_CHECK_EQUAL(config.get_transfer_function_gamma("Adobe"), 563.0f / 256.0f); - for (auto name : { "Plain", "Mislabeled" }) { + for (auto name : { "Plain", "Mislabeled" }) OIIO_CHECK_EQUAL(config.get_transfer_function_gamma(name), 1.0f); - OIIO_CHECK_ASSERT(config.get_chromaticities(name).empty()); - } + OIIO_CHECK_ASSERT(config.get_chromaticities("Plain").empty()); + // The ID gives the primaries even when the encoding contradicts its + // transfer function, which the encoding decides. + const float rec709[] = { .64f, .33f, .30f, .60f, + .15f, .06f, .3127f, .329f }; + OIIO_CHECK_ASSERT(config.get_chromaticities("Mislabeled") + == cspan(rec709)); for (auto name : { "Data", "data", "missing" }) { OIIO_CHECK_EQUAL(config.get_transfer_function_gamma(name), 0.0f); OIIO_CHECK_ASSERT(config.get_chromaticities(name).empty()); @@ -485,81 +490,123 @@ test_color_space_properties() static void test_derived_color_space_properties() { + if (!ColorConfig::supportsOpenColorIO()) + return; + // Color spaces with no Color Interop ID, written with OCIO's built-in // transforms, report what their transforms establish. const std::string filename = Filesystem::temp_directory_path() + "/" + Filesystem::unique_path() + ".ocio"; - // A 3D LUT that is the identity except at black, which lowering one - // channel from white never reaches. - const std::string cube = Filesystem::temp_directory_path() + "/" - + Filesystem::unique_path() + ".cube"; + const std::string cube = Filesystem::temp_directory_path() + "/" + + Filesystem::unique_path() + ".cube"; OIIO_CHECK_ASSERT(Filesystem::write_text_file( cube, "LUT_3D_SIZE 2\n0.1 0.1 0.1\n1 0 0\n0 1 0\n1 1 0\n" "0 0 1\n1 0 1\n0 1 1\n1 1 1\n")); + auto scene = [](const char* name, const char* transform) { + return Strutil::fmt::format( + " - !\n name: {}\n to_scene_reference: {}\n", + name, transform); + }; + auto display = [](const char* name, const char* builtin, + const char* encoding = nullptr) { + return Strutil::fmt::format( + " - !\n name: {}\n{} from_display_reference: " + "! {{style: DISPLAY - CIE-XYZ-D65_to_{}}}\n", + name, + encoding ? Strutil::fmt::format(" encoding: {}\n", encoding) + : std::string(), + builtin); + }; + const char* acescg = "! {style: ACEScg_to_ACES2065-1}"; + const std::string from_xyz + = "! {style: UTILITY - ACES-AP0_to_CIE-XYZ-D65_BFD, " + "direction: inverse}"; + // RGB to CIE XYZ of P3-D65, and of the primaries (0.7, 0.3), (0.2, 0.75) + // and (0.12, 0.05) with a D65 white. + const std::string p3_matrix + = "! {matrix: [0.4865709486482162, " + "0.2656676931690931, 0.1982172852343625, 0, 0.2289745640697488, " + "0.6917385218365064, 0.0792869140937450, 0, 0, 0.0451133818589026, " + "1.0439443689009760, 0, 0, 0, 0, 1]}"; + const std::string custom_matrix + = "! {matrix: [0.6204938816007054, " + "0.1789769595413957, 0.1509850859095705, 0, 0.2659259492574452, " + "0.6711635982802339, 0.06291045246232105, 0, 0, " + "0.04474423988534896, 1.044313510874529, 0, 0, 0, 0, 1]}"; OIIO_CHECK_ASSERT(Filesystem::write_text_file( filename, - std::string( - "ocio_profile_version: 2.3\n" - "roles: {aces_interchange: ACES2065-1, " - "cie_xyz_d65_interchange: CIE-XYZ-D65, default: ACES2065-1}\n" - "file_rules:\n - ! {name: Default, colorspace: default}\n" - "colorspaces:\n" - " - ! {name: ACES2065-1}\n" - " - !\n" - " name: Power AP1\n" - " to_scene_reference: ! {children: [\n" - " ! {value: 2.2},\n" - " ! {style: ACEScg_to_ACES2065-1}]}\n" - " - !\n" - " name: Log AP1\n" - " to_scene_reference: ! " - "{style: ACEScct_to_ACES2065-1}\n" - // P3-D65 reaching ACES2065-1 through OCIO's Bradford adaptation. - " - !\n" - " name: Plain P3\n" - " to_scene_reference: ! {children: [\n" - " ! {matrix: [0.4865709486482162, " - "0.2656676931690931, 0.1982172852343625, 0, 0.2289745640697488, " - "0.6917385218365064, 0.0792869140937450, 0, 0, 0.0451133818589026, " - "1.0439443689009760, 0, 0, 0, 0, 1]},\n" - " ! {style: UTILITY - ACES-AP0_to_CIE-XYZ-D65_BFD, " - "direction: inverse}]}\n" - // A curve after the matrix is not a curve and then a matrix. - " - !\n" - " name: Curve Last\n" - " to_scene_reference: ! {children: [\n" - " ! {style: ACEScg_to_ACES2065-1},\n" - " ! {value: 2.2}]}\n" - // A declared linear encoding is honored over the measured power. - " - !\n" - " name: Said Linear\n" - " encoding: scene-linear\n" - " to_scene_reference: ! {children: [\n" - " ! {value: 2.2},\n" - " ! {style: ACEScg_to_ACES2065-1}]}\n" - " - !\n" - " name: Cube\n" - " to_scene_reference: ! {children: [\n" - " ! {src: \"") - + cube - + "\", interpolation: linear},\n" - " ! {style: ACEScg_to_ACES2065-1}]}\n" - "display_colorspaces:\n" + "ocio_profile_version: 2.3\n" + "roles: {aces_interchange: ACES2065-1, " + "cie_xyz_d65_interchange: CIE-XYZ-D65, default: ACES2065-1}\n" + "file_rules:\n - ! {name: Default, colorspace: default}\n" + "colorspaces:\n" + " - ! {name: ACES2065-1}\n" + + scene("ACEScg", acescg) + + scene("Power AP1", + "! {children: [! " + "{value: 2.2}, ! {style: " + "ACEScg_to_ACES2065-1}]}") + + scene("Log AP1", + "! {style: ACEScct_to_ACES2065-1}") + // ACEScc's decoder clamps negatives after its matrix. + + scene("Clamped Log AP1", + "! {style: ACEScc_to_ACES2065-1}") + // P3-D65 reaching ACES2065-1 through Bradford adaptation. + + scene("Plain P3", ("! {children: [" + p3_matrix + + ", " + from_xyz + "]}") + .c_str()) + // A camera gamut, named as its maker publishes it. + + scene("RED Log", + "! {style: RED_LOG3G10-RWG_to_ACES2065-1}") + // Primaries no published gamut has. + + scene("Custom Gamut", ("! {children: [" + + custom_matrix + ", " + from_xyz + "]}") + .c_str()) + // A grade ahead of ACEScg answers as ACEScg. + + " - !\n name: Graded AP1\n" + " from_scene_reference: ! {children: [" + "! {slope: [1.1, 1, 0.9]}, " + "! {src: ACES2065-1, dst: ACEScg}]}\n" + // A curve applied after the matrix leaves only ACES2065-1's own. + + scene("Curve Last", + "! {children: [! " + "{style: ACEScg_to_ACES2065-1}, ! " + "{value: 2.2}]}") + // A declared linear encoding is honored over the transforms. + + " - !\n name: Said Linear\n" + " encoding: scene-linear\n" + " to_scene_reference: ! {children: [" + "! {value: 2.2}, ! " + "{style: ACEScg_to_ACES2065-1}]}\n" + + scene("Cube", + ("! {children: [! " + "{src: \"" + + cube + "\", interpolation: linear}, " + acescg + "]}") + .c_str()) + + "display_colorspaces:\n" " - ! {name: CIE-XYZ-D65}\n" - " - !\n" - " name: Monitor 1886\n" - " from_display_reference: ! " - "{style: DISPLAY - CIE-XYZ-D65_to_REC.1886-REC.709}\n" - " - !\n" - " name: Monitor P3\n" - " from_display_reference: ! " - "{style: DISPLAY - CIE-XYZ-D65_to_DisplayP3}\n")); + + display("Monitor 1886", "REC.1886-REC.709") + + display("Monitor P3", "DisplayP3") + + display("Cinema P3-D60", "G2.6-P3-D60-BFD") + + display("Cinema P3-DCI", "G2.6-P3-DCI-BFD") + // A scene encoding on a display space is not linear for it. + + display("Mistagged 1886", "REC.1886-REC.709", "scene-linear"))); + const float ap0[] = { .7347f, .2653f, 0.0f, 1.0f, .0001f, -.077f, .32168f, .33767f }; const float ap1[] = { .713f, .293f, .165f, .83f, .128f, .044f, .32168f, .33767f }; const float rec709[] = { .64f, .33f, .30f, .60f, .15f, .06f, .3127f, .329f }; const float p3d65[] = { .68f, .32f, .265f, .69f, .15f, .06f, .3127f, .329f }; + const float p3d60[] = { .68f, .32f, .265f, .69f, + .15f, .06f, .32168f, .33767f }; + const float p3dci[] = { .68f, .32f, .265f, .69f, .15f, .06f, .314f, .351f }; + const float rwg[] = { .780308f, .304253f, .121595f, 1.493994f, + .095612f, -.084589f, .3127f, .329f }; + const float custom[] = { .7f, .3f, .2f, .75f, .12f, .05f, .3127f, .329f }; + // CIE XYZ taken as RGB, so R = G = B is illuminant E. + const float xyz[] + = { 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f / 3, 1.0f / 3 }; { ColorConfig config(filename); OIIO_CHECK_FALSE(config.has_error()); @@ -570,16 +617,24 @@ test_derived_color_space_properties() }; for (const Expected& e : { Expected { "ACES2065-1", ap0, 1.0f }, - Expected { "Power AP1", ap1, 2.2f }, + Expected { "ACEScg", ap1, 1.0f }, + Expected { "Power AP1", ap1, 0.0f }, Expected { "Log AP1", ap1, 0.0f }, + Expected { "Clamped Log AP1", ap1, 0.0f }, Expected { "Plain P3", p3d65, 1.0f }, - Expected { "Curve Last", nullptr, 0.0f }, - Expected { "Cube", nullptr, 0.0f }, + Expected { "RED Log", rwg, 0.0f }, + Expected { "Custom Gamut", custom, 1.0f }, + Expected { "Graded AP1", ap1, 1.0f }, + Expected { "Curve Last", ap0, 0.0f }, Expected { "Said Linear", ap1, 1.0f }, - Expected { "Monitor 1886", rec709, 2.4f }, + Expected { "Cube", ap1, 0.0f }, + Expected { "CIE-XYZ-D65", xyz, 1.0f }, + Expected { "Monitor 1886", rec709, 0.0f }, Expected { "Monitor P3", p3d65, 0.0f }, + Expected { "Cinema P3-D60", p3d60, 0.0f }, + Expected { "Cinema P3-DCI", p3dci, 0.0f }, + Expected { "Mistagged 1886", rec709, 0.0f }, }) { - OIIO_CHECK_EQUAL(config.get_color_interop_id(e.name), ""); // Compared as text, so a failure names the color space. auto describe = [&](cspan xy, float gamma) { return Strutil::fmt::format("{}: [{}] {}", e.name, @@ -595,12 +650,48 @@ test_derived_color_space_properties() // With analysis off, only declarations and known names answer, even // for a space already analyzed. OIIO::attribute("colorconfig:analyze_transforms", 0); - OIIO_CHECK_ASSERT(config.get_chromaticities("Power AP1").empty()); - OIIO_CHECK_EQUAL(config.get_transfer_function_gamma("Power AP1"), 0.0f); + OIIO_CHECK_ASSERT(config.get_chromaticities("Plain P3").empty()); + OIIO_CHECK_EQUAL(config.get_transfer_function_gamma("Plain P3"), 0.0f); OIIO::attribute("colorconfig:analyze_transforms", 1); } Filesystem::remove(filename); Filesystem::remove(cube); + + // OpenColorIO 2.5 reads a declared interop_id. The ID gives the + // primaries, from the table or from OpenImageIO's own definition of it, + // whatever the transforms do; an ID OpenImageIO doesn't know leaves them + // to the transforms. + if (ColorConfig::OpenColorIO_version_hex() >= 0x02050000) { + OIIO_CHECK_ASSERT(Filesystem::write_text_file( + filename, + "ocio_profile_version: 2.5\n" + "roles: {aces_interchange: ACES2065-1, " + "cie_xyz_d65_interchange: CIE-XYZ-D65, default: ACES2065-1}\n" + "file_rules:\n - ! {name: Default, colorspace: default}\n" + "colorspaces:\n" + " - ! {name: ACES2065-1}\n" + " - ! {name: Camera, interop_id: ocio:lin_awg3_scene}\n" + " - ! {name: Film, interop_id: ocio:adx10_apd_scene}\n" + " - !\n name: Studio Wide\n" + " interop_id: acme:lin_wide_scene\n" + " to_scene_reference: ! {children: [" + + p3_matrix + ", " + from_xyz + + "]}\n" + "display_colorspaces:\n" + " - ! {name: CIE-XYZ-D65}\n")); + const float awg3[] = { .684f, .313f, .221f, .848f, + .0861f, -.102f, .3127f, .329f }; + ColorConfig config(filename); + OIIO_CHECK_FALSE(config.has_error()); + OIIO_CHECK_ASSERT(config.get_chromaticities("Camera") + == cspan(awg3)); + OIIO_CHECK_EQUAL(config.get_chromaticities("Film").size(), 8); + OIIO_CHECK_ASSERT(config.get_chromaticities("Studio Wide") + == cspan(p3d65)); + OIIO_CHECK_EQUAL(config.get_transfer_function_gamma("Studio Wide"), + 1.0f); + Filesystem::remove(filename); + } }