Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d0f8853
feat: ACES Container writer for OpenEXR
Glowies Sep 25, 2025
73d1e75
refactor(openexr): follow clang-format recommendations for exroutput.cpp
Glowies Sep 25, 2025
fda980e
fix(openexr): do not write ACES Container metadata when non-compliant…
Glowies Sep 26, 2025
e89f01f
test(openexr): test ACES container output with various modes of failure
Glowies Sep 26, 2025
2deb446
docs(openexr): document the oiio:ACESContainer hint
Glowies Sep 26, 2025
ca010df
refactor(openexr): rename oiio:ACESContainer hint to openexr:ACESCont…
Glowies Sep 27, 2025
0b3967e
refactor(openexr): add static keyword to AP0 chromaticities array
Glowies Sep 27, 2025
f98e2ce
fix(openexr): remove printing warning to stderr
Glowies Sep 30, 2025
2571f37
fix(openexr): chromaticities get set to AP0 in relaxed mode of ACES C…
Glowies Oct 1, 2025
c9b40c0
docs(openexr): update permalink to st2065-4 standard
Glowies Oct 1, 2025
ee84d2a
fix(openexr): check for existence of correct channel names instead of…
Glowies Oct 5, 2025
0f2feb5
refactor(openexr): update comment in exroutput.cpp
Glowies Oct 6, 2025
9f23db7
fix(openexr): use static and const keywords for the allowed_sets
Glowies Oct 6, 2025
7322ff1
test(openexr): more explanatory test results for aces container
Glowies Oct 6, 2025
c30d8f8
fix(openexr): test for non-empty attributes in ACES container
Glowies Oct 7, 2025
e94bd5d
feat(openexr): check attributes with exact required values
Glowies Oct 7, 2025
5c3fd08
refactor(openexr): follow clang-format recommendations
Glowies Oct 7, 2025
ac1c654
refactor(openexr): make chromaticities check a bit more readable
Glowies Oct 7, 2025
2052c52
feat(openexr): rename ACES container flag to openexr:ACESContainerPolicy
Glowies Oct 7, 2025
1e89fa5
refactor(openexr): apply clang-format recommendations
Glowies Oct 7, 2025
ae19a4c
feat(openexr): set aces container attributes in relaxed mode as well
Glowies Oct 8, 2025
c177142
docs(openexr): update ACES container mode to reflect changes to relax…
Glowies Oct 11, 2025
25fac3c
feat(openexr): better error messages for each failure case of ACES Co…
Glowies Oct 11, 2025
d3530e4
fix(openexr): inconsistent attribute read behaviour with ParamValue
Glowies Oct 11, 2025
21dfe1b
refactor(openexr): apply clang-format recommendations
Glowies Oct 11, 2025
d725544
test(openexr): fix issue with using single quotes in windows tests
Glowies Oct 11, 2025
b61974a
test(openexr): improve readability of ACES container test outputs
Glowies Oct 12, 2025
5980084
fix(openexr): remove redundant search for found attributes
Glowies Oct 19, 2025
60fba1b
refactor(openexr): replace vector with a c array to avoid extra alloc…
Glowies Oct 19, 2025
9216912
refactor(openexr): rename flag parameter and apply clang-format sugge…
Glowies Oct 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/doc/builtinplugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1643,6 +1643,19 @@ control aspects of the writing itself:
* - Output Configuration Attribute
- Type
- Meaning
* - ``openexr:ACESContainerPolicy``
- string
- One of `none` (default), `strict`, or `relaxed`.
If not `none`, the spec will be checked to see if it is compliant
with the ACES Container format defined in `ST 2065-4`_. If it is,
`chromaticities` will be set to the ACES AP0 ones, `colorInteropId`
will be set to 'lin_ap0_scene' and the `acesImageContainerFlag`
attribute will be set to 1.
In `strict` mode, if the spec is non-compliant, the output will
throw an error and avoid writing the image.
While in `relaxed` mode, if the spec is non-compliant, `chromaticities`
and `colorInteropId` will be set, but `acesImageContainerFlag`
will NOT.
Comment on lines +1649 to +1658
Copy link
Collaborator

Choose a reason for hiding this comment

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

I feel like this is not clear about what happens if the spec is passed with a colorInteropID that is set, but is not lin_ap0_scene. Are you saying that it will be changed to lin_ap0_scene (but pixels stay as they are)? Or is this something that will be rejected as non-compliant? Or does the behavior differ depending on the policy setting?

* - ``oiio:RawColor``
- int
- If nonzero, writing images with non-RGB color models (such as YCbCr)
Expand All @@ -1654,6 +1667,7 @@ control aspects of the writing itself:
- Pointer to a ``Filesystem::IOProxy`` that will handle the I/O, for
example by writing to a memory buffer.

.. _ST 2065-4: https://pub.smpte.org/pub/st2065-4/

**Custom I/O Overrides**

Expand Down
212 changes: 212 additions & 0 deletions src/openexr.imageio/exroutput.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,198 @@ set_exr_threads()



static constexpr float ACES_AP0_chromaticities[8] = {
0.7347f, 0.2653f, // red
0.0f, 1.0f, // green
0.0001f, -0.077f, // blue
0.32168f, 0.33767f // white
};

static const std::string ACES_AP0_colorInteropId = "lin_ap0_scene";


bool
is_spec_aces_container_channels_only(const OIIO::ImageSpec& spec)
{
// Note: this is constructing and comparing sets, so that channel order
// doesn't matter.

// Allowed channel sets
static const std::vector<std::set<std::string>> allowed_sets
= { { "B", "G", "R" },
{ "A", "B", "G", "R" },
{ "B", "G", "R", "left.B", "left.G", "left.R" },
{ "A", "B", "G", "R", "left.A", "left.B", "left.G", "left.R" } };

// Gather channel set from spec
std::set<std::string> channels(spec.channelnames.begin(),
spec.channelnames.end());

// Compare to allowed sets (unordered)
for (const auto& allowed : allowed_sets) {
if (channels == allowed) {
return true;
}
}

return false;
}



bool
is_aces_container_attributes_non_empty(const OIIO::ImageSpec& spec,
std::string& non_compliant_attr)
{
// attributes in this list should NOT be empty if they exist
static const std::string nonEmptyAttribs[] = {
"cameraFirmwareVersion",
"cameraIdentifier",
"cameraLabel",
"cameraMake",
"cameraModel",
"cameraSerialNumber",
"comments",
"creator",
"lensAttributes",
"lensFirmwareVersion",
"lensMake",
"lensModel",
"lensSerialNumber",
"owner",
"recorderFirmwareVersion",
"recorderMake",
"recorderModel",
"recorderSerialNumber",
"reelName",
"storageMediaSerialNumber",
};

for (const auto& label : nonEmptyAttribs) {
const ParamValue* found = spec.find_attribute(label,
OIIO::TypeDesc::STRING);
if (found
&& (found->type() != TypeString || found->get_string(1).empty())) {
non_compliant_attr = label;
return false;
}
}

return true;
}



bool
is_aces_container_compliant(const OIIO::ImageSpec& spec, std::string& reason)
{
if (!is_spec_aces_container_channels_only(spec)) {
reason
= "Spec channel names do not match those required for an ACES Container.";
return false;
}

// Check data type
if (spec.format != OIIO::TypeDesc::HALF) {
reason
= "EXR data type is not 'HALF' as required for an ACES Container.";
return false;
}

// Check compression
std::string compression = spec.get_string_attribute("compression", "zip");
if (compression != "none") {
reason = "Compression is not 'none' as required for an ACES Container.";
return false;
Comment on lines +384 to +395
Copy link
Collaborator

Choose a reason for hiding this comment

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

What's the best policy here?

For something like "doesn't have the right channels", the only thing to do is fail.

But for "wrong data type" or "wrong compression", we could force compliance by just fixing it.

}

// Check non-empty attributes
std::string non_compliant_attr = "";
if (!is_aces_container_attributes_non_empty(spec, non_compliant_attr)) {
reason = "Spec contains an empty string attribute (";
reason += non_compliant_attr;
reason += ") that is required to be non-empty in an ACES Container.";
return false;
}

// Check attributes with exact values if they exist
if (spec.get_string_attribute("oiio:ColorSpace", ACES_AP0_colorInteropId)
!= ACES_AP0_colorInteropId
|| spec.get_string_attribute("colorInteropId", ACES_AP0_colorInteropId)
!= ACES_AP0_colorInteropId) {
reason
= "Color space is not lin_ap0_scene as required for an ACES Container.";
return false;
}
Comment on lines +408 to +415
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we want "no color space known" to pass? Or fail? The way to make "none specified" to fail is to not supply default values to the get_string_attribute calls.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I'd like to get @antond-weta's opinions on that. I had assumed that we want the "no color space known" case to pass the compliance check. (ie. If there is no attribute indicating a color space, it passes the ACES Container compliance check, and we just continue processing the image assuming it is in linear AP0 space)


if (spec.get_int_attribute("acesImageContainerFlag", 1) != 1) {
reason
= "acesImageContainerFlag is not set to '1' as required for an ACES Container.";
return false;
}

// Check chromaticities
float chromaticities[8] = { 0., 0., 0., 0., 0., 0., 0., 0. };
bool chroms_found
= spec.getattribute("chromaticities",
OIIO::TypeDesc(OIIO::TypeDesc::FLOAT, 8),
chromaticities);
bool chroms_equal = std::equal(std::begin(chromaticities),
std::end(chromaticities),
std::begin(ACES_AP0_chromaticities));

if (chroms_found && !chroms_equal) {
reason
= "Chromaticities are not set to AP0 chromaticities as required for an ACES Container.";
return false;
}

return true;
}



void
set_aces_container_attributes(OIIO::ImageSpec& spec)
{
spec.attribute("chromaticities", OIIO::TypeDesc(OIIO::TypeDesc::FLOAT, 8),
ACES_AP0_chromaticities);
spec.attribute("colorInteropId", ACES_AP0_colorInteropId);
spec.attribute("acesImageContainerFlag", 1);
}



bool
process_aces_container(OIIO::ImageSpec& spec, std::string policy,
int acesImageContainerFlag,
std::string& non_compliance_reason)
{
bool treat_as_aces_container = policy == "strict"
|| acesImageContainerFlag == 1;
bool is_compliant = is_aces_container_compliant(spec,
non_compliance_reason);

if (treat_as_aces_container && !is_compliant) {
return false;
}

set_aces_container_attributes(spec);

if (policy == "relaxed" && !is_compliant) {
// When image is not compliant in relaxed mode, we should avoid
// setting the flag, and we should print a warning

// TODO: When we have a way to report warnings, report one here
// to indicate that the given image spec is not compliant
spec.erase_attribute("acesImageContainerFlag");
}

return true;
}



OpenEXROutput::OpenEXROutput()
{
pvt::set_exr_threads();
Expand Down Expand Up @@ -812,6 +1004,26 @@ OpenEXROutput::spec_to_header(ImageSpec& spec, int subimage,
Imf::LevelMode(m_levelmode),
Imf::LevelRoundingMode(m_roundingmode)));

// Check ACES Container hint
int aces_container_flag = spec.get_int_attribute("acesImageContainerFlag",
0);
std::string aces_container_policy
= spec.get_string_attribute("openexr:ACESContainerPolicy", "none");

if (aces_container_policy != "none" || aces_container_flag == 1) {
std::string non_compliance_reason = "";
bool should_panic = !process_aces_container(spec, aces_container_policy,
aces_container_flag,
non_compliance_reason);

if (should_panic) {
errorfmt(
"Cannot output non-compliant ACES Container in 'strict' mode. REASON: {}",
non_compliance_reason);
return false;
}
}

// Deal with all other params
for (const auto& p : spec.extra_attribs)
put_parameter(p.name().string(), p.type(), p.data(), header);
Expand Down
28 changes: 28 additions & 0 deletions testsuite/openexr-suite/ref/out.txt
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,31 @@ negoverscan.exr : 64 x 64, 3 channel, half openexr
screenWindowWidth: 1
oiio:subimages: 1
openexr:lineOrder: "increasingY"
acesImageContainerFlag for relaxed-out.exr is (1)
acesImageContainerFlag for fail.exr is ()
acesImageContainerFlag for fail.exr is ()
acesImageContainerFlag for fail.exr is ()
Reading strict-out.exr
strict-out.exr : 4 x 4, 3 channel, half openexr
SHA-1: C49A9785B2243F2F080DAAD1747F119ACCECCFA5
channel list: R, G, B
acesImageContainerFlag: 1
chromaticities: 0.7347, 0.2653, 0, 1, 0.0001, -0.077, 0.32168, 0.33767
colorInteropId: "lin_ap0_scene"
compression: "none"
PixelAspectRatio: 1
screenWindowCenter: 0, 0
screenWindowWidth: 1
oiio:ColorSpace: "lin_ap0_scene"
oiio:subimages: 1
openexr:ACESContainerPolicy: "strict"
openexr:lineOrder: "increasingY"
oiiotool ERROR: -o : Cannot output non-compliant ACES Container in 'strict' mode. REASON: Spec channel names do not match those required for an ACES Container.
Full command line was:
> oiiotool --create 4x4 3 -d half --compression none --ch left.R=R,G,B -sattrib openexr:ACESContainerPolicy strict -o strict-fail.exr
oiiotool ERROR: -o : Cannot output non-compliant ACES Container in 'strict' mode. REASON: Compression is not 'none' as required for an ACES Container.
Full command line was:
> oiiotool --create 4x4 3 -d half --compression zip -sattrib openexr:ACESContainerPolicy strict -o strict-fail.exr
oiiotool ERROR: -o : Cannot output non-compliant ACES Container in 'strict' mode. REASON: EXR data type is not 'HALF' as required for an ACES Container.
Full command line was:
> oiiotool --create 4x4 3 -d float --compression none -sattrib openexr:ACESContainerPolicy strict -o strict-fail.exr
33 changes: 33 additions & 0 deletions testsuite/openexr-suite/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,36 @@
# Check writing overscan and negative range
command += oiiotool("--create 64x64-16-16 3 -d half -o negoverscan.exr")
command += info_command("negoverscan.exr", safematch=True)

# Check ACES Container output for relaxed mode
#
# Valid ACES Container
command += oiiotool("--create 4x4 3 -d half --compression none -sattrib openexr:ACESContainerPolicy relaxed -o relaxed-out.exr")
command += oiiotool("relaxed-out.exr --echo \"acesImageContainerFlag for {TOP.filename} is ({TOP[acesImageContainerFlag]})\"", failureok=True) # should give 1

# Invalid channel name set
command += oiiotool("--create 4x4 3 -d half --compression none --ch left.R=R,G,B -sattrib openexr:ACESContainerPolicy relaxed -o fail.exr")
command += oiiotool("fail.exr --echo \"acesImageContainerFlag for {TOP.filename} is ({TOP[acesImageContainerFlag]})\"", failureok=True) # should be empty

# Invalid compression
command += oiiotool("--create 4x4 3 -d half --compression zip -sattrib openexr:ACESContainerPolicy relaxed -o fail.exr")
command += oiiotool("fail.exr --echo \"acesImageContainerFlag for {TOP.filename} is ({TOP[acesImageContainerFlag]})\"", failureok=True) # should be empty

# Invalid data type
command += oiiotool("--create 4x4 3 -d float --compression none -sattrib openexr:ACESContainerPolicy relaxed -o fail.exr")
command += oiiotool("fail.exr --echo \"acesImageContainerFlag for {TOP.filename} is ({TOP[acesImageContainerFlag]})\"", failureok=True) # should be empty

# Check ACES Container output for strict mode
#
# Valid ACES Container
command += oiiotool("--create 4x4 3 -d half --compression none -sattrib openexr:ACESContainerPolicy strict -o strict-out.exr")
command += info_command("strict-out.exr", safematch=True)

# Invalid channel name set
command += oiiotool("--create 4x4 3 -d half --compression none --ch left.R=R,G,B -sattrib openexr:ACESContainerPolicy strict -o strict-fail.exr", failureok=True)

# Invalid compression
command += oiiotool("--create 4x4 3 -d half --compression zip -sattrib openexr:ACESContainerPolicy strict -o strict-fail.exr", failureok=True)

# Invalid data type
command += oiiotool("--create 4x4 3 -d float --compression none -sattrib openexr:ACESContainerPolicy strict -o strict-fail.exr", failureok=True)
Loading