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 applications — findColorSpaceForID, 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
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 anchor — aces_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:
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
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.