Color Interop in OpenImageIO

Full Color Interop Forum guideline support — with zero new public API

Zach Lewis

2026-07-27

The problem

A color image arrives. What color space is it really in? The file may carry:

  • colorInteropID (OpenEXR) — an explicit Color Interop Forum identifier
  • CICP — coding-independent code points (H.273)
  • ICC profile
  • chromaticities + transfer function
  • custom metadata, file-rule patterns
  • …or nothing but a config default

These signals disagree. Every app rolls its own ad-hoc adjudication. Color breaks silently at every tool boundary.

“A robust, centrally defined heuristic that has a priority list of what we listen to, and can adjudicate between somewhat contradictory signals.” — Larry Gritz, #4787

What the Color Interop Forum published

The ASWF Color Interop Forum recommendation gives us, for the first time, a shared identity vocabulary:

  • Color interop IDs — a published list of identifiers naming the same color everywhere (lin_ap1_scene, srgb_rec709_display, …), with namespacing rules and a config-local form
  • The OpenEXR colorInteropID attribute — the native carrier: in a format that officially supports it, the ID always wins
  • A described function set for OCIO-based applicationsfindColorSpaceForID, generateLocalIDForColorSpace, LocateBuiltinColorSpace, IdentifyBuiltinColorSpace, getInteropID, GetProcessorFromConfigs

Some of those functions ship in OpenColorIO today; some are recommendation-described and not yet in any release.

The headline

Full guideline support in OpenImageIO — with backward compatibility for OIIO, OCIO, and configs that predate colorInteropID.

Supporting configs that already declare interop IDs is the easy case. The interesting case is the large population of production configs whose spaces have names like “Studio Linear” and no ID at all.

This work identifies such a space by what it does rather than what it is called — “Studio Linear” is numerically ACEScg — and writes the correct interop ID for it.

A config written in 2019 gets correct interop IDs on its files without editing a single line — wherever its spaces match the published built-ins, and never a guessed ID where they don’t.

What features are we bringing to OIIO?

Capability Lands
Read, resolve, identify, convert and write colorInteropID — against any config, including ones written before the ID existed Phase 1, for 3.2
Transform-comparison identification — a space is recognized by what its transforms do (“Studio Linear” is numerically ACEScg) Phase 1
The built-in interop-identities registry — the published ID set, embedded as pinned data Phase 1
Cross-config conversion — an ID works even when your config never heard of the space Phase 1
Inspection and search machinery — per-space facts with per-field provenance Internal — zero new public symbols
Experimental oiiotool --colorinfo / --colorspacesearch diagnostics — the machinery’s only surface Experimental, 3.2
Config utilities — serialize, archive, build-from-text, evolve, debug info, scoped context, cache clearing Internal — in progress
Automatic per-operation metadata freshness around the ImageBufAlgo color operations Phase 2
Declarative policy: profiles and per-config rules for what gets written Phase 3, proposal first

Three phases

Phase Scope
1 — Identity colorInteropID end-to-end per the Forum guidelines: read, resolve, identify, convert, write — through OpenEXR, against any config
2 — Metadata The other color-metadata signals and formats: CICP, ICC, mDCV, provenance; automatic metadata freshness around the ImageBufAlgo color operations; per-format derivation of confirmatory metadata
3 — Policy Declarative profiles and policies: per-config / per-pattern control of which signals are written, write planning + previews — proposed to the Forum/TSC before anything ships enabled

Phase 1 is one deliverable in six serial, individually reviewable PRs. Phases post in order; each PR lands on its own merits.

Zero new public API

Nothing in this contribution adds a public C++ or Python symbol. The inspection and search machinery is internal, and it surfaces in exactly one place — experimental oiiotool diagnostics:

  • oiiotool --colorinfo — what the engine concluded about an image or a color space, per-field provenance included
  • oiiotool --colorspacesearch — find spaces by what they are, not what they’re called
  • Marked experimental: flags and output may still change while the machinery settles — no signature ever has to lock for them

Where behavior meets the existing API, it rides existing seams, enriched: resolve() learns the ID grammar, the OpenEXR reader and writer learn the attribute, conventions travel as strings on ImageSpec. Cheap calls stay cheap — nothing new happens at construction, and untouched paths keep their cost profile.

A public inspection API is deliberately deferred until a demonstrated consumer arrives. The internal record is shaped so promotion is additive when that day comes — and the diagnostics are real, not stubs: everything here passes on the combined branch behind the PR series.

Phase 1 — one deliverable, six reviewable steps

flowchart LR
    subgraph phase1["Each lands on its own merits, strictly serial"]
        direction LR
        S1["ID parsing in resolve()"]
        S2["Built-in interop identities +<br/>caching foundation"]
        S3["Central OpenEXR read<br/>reconciliation"]
        S4["Transform-comparison identification:<br/>a space is recognized by what its<br/>transforms DO ('Studio Linear' is<br/>numerically ACEScg)"]
        S5["Native OpenEXR write:<br/>emits a derived ID for unlabeled<br/>spaces it can identify —<br/>otherwise omits, never guesses"]
        S6["Cross-config conversion:<br/>an ID works even when the<br/>config lacks the space"]
        S1 --> S2 --> S3 --> S4 --> S5 --> S6
    end

Order is load-bearing: identification precedes the write because the write’s ID selection sequence consumes it — that ordering is the backward-compatibility story. Everything in this series is internal: zero new public API.

Wrapper parity with the recommendation

Phase 1 implements the recommendation’s function set for OCIO-based applications — delegating to OpenColorIO natively where the linked version provides a function, backfilling the described semantics where it does not:

Recommendation function In OCIO today? OIIO behavior
getInteropID yes (2.5) native ≥2.5; alias/name resolution earlier
findColorSpaceForID not yet full implementation via the existing resolve()
generateLocalIDForColorSpace not yet full implementation (write-side helper)
LocateBuiltinColorSpace not yet via resolve() + the identification tier
IdentifyBuiltinColorSpace yes native where available; comparison fallback
GetProcessorFromConfigs yes (2.2+) native, via aces_interchange

The seventh, MergeColorSpaceFromConfig, is deferred until OIIO has a consumer for it. As native implementations arrive upstream, OIIO hands off to them.

The read side: one priority list

flowchart TD
    A["Strict parsing<br/>(the name/ID itself)"] --> B["ACES container flag"]
    B --> C["color interop ID<br/>native format: always wins<br/>(this series, OpenEXR)"]
    C --> D["CICP<br/>read display-referred by default<br/>(follow-up round)"]
    D --> E["ICC profile<br/>(follow-up round)"]
    E --> F["Custom metadata"]
    F --> G["File rules"]
    G --> H["Config default"]
    H --> T["No signal survives:<br/>no identity claim, never guess"]

    classDef terminal fill:#f8d7da,stroke:#842029,stroke-width:2px,color:#000;
    class T terminal;

One centrally implemented heuristic — checked in order, with an explicit no-guess floor. Read anything; write only best-practice (the asymmetry the OCIO TSC endorsed).

How it works

  • The built-in interop-identities config — the published ID set, embedded as data, pinned per OIIO release (unpinned “latest” is cross-build instability), usable from any config on any supported OCIO version
  • The interchange anchoraces_interchange / cie_xyz_d65_interchange roles anchor every comparison and cross-config conversion; one line in a config buys anchored, reproducible identification
  • Lazy identification — cheap ID/name/alias lookups first; transform comparison runs only at first actual need, with optimizations off and a documented tolerance, so results are reproducible across builds and platforms
  • cacheID-keyed immutable configs — every process-global memo keys on OpenColorIO’s own config cacheID; a config is immutable once constructed (a type guarantee, not a convention), any repair is a copy keyed by the source config — so identification cost is paid once per config, not per file

Identification is your own classifier, one tier deeper

ColorConfig already classifies color spaces — and already does it lazily:

  • classify_by_name (cheap) and classify_by_conversions (expensive), both run from examine() at the first query about a space
  • Deliberately not at construction — the eager classify call at insertion is commented out in color_ocio.cpp today
Upstream today What this work adds
cheap tier: name-based classification cheap tier: interop-ID / alias / declared-ID lookup
expensive tier: classify by conversions, at first need expensive tier: transform comparison against the pinned registry through the interchange anchor, at first need
per-space examined flag memoized under the config’s own OCIO cacheID

Your examine() tiers, extended to the registry identities and memoized. Nothing new happens at construction — no warm-priming, no eager probing; constructor cost is unchanged.

How the tracking harness works

Around the color-aware ImageBufAlgo operations: resolve the source before the pixel math, then maintain the output’s metadata according to what the operation can honestly claim.

flowchart TD
    CALL["Color-aware ImageBufAlgo call<br/>colorconvert / ociolook / ociodisplay / ociofiletransform"]
    CALL --> PREP["prepare: resolve the source color space<br/>caller's argument → oiio:ColorSpace tag →<br/>the file's color hints → policy floor"]
    PREP --> PIX["pixel operation<br/>(no-op processors included)"]
    PIX --> FIN{"finish: what can this<br/>operation honestly claim?"}
    FIN -- "identity-known" --> K["stamp the verdict with the target space,<br/>scrub the facts the FILE deposited,<br/>update-or-erase the state descriptors"]
    FIN -- "space-preserving" --> P["everything passes through;<br/>verdict re-stamped only when the<br/>operation names a differing space"]
    FIN -- "unknowable" --> U["absence everywhere: verdict,<br/>facts and descriptors erased"]

    classDef terminal fill:#f8d7da,stroke:#842029,stroke-width:2px,color:#000;
    class U terminal;

If the pixel math failed, finish is a no-op — the spec is left exactly as it was.

What each operation may claim

flowchart LR
    CC["colorconvert"] -- "normal conversion" --> KN
    CC -- "data space, or lenient<br/>cross-config pass-through" --> PR
    OL["ociolook"] -- "the look declares<br/>its output space" --> KN
    OD["ociodisplay"] -- "forward: display/view<br/>inverse: resolved source" --> KN
    OD -- "lenient pass-through" --> PR
    OFT["ociofiletransform"] -- "config file rules<br/>name the result" --> KN
    OFT -- "arbitrary LUT or transform file:<br/>the result cannot be known" --> UN

    KN["identity-known:<br/>stamp + scrub + maintain"]
    PR["space-preserving:<br/>passes through untouched"]
    UN["unknowable:<br/>erases everything"]

    classDef terminal fill:#f8d7da,stroke:#842029,stroke-width:2px,color:#000;
    class UN terminal;

Two-bucket rule: facts a file deposited about the source scrub after any known color change — they no longer describe the buffer. Current-state descriptors are updated or erased, never guessed.

Tracking is a convenience, not a contract — the never-guess floor holds. A raw-processor colorconvert, ocionamedtransform and colormatrixtransform carry no automatic tracking: a processor or a matrix does not declare its output space.

The API/ABI doctrine

Grow by fields, strings, and data — never by methods.

  • The internal info record is an opaque handle: one shared_ptr<const Impl>, sizeof frozen forever; every future field is another accessor
  • Options records instead of overload explosions; a closed verb set — new capability rides an option field, not a new verb
  • Conventions travel as strings on ImageSpec — zero ABI; the interop-ID list is data, not generated constants
  • Nothing public locks: the record stays internal until a consumer demonstrates the need — and inherits this doctrine, additively, if it is ever promoted
  • 3.1.x backport lane rides the same doctrine — next slide

The 3.1.x backport lane

With zero new public symbols anywhere in the series, the whole engine is patch-train-shaped by construction.

  • What can reach a patch train: the internal engine (resolution + identification, all internal symbols) and its transport — conventions carried as attributes on ImageSpec, read back through getattribute, shipped for decades. Zero new public symbols.
  • What never backports: the experimental oiiotool --colorinfo / --colorspacesearch diagnostics. Those are 3.2 and only 3.2.
  • Same brain, two exposure levels: patch trains get the behavior, 3.2 gets the diagnostics.

The two-sided proof each backport candidate carries:

Knob What the testsuite shows
off zero reference diffs tree-wide — behavioral invisibility
on exactly these refs change, in exactly these ways — function

The conversation opens after phase 1 lands. What actually gets backported is the maintainers’ call — available per PR, never assumed.

Compatible in both directions

Backward and forward compatible with OpenColorIO 2.3+ features:

  • Native delegation where the linked OCIO provides the function
  • Identical-semantics backfill where it does not — same answer, our implementation underneath
  • Hand-off as OCIO catches up: when a newer OCIO ships the function, OIIO calls it and stops backfilling — no behavior change at the seam

Any given release of OpenImageIO supports the same set of built-in color interop IDs on every platform, every build, and every linked OCIO version.

  • The registry is compiled in — byte-for-byte identical everywhere; the ID list and every emitted ID string are stable per OIIO release
  • Identification decisions are behavior-stable (optimizations off, documented tolerance) — the verdict is promised, never the bits
  • Recognition scope is the one thing that tracks the linked OCIO minor: a newer OCIO can recognize more spaces, never rename ones already recognized

Saying “I don’t know” precisely

Three markers, one job each — never guess, but say why you don’t know:

Marker Meaning
ocio:unknown The config’s interop ID is literally unknown
oiio:unknown Synthetic isData/NoOp treatment — may coexist with a real verdict
error:unknown Strict-mode marker; error deferred to processor build

Absence means could-not-determine — never a silent guess.

And the boundary rule: no namespaced marker ever reaches a file — internal working attributes are stripped at the writer boundary, and an unidentifiable space gets no ID rather than a plausible-looking wrong one.

Round 3 preview: the config declares its policy

The scene/display CICP write question needs no winner. OCIO FileRules already carry custom key/value pairs — round-tripped by OCIO, ignored by apps that don’t know them:

file_rules:
  - !<Rule> {name: oiio:default, colorspace: raw_data, regex: "$^",
             custom: {oiio:colorpolicy:write:cicp: display}}
  - !<Rule> {name: oiio:archival, colorspace: raw_data, regex: "$^",
             custom: {oiio:colorpolicy:write:cicp: always,
                      oiio:colorpolicy:write:interop_id: always}}
  - !<Rule> {name: Default, colorspace: raw_data}

Named values, not modes (display / always / never — no “auto”). The config author — who actually knows the pipeline — decides; the default honors the Forum position. Key spellings are explicitly strawman; the convention is offered to the Forum/TSC first and ships disabled until that conversation lands.

Status, and what lands when

  • Built and passing on the combined branch behind the PR series (draft PR #5392): the full identity engine, the experimental oiiotool diagnostics, and the policy mechanism — design closed, every check at baseline
  • First posts: two small standalone fixes + the umbrella issue laying out this plan, checked item-by-item against the #4980 task list
  • For 3.2 (beta mid-to-late August): the six-PR internal identity series, small and reviewable — zero new public symbols, with the experimental diagnostics riding the machinery they expose
  • Round 2: CICP/ICC/PNG legs, provenance, automatic metadata freshness — same fixed-default posture
  • Round 3: the policy proposal, discussion-first
  • Measured cost: 3–8% on reads that actually resolve metadata (prototype numbers); untouched paths unaffected; identification memoized per config

The ask

Review the identity series one small PR at a time — internal machinery, zero new public API symbols, and two experimental oiiotool diagnostics to try it with.

Full guideline support, backward compatible with the configs studios actually have. Feedback welcome — especially on the priority-list ordering before the reconciliation PR opens.