Skip to content

feat(color): derive primaries for color spaces without a known ID - #5563

Open
zachlewis wants to merge 2 commits into
AcademySoftwareFoundation:mainfrom
zachlewis:codex/sept-split-a10-derive
Open

zachlewis wants to merge 2 commits into
AcademySoftwareFoundation:mainfrom
zachlewis:codex/sept-split-a10-derive

Conversation

@zachlewis

@zachlewis zachlewis commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

ColorConfig::get_chromaticities() and get_transfer_function_gamma() (#5521,
#5557) report the primaries and transfer function of a color space with a known
Color Interop ID. This derives the primaries of a color space without one, and
whether it is linear, by analyzing the transforms in the OpenColorIO config.

Primaries

For a color space whose ID OpenImageIO has no primaries for:

  • It reads the matrices that lead the conversion from the config's interchange
    role for the space's reference type (aces_interchange or
    cie_xyz_d65_interchange), from OpenColorIO's optimized processor: clamps
    are skipped and the first curve ends them. ACEScc, whose decoder clamps
    negative values after its matrix, reads as AP1.
  • When those matrices are a published gamut reached with Bradford, CAT02 or no
    chromatic adaptation, within 1e-5, it reports that gamut's primaries and
    white. A D65 space authored against ACES2065-1 through an adapted matrix
    reports P3-D65, not adapted coordinates and a D60 white.
  • Otherwise it reads the primaries under the usual white points (D65, D60,
    DCI, D50) and adaptations, and keeps the reading with the most exact
    coordinates.
  • A space whose transforms include a 3D LUT, CDL, look or view, but that ends
    in another color space, answers as that one.

The ColorInteropID table gains OpenColorIO's camera IDs (ARRI, Blackmagic,
DaVinci, Canon, DJI, Panasonic, RED, Sony and Apple) with the primaries their
makers publish. With the table's other gamuts, plus P3-D60, P3-DCI and
Rec.601, those are the gamuts named above, so RED Wide Gamut reports RED's
published primaries and D65 white.

An ID gives the primaries: a space declaring ocio:lin_awg3_scene reports ARRI
Wide Gamut 3 whatever its transforms do. An ID the table lacks takes them from
OpenImageIO's built-in definition of it (#5547), and one neither knows, such
as a custom namespaced ID, has them derived.

Transfer function

The transfer function is the color space's own, never its ID's. A declared
encoding is linear only for the space's own reference type, so a display space
tagged scene-linear is not linear. Without one, a space the table doesn't
describe is linear if OpenColorIO's isColorSpaceLinear() says so. A gamma
other than 1.0 still comes only from a known ID; matching a space's transfer
function against known curves is for a later PR.

Cost

Each color space is analyzed once per ColorConfig, the first time it is
queried; image readers never analyze anything. Over the 52 spaces of OCIO's
studio-config-v2.1.0, which declares no IDs, querying both properties of
every space takes 7.6 ms the first time instead of 3.8 ms, and 2.2 ms after
that, as before (median of 15 processes).

Turning it off

A new global attribute, colorconfig:analyze_transforms (default 1), turns the
analysis off: get_chromaticities() and get_transfer_function_gamma() then
answer only from what the config declares and the names OpenImageIO knows.
Recognizing IDs by their transforms, a later PR from #5518, will follow the
same attribute.

This is new public API, so I'd value your view on its form. A global attribute
matches imageinput:strict, needs no new ABI, and can be set without code
changes through OPENIMAGEIO_OPTIONS=colorconfig:analyze_transforms=0. A
per-ColorConfig option would also work. Are the attribute and its name right?

Changed behavior

  • get_chromaticities() can report primaries for a config space with no known
    ID, or with an ID OpenImageIO has no primaries for, and
    get_transfer_function_gamma() reports 1.0 for such a space when it is
    linear.
  • An ID keeps its primaries when the space's declared encoding contradicts its
    transfer function.
  • A display space declaring scene-linear, or a scene space declaring
    display-linear, is no longer reported as linear.
  • get_color_interop_id() answers OpenColorIO's camera IDs for a space named
    or aliased by one.
  • New global attribute colorconfig:analyze_transforms.

On OCIO's built-in studio configs, every color space but data and the display
CIE XYZ space now reports primaries: 51 of the 52 in studio-config-v2.1.0,
where 23 did before. Every camera space reports its maker's published
primaries.

Validation

  • unit_color checks eighteen spaces written with OCIO's built-in transforms:
    AP0, AP1 linear, with a 2.2 power and as ACEScct and ACEScc; P3-D65 through
    Bradford adaptation; RED Log3G10; primaries no published gamut has; a CDL
    ahead of a ColorSpaceTransform; a curve after the matrix; a 3D LUT; a
    declared linear encoding; display CIE XYZ, Rec.1886, Display P3, P3-D60 and
    P3-DCI; and a display space tagged scene-linear. With the attribute at 0
    nothing is derived. With OCIO 2.5, a declared camera ID, an ID taken from
    OpenImageIO's built-in definition, and an unknown namespaced ID.
  • macOS arm64, AppleClang, Release, OCIO 2.5.1: full CTest 221/221. OCIO
    2.3.0: unit_color, python-colorconfig, python-imageinput-colorconfig
    and oiiotool-color pass. gcc 11 compiles color_ocio.cpp and
    color_derive.cpp as one unit, as the CI unity build does, with -Werror.
  • clang-format 22 reports no changes.

Since the first version of this PR, the primaries are read from the matrices
instead of sampling the decoder, which failed for ACEScc; gamuts the table
didn't list are reported; camera gamuts joined the table; Imath types replace
the hand-written matrices; and an ID OpenImageIO doesn't know no longer stops
the analysis, as Brecht pointed out.

Split out of #5518.

Assisted-by: Claude Code / Claude Opus 5.5

@brechtvl

Copy link
Copy Markdown
Contributor

As Brecht suggested on #5521, a color space without one should
get its information by analyzing the transforms in the OpenColorIO config. This
does that, for those two properties.

Nitpick, but this overall idea didn't come from me, it was part of your existing plans? For my use cases I was actually fine only supporting known and specified interop IDs, but I can see this being useful.

Comment thread src/libOpenImageIO/color_ocio.cpp Outdated
}
if (cs && interop_id.empty() && OIIO::pvt::colorconfig_analyze_transforms) {
// No known ID, so derive what the space's transforms establish.
result = derived(*cs);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the colorspace has an interop_id: specified in the config, no chromaticities or gamma is derived. If it's an interop ID that is not known to OIIO (like some custom namespaced interop ID), I think the intent is to derive it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, fixed in e25bd19. A declared ID that OpenImageIO's table doesn't know now gets its primaries from OpenImageIO's built-in definition of it, if it has one (ocio:adx10_apd_scene, say), and otherwise from the space's own transforms, as a custom acme: ID does. The ID only ever gives the primaries; whether the space is linear comes from its own encoding or transforms.

Comment thread src/libOpenImageIO/color_derive.cpp Outdated
namespace {

using Mat3 = std::array<double, 9>; // Row-major, applied to column vectors
using Vec3 = std::array<double, 3>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imath is already a required OIIO dependency, any reason its types are not used instead of this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, done in e25bd19: it uses Imath's M33d and V3d now.

Comment thread src/libOpenImageIO/color_derive.cpp Outdated
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling derive for every colorspace can be very slow on large OCIO config, as it needs to e.g. potentially load many large LUTs.

For example computing the chromaticities for an unknown colorspace when writing out a JXL file should be OK, as it's just one colorspace. But on file read, we probably wouldn't want to loop over all colorspaces to find the one that corresponds to given chromaticities.

We already discussed this before, and this PR doesn't do any of that so it's not an immediate concern. Just noting that if that is considered in the future, this could be optimized to e.g. skip any transform involving LUTs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Readers never call this, and it only analyzes the one color space asked about, once per config. I also switched to OpenColorIO's lossless optimization, which reads the same matrices without composing LUTs: the first pass over a 52-space studio config now takes 7.6 ms, against 3.8 ms without the analysis.

When matching a file's chromaticities against a config comes up, I'd only consider color spaces whose transforms are simple: no 3D LUTs, CDLs, looks or views, and no file LUTs other than 1D or matrix ones. That is the same rule OpenColorIO's own IdentifyBuiltinColorSpace() uses. I'd also cache the results per config.

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 <zachcanbereached@gmail.com>
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 <zachcanbereached@gmail.com>
@zachlewis
zachlewis force-pushed the codex/sept-split-a10-derive branch from 54a83a2 to e25bd19 Compare October 14, 2026 18:05
@zachlewis zachlewis changed the title feat(color): derive primaries and gamma for spaces without an ID feat(color): derive primaries for color spaces without a known ID Oct 14, 2026
@zachlewis

Copy link
Copy Markdown
Collaborator Author

You're right, sorry, I misattributed that: deriving properties was already in my plans, and on #5521 you suggested how to describe it. I've corrected the description.

I've also reworked the derivation since your review; the last paragraph of the description lists what changed.

@brechtvl

Copy link
Copy Markdown
Contributor

Thanks, overall this now works the way I would expect.

I'm not immediately sure what the right way of doing this is. But my local AI review is pointing out a bunch of plausible issues, and I won't have time to properly review again this week. So pasting it here in case it's helpful.

AI review

MEDIUM: a space whose chain starts with a curve gets the reference gamut's primaries

src/libOpenImageIO/color_derive.cpp:280-302, 463-476

leading_matrices() stops at the first non-matrix op and returns identity. known_gamut() then matches AP0 (scene) or reports XYZ (display). The "Curve Last" test codifies this, but it is confidently wrong for real spaces. An example is shaper-curve-plus-3D-LUT output spaces, as in ACES 1.x OCIO v1 configs (e.g. "Output - sRGB"), which would report AP0 primaries. A chain with no matrix before its first curve establishes nothing, so derive should return false.

MEDIUM: fallback to the trailing ColorSpaceTransform only looks at from_reference

src/libOpenImageIO/color_derive.cpp:410-432, 452-461

is_complex() inspects to_reference when from_reference is absent. trailing_color_space() is only given cs.getTransform(COLORSPACE_DIR_FROM_REFERENCE). A complex space defined only with to_scene_reference: [CST(src: ACEScg, …), <grade>] therefore never falls back. It reads the leading matrices of the complex chain instead. Fix: pass getTransform(COLORSPACE_DIR_TO_REFERENCE) with TRANSFORM_DIR_INVERSE when from is null.

MEDIUM: a table row without chromaticities blocks derivation, contradicting the docs

src/libOpenImageIO/color_ocio.cpp:3065-3071

color.h:457-459 and pythonbindings.md say a space "whose ID OpenImageIO has no primaries for" gets them derived. The code returns empty for any matched row without chromaticities: ocio:lin_ciexyzd65_display, g26_xyzd65_display, pq_xyzd65_display. The result is inconsistent:

  • An unlabeled CIE-XYZ-D65 display space reports XYZ primaries (the test expects xyz).
  • The same space tagged interop_id: ocio:lin_ciexyzd65_display (OCIO 2.5) reports none.

Either fix the docs or give those rows xy_xyz.

LOW: the "only scales" test also skips real gamut matrices

src/libOpenImageIO/color_derive.cpp:292-297

The test discards any matrix whose off-diagonals are all ≤ 0.1. The full reference→space matrix of a gamut near AP0 then reads as AP0. Examples:

  • A Bradford-only white change, such as AP0 with a D65 white.
  • A custom wide gamut.
  • A gamut matrix separated from the rest by a Range op.

Known gamuts clear the threshold, but narrowly: the S-Gamut3 Venice matrix has a 0.119 largest off-diagonal. A diagonal-only test (all off-diagonals ≈ 0) would target exposure scales without this risk.

LOW: off-by-one OCIO version guard

src/libOpenImageIO/color_derive.cpp:322

OCIO_VERSION_HEX > 0x02040000 excludes OCIO 2.4.0, but FIXED_FUNCTION_LIN_TO_DOUBLE_LOG and LIN_TO_GAMMA_LOG exist since 2.4.0. On 2.4.0 these fixed functions count as complex. Should be >=.

LOW: unneeded derivation work

src/libOpenImageIO/color_ocio.cpp:3063-3071

derived(*cs) runs the full analysis, including the getProcessor work in leading_matrices and isColorSpaceLinear, before checking builtin_id_chromaticities(). When the built-in ID supplies the primaries, the derived ones are unused. The derived linearity is used only when there's no encoding. That work could be deferred.

LOW: no guard on a zero-sum column

src/libOpenImageIO/color_derive.cpp:147-151

If a column of the derived RGB→XYZ matrix has X+Y+Z == 0, chromaticities() divides by zero. NaN coordinates are then cached and returned. Returning false for a non-finite or zero sum would be safer.

Note: apply()

Imath only has v * m for M33, which treats v as a row vector. apply(m, v) equals v * m.transposed(). To drop the helper, either use that or switch the file to Imath's row-vector convention (transposed matrices, reversed products).

@zachlewis

Copy link
Copy Markdown
Collaborator Author

Thanks, Brecht!

I'm on vacation until mid-next week, so I won't get a chance to address some of the feedback your robot generated, but at first glance, there are some concerning bits I need to address... thanks for checking!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants