From c142b37cd0326655ec800dac41dd10881e4e0cbb Mon Sep 17 00:00:00 2001 From: Doug Walker Date: Sun, 20 Sep 2026 22:57:44 -0400 Subject: [PATCH] Implement SMPTE CLF no-clamp Range writing Signed-off-by: Doug Walker --- .../fileformats/ctf/CTFTransform.cpp | 114 ++++++++++++++- tests/cpu/fileformats/FileFormatCTF_tests.cpp | 83 +++++++++++ .../fileformats/ctf/CTFTransform_tests.cpp | 136 ++++++++++++++++++ 3 files changed, 331 insertions(+), 2 deletions(-) diff --git a/src/OpenColorIO/fileformats/ctf/CTFTransform.cpp b/src/OpenColorIO/fileformats/ctf/CTFTransform.cpp index 1e693addc..73e45a090 100644 --- a/src/OpenColorIO/fileformats/ctf/CTFTransform.cpp +++ b/src/OpenColorIO/fileformats/ctf/CTFTransform.cpp @@ -5,6 +5,7 @@ #include #include "BitDepthUtils.h" +#include "MathUtils.h" #include "fileformats/ctf/CTFReaderUtils.h" #include "fileformats/ctf/CTFTransform.h" #include "fileformats/xmlutils/XMLReaderUtils.h" @@ -2528,22 +2529,27 @@ class RangeWriter : public OpWriter RangeWriter(const RangeWriter&) = delete; RangeWriter& operator=(const RangeWriter&) = delete; RangeWriter(XmlFormatter & formatter, - ConstRangeOpDataRcPtr range); + ConstRangeOpDataRcPtr range, + bool noClamp = false); virtual ~RangeWriter(); protected: ConstOpDataRcPtr getOp() const override; const char * getTagName() const override; + void getAttributes(XmlFormatter::Attributes & attributes) const override; void writeContent() const override; private: ConstRangeOpDataRcPtr m_range; + bool m_noClamp; }; RangeWriter::RangeWriter(XmlFormatter & formatter, - ConstRangeOpDataRcPtr range) + ConstRangeOpDataRcPtr range, + bool noClamp) : OpWriter(formatter) , m_range(range) + , m_noClamp(noClamp) { } @@ -2561,6 +2567,15 @@ const char * RangeWriter::getTagName() const return TAG_RANGE; } +void RangeWriter::getAttributes(XmlFormatter::Attributes & attributes) const +{ + OpWriter::getAttributes(attributes); + if (m_noClamp) + { + attributes.push_back(XmlFormatter::Attribute(ATTR_STYLE, "noClamp")); + } +} + void WriteTag(XmlFormatter & fmt, const char * tag, double value) { std::ostringstream o; @@ -2827,6 +2842,80 @@ BitDepth GetInputFileBD(ConstOpDataRcPtr op) return BIT_DEPTH_F32; } +// A Range with the noClamp style is read in as an equivalent Matrix since a RangeOpData +// always clamps (see RangeOpData::convertToMatrix). The SMPTE standard ST2136-10, the +// CLF Broadcast Profile, relies on writing that exact noClamp Range back out rather +// than a Matrix. Since a Matrix's scale & offset alone can't tell us what minIn/maxIn/ +// minOut/maxOut bounds the original Range used, we only recognize this one specific, +// fixed transform (and its exact inverse) rather than attempting a general reconstruction. + +// Set minOutValue/maxOutValue used by the SMPTE CLF Broadcast Profile signal-range brackets. +// See an example in tests/data/files/clf/smpte_only/broadcast_profile_lut33.clf. +// This is a 10-bit full-to-narrow range conversion on input and narrow-to-full on output. +constexpr double NOCLAMP_RANGE_MIN_OUT_VALUE = 64./1023.; // 0.06256109481915934 +constexpr double NOCLAMP_RANGE_MAX_OUT_VALUE = 940./1023.; // 0.91886608015640270 + +// Equivalent uniform scale/offset (out = in * scale + offset) for the "input" Range +// (minInValue=0, maxInValue=1, minOutValue/maxOutValue as above) and for the "output" +// Range, which is its exact algebraic inverse (minInValue/maxInValue as above, +// minOutValue=0, maxOutValue=1). +constexpr double NOCLAMP_INPUT_RANGE_SCALE = NOCLAMP_RANGE_MAX_OUT_VALUE - NOCLAMP_RANGE_MIN_OUT_VALUE; +constexpr double NOCLAMP_INPUT_RANGE_OFFSET = NOCLAMP_RANGE_MIN_OUT_VALUE; +constexpr double NOCLAMP_OUTPUT_RANGE_SCALE = 1.0 / NOCLAMP_INPUT_RANGE_SCALE; +constexpr double NOCLAMP_OUTPUT_RANGE_OFFSET = -NOCLAMP_RANGE_MIN_OUT_VALUE * NOCLAMP_OUTPUT_RANGE_SCALE; + +constexpr double NOCLAMP_RANGE_TOLERANCE = 1e-6; + +// Returns the equivalent noClamp Range for matSrc if, and only if, it is a forward, +// diagonal matrix applying the same scale & offset to R, G, and B as the fixed Range used +// by the SMPTE CLF Broadcast Profile (isInputRange selects which one of the pair, the +// input-side or the output-side, to compare against). Returns null otherwise, leaving +// the Matrix to be written as-is. +RangeOpDataRcPtr GetEquivalentNoClampRange(ConstMatrixOpDataRcPtr matSrc, bool isInputRange) +{ + if (matSrc->getDirection() != TRANSFORM_DIR_FORWARD || + matSrc->hasAlpha() || + !matSrc->isDiagonal()) + { + return RangeOpDataRcPtr(); + } + + // Matrix must scale R, G, and B by the same amount. + const double scale = matSrc->getArrayValue(0); + if (!EqualWithAbsError(scale, matSrc->getArrayValue(5), NOCLAMP_RANGE_TOLERANCE) || + !EqualWithAbsError(scale, matSrc->getArrayValue(10), NOCLAMP_RANGE_TOLERANCE)) + { + return RangeOpDataRcPtr(); + } + + // Matrix must offset R, G, and B by the same amount. + const double offset = matSrc->getOffsetValue(0); + if (!EqualWithAbsError(offset, matSrc->getOffsetValue(1), NOCLAMP_RANGE_TOLERANCE) || + !EqualWithAbsError(offset, matSrc->getOffsetValue(2), NOCLAMP_RANGE_TOLERANCE)) + { + return RangeOpDataRcPtr(); + } + + const double targetScale = isInputRange ? NOCLAMP_INPUT_RANGE_SCALE : NOCLAMP_OUTPUT_RANGE_SCALE; + const double targetOffset = isInputRange ? NOCLAMP_INPUT_RANGE_OFFSET : NOCLAMP_OUTPUT_RANGE_OFFSET; + + if (!EqualWithAbsError(scale, targetScale, NOCLAMP_RANGE_TOLERANCE) || + !EqualWithAbsError(offset, targetOffset, NOCLAMP_RANGE_TOLERANCE)) + { + return RangeOpDataRcPtr(); + } + + RangeOpDataRcPtr range = isInputRange + ? std::make_shared(0., 1., NOCLAMP_RANGE_MIN_OUT_VALUE, NOCLAMP_RANGE_MAX_OUT_VALUE) + : std::make_shared(NOCLAMP_RANGE_MIN_OUT_VALUE, NOCLAMP_RANGE_MAX_OUT_VALUE, 0., 1.); + + range->getFormatMetadata() = matSrc->getFormatMetadata(); + range->setFileInputBitDepth(matSrc->getFileInputBitDepth()); + range->setFileOutputBitDepth(matSrc->getFileOutputBitDepth()); + + return range; +} + } void TransformWriter::writeOps(const CTFVersion & version) const @@ -3078,6 +3167,27 @@ void TransformWriter::writeOps(const CTFVersion & version) const { ThrowWriteOp("Matrix with alpha component"); } + + // SMPTE CLF Broadcast Profile requires writing a noClamp Range at the + // start or end of a CLF when it is equivalent to a specific full/narrow + // scale and offset value. Only op[0] and the last op qualify. + if (i == 0 || i == numOps - 1) + { + const bool isInputRange = i == 0; + RangeOpDataRcPtr range = GetEquivalentNoClampRange(matSrc, isInputRange); + if (range) + { + outBD = GetValidatedFileBitDepth(range->getFileOutputBitDepth(), + OpData::RangeType); + + RangeWriter opWriter(m_formatter, range, /*noClamp=*/ true); + opWriter.setInputBitdepth(inBD); + opWriter.setOutputBitdepth(outBD); + + opWriter.write(); + break; + } + } } auto mat = matSrc->clone(); diff --git a/tests/cpu/fileformats/FileFormatCTF_tests.cpp b/tests/cpu/fileformats/FileFormatCTF_tests.cpp index e46555c74..ae136cd42 100644 --- a/tests/cpu/fileformats/FileFormatCTF_tests.cpp +++ b/tests/cpu/fileformats/FileFormatCTF_tests.cpp @@ -1608,6 +1608,89 @@ OCIO_ADD_TEST(FileFormatCTF, smpte_all_metadata) } } +OCIO_ADD_TEST(FileFormatCTF, smpte_broadcast_profile_noclamp_range) +{ + // Tests of the low-level GetEquivalentNoClampRange are in CTFTransform_tests.cpp. + + // The file starts and ends with a Range using the noClamp style, which OCIO reads in + // as an equivalent Matrix (a Range always clamps in OCIO, see + // RangeOpData::convertToMatrix). When writing CLF, a Matrix at the start or end of the + // op list that is equivalent to this specific noClamp Range must be written back out + // as that Range rather than as a Matrix. + const std::string ctfFile("clf/smpte_only/broadcast_profile_lut33.clf"); + + OCIO::LocalCachedFileRcPtr cachedFile; + OCIO_CHECK_NO_THROW(cachedFile = LoadCLFFile(ctfFile)); + OCIO_REQUIRE_ASSERT((bool)cachedFile); + + // On read, the noClamp Range ops are converted to Matrix ops. + const OCIO::ConstOpDataVec & opList = cachedFile->m_transform->getOpDataVec(); + OCIO_REQUIRE_EQUAL(opList.size(), 3); + OCIO_CHECK_EQUAL(opList[0]->getType(), OCIO::OpData::MatrixType); + OCIO_CHECK_EQUAL(opList[1]->getType(), OCIO::OpData::Lut3DType); + OCIO_CHECK_EQUAL(opList[2]->getType(), OCIO::OpData::MatrixType); + + auto mat0 = OCIO::DynamicPtrCast(opList[0]); + auto mat2 = OCIO::DynamicPtrCast(opList[2]); + OCIO_REQUIRE_ASSERT(mat0); + OCIO_REQUIRE_ASSERT(mat2); + + // Write the file back out as CLF. + + OCIO::ConstProcessorRcPtr processor; + OCIO_CHECK_NO_THROW(processor = OCIO::GetFileTransformProcessor(ctfFile)); + OCIO_REQUIRE_ASSERT(processor); + + std::ostringstream oss; + const auto group = processor->createGroupTransform(); + OCIO::ConstConfigRcPtr config = OCIO::Config::CreateRaw(); + OCIO_CHECK_NO_THROW(group->write(config, "Academy/ASC Common LUT Format", oss)); + const std::string clfString = oss.str(); + + // Count how many times sub appears in str. + auto countOccurrences = [](const std::string & str, const std::string & sub) -> size_t + { + size_t count = 0; + size_t pos = 0; + while ((pos = str.find(sub, pos)) != std::string::npos) + { + ++count; + pos += sub.length(); + } + return count; + }; + + // The Matrix ops at the start and end must have been written as noClamp Range ops + // rather than as Matrix ops. The LUT3D in between is untouched. + OCIO_CHECK_EQUAL(countOccurrences(clfString, "m_transform->getOpDataVec(); + OCIO_REQUIRE_EQUAL(opList2.size(), 3); + OCIO_CHECK_EQUAL(opList2[0]->getType(), OCIO::OpData::MatrixType); + OCIO_CHECK_EQUAL(opList2[1]->getType(), OCIO::OpData::Lut3DType); + OCIO_CHECK_EQUAL(opList2[2]->getType(), OCIO::OpData::MatrixType); + + auto mat0b = OCIO::DynamicPtrCast(opList2[0]); + auto mat2b = OCIO::DynamicPtrCast(opList2[2]); + OCIO_REQUIRE_ASSERT(mat0b); + OCIO_REQUIRE_ASSERT(mat2b); + + constexpr double error = 1e-9; + OCIO_CHECK_CLOSE(mat0b->getArrayValue(0), mat0->getArrayValue(0), error); + OCIO_CHECK_CLOSE(mat0b->getOffsetValue(0), mat0->getOffsetValue(0), error); + OCIO_CHECK_CLOSE(mat2b->getArrayValue(0), mat2->getArrayValue(0), error); + OCIO_CHECK_CLOSE(mat2b->getOffsetValue(0), mat2->getOffsetValue(0), error); +} + OCIO_ADD_TEST(FileFormatCTF, smpte_namespaces) { const std::string ctfFile("clf/smpte_only/namespaces.clf"); diff --git a/tests/cpu/fileformats/ctf/CTFTransform_tests.cpp b/tests/cpu/fileformats/ctf/CTFTransform_tests.cpp index f04d3155a..37b213a68 100644 --- a/tests/cpu/fileformats/ctf/CTFTransform_tests.cpp +++ b/tests/cpu/fileformats/ctf/CTFTransform_tests.cpp @@ -292,3 +292,139 @@ OCIO_ADD_TEST(CTFReaderTransform, accessors) OCIO_CHECK_EQUAL_STR(meta.getChildElement(6).getAttributeValue("language"), "tr"); } } + +OCIO_ADD_TEST(CTFTransform, get_equivalent_noclamp_range) +{ + // Integration test is in FileFormatCTF_tests.cpp smpte_broadcast_profile_noclamp_range. + + // GetEquivalentNoClampRange only recognizes a forward, diagonal Matrix whose scale and + // offset (applied uniformly to R, G, and B) exactly match (within tolerance) the fixed + // input-side or output-side Range used by the SMPTE CLF Broadcast Profile (see + // tests/data/files/clf/smpte_only/broadcast_profile_lut33.clf). Anything else must + // return null so the Matrix is written as-is. + + auto createDiagMatrix = [](double scale, double offset, + OCIO::TransformDirection dir = OCIO::TRANSFORM_DIR_FORWARD) + { + auto mat = std::make_shared(); + mat->setArrayValue(0, scale); + mat->setArrayValue(5, scale); + mat->setArrayValue(10, scale); + mat->setOffsetValue(0, offset); + mat->setOffsetValue(1, offset); + mat->setOffsetValue(2, offset); + mat->setDirection(dir); + return mat; + }; + + // Matches the "input" Range (minInValue=0, maxInValue=1) but not the "output" one. + { + auto mat = createDiagMatrix(OCIO::NOCLAMP_INPUT_RANGE_SCALE, + OCIO::NOCLAMP_INPUT_RANGE_OFFSET); + + OCIO::RangeOpDataRcPtr range; + OCIO_CHECK_NO_THROW(range = OCIO::GetEquivalentNoClampRange(mat, true)); + OCIO_REQUIRE_ASSERT(range); + OCIO_CHECK_EQUAL(range->getMinInValue(), 0.); + OCIO_CHECK_EQUAL(range->getMaxInValue(), 1.); + OCIO_CHECK_CLOSE(range->getMinOutValue(), OCIO::NOCLAMP_RANGE_MIN_OUT_VALUE, 1e-12); + OCIO_CHECK_CLOSE(range->getMaxOutValue(), OCIO::NOCLAMP_RANGE_MAX_OUT_VALUE, 1e-12); + + OCIO_CHECK_ASSERT(!OCIO::GetEquivalentNoClampRange(mat, false)); + } + + // Matches the "output" Range (minOutValue=0, maxOutValue=1) but not the "input" one. + { + auto mat = createDiagMatrix(OCIO::NOCLAMP_OUTPUT_RANGE_SCALE, + OCIO::NOCLAMP_OUTPUT_RANGE_OFFSET); + + OCIO::RangeOpDataRcPtr range; + OCIO_CHECK_NO_THROW(range = OCIO::GetEquivalentNoClampRange(mat, false)); + OCIO_REQUIRE_ASSERT(range); + OCIO_CHECK_CLOSE(range->getMinInValue(), OCIO::NOCLAMP_RANGE_MIN_OUT_VALUE, 1e-12); + OCIO_CHECK_CLOSE(range->getMaxInValue(), OCIO::NOCLAMP_RANGE_MAX_OUT_VALUE, 1e-12); + OCIO_CHECK_EQUAL(range->getMinOutValue(), 0.); + OCIO_CHECK_EQUAL(range->getMaxOutValue(), 1.); + + OCIO_CHECK_ASSERT(!OCIO::GetEquivalentNoClampRange(mat, true)); + } + + // Scale/offset within tolerance of the target still match. + { + auto mat = createDiagMatrix(OCIO::NOCLAMP_INPUT_RANGE_SCALE + 1e-7, + OCIO::NOCLAMP_INPUT_RANGE_OFFSET - 1e-7); + OCIO_CHECK_ASSERT(OCIO::GetEquivalentNoClampRange(mat, true)); + } + + // Scale/offset outside tolerance of the target do not match. + { + auto mat = createDiagMatrix(OCIO::NOCLAMP_INPUT_RANGE_SCALE + 1e-3, + OCIO::NOCLAMP_INPUT_RANGE_OFFSET); + OCIO_CHECK_ASSERT(!OCIO::GetEquivalentNoClampRange(mat, true)); + } + + // An unrelated diagonal matrix (identity) never matches. + { + auto mat = std::make_shared(); + OCIO_CHECK_ASSERT(!OCIO::GetEquivalentNoClampRange(mat, true)); + OCIO_CHECK_ASSERT(!OCIO::GetEquivalentNoClampRange(mat, false)); + } + + // An inverse-direction Matrix never matches, even with the right scale/offset. + { + auto mat = createDiagMatrix(OCIO::NOCLAMP_INPUT_RANGE_SCALE, + OCIO::NOCLAMP_INPUT_RANGE_OFFSET, + OCIO::TRANSFORM_DIR_INVERSE); + OCIO_CHECK_ASSERT(!OCIO::GetEquivalentNoClampRange(mat, true)); + } + + // A non-diagonal matrix never matches, even with the right values on the diagonal. + { + auto mat = createDiagMatrix(OCIO::NOCLAMP_INPUT_RANGE_SCALE, + OCIO::NOCLAMP_INPUT_RANGE_OFFSET); + mat->setArrayValue(1, 0.01); + OCIO_CHECK_ASSERT(!OCIO::GetEquivalentNoClampRange(mat, true)); + } + + // Non-uniform scale across R, G, and B never matches. + { + auto mat = createDiagMatrix(OCIO::NOCLAMP_INPUT_RANGE_SCALE, + OCIO::NOCLAMP_INPUT_RANGE_OFFSET); + mat->setArrayValue(5, OCIO::NOCLAMP_INPUT_RANGE_SCALE + 0.01); + OCIO_CHECK_ASSERT(!OCIO::GetEquivalentNoClampRange(mat, true)); + } + + // Non-uniform offset across R, G, and B never matches. + { + auto mat = createDiagMatrix(OCIO::NOCLAMP_INPUT_RANGE_SCALE, + OCIO::NOCLAMP_INPUT_RANGE_OFFSET); + mat->setOffsetValue(1, OCIO::NOCLAMP_INPUT_RANGE_OFFSET + 0.01); + OCIO_CHECK_ASSERT(!OCIO::GetEquivalentNoClampRange(mat, true)); + } + + // A Matrix with an alpha component never matches, even with matching R/G/B scale/offset. + { + auto mat = createDiagMatrix(OCIO::NOCLAMP_INPUT_RANGE_SCALE, + OCIO::NOCLAMP_INPUT_RANGE_OFFSET); + mat->setOffsetValue(3, 0.1); + OCIO_CHECK_ASSERT(!OCIO::GetEquivalentNoClampRange(mat, true)); + } + + // FormatMetadata and file bit-depths are carried over onto the returned Range. + { + auto mat = createDiagMatrix(OCIO::NOCLAMP_INPUT_RANGE_SCALE, + OCIO::NOCLAMP_INPUT_RANGE_OFFSET); + mat->getFormatMetadata().addChildElement(OCIO::METADATA_DESCRIPTION, "My Description"); + mat->setFileInputBitDepth(OCIO::BIT_DEPTH_UINT10); + mat->setFileOutputBitDepth(OCIO::BIT_DEPTH_UINT12); + + OCIO::RangeOpDataRcPtr range; + OCIO_CHECK_NO_THROW(range = OCIO::GetEquivalentNoClampRange(mat, true)); + OCIO_REQUIRE_ASSERT(range); + OCIO_CHECK_EQUAL(range->getFileInputBitDepth(), OCIO::BIT_DEPTH_UINT10); + OCIO_CHECK_EQUAL(range->getFileOutputBitDepth(), OCIO::BIT_DEPTH_UINT12); + OCIO_REQUIRE_EQUAL(range->getFormatMetadata().getNumChildrenElements(), 1); + OCIO_CHECK_EQUAL_STR(range->getFormatMetadata().getChildElement(0).getElementValue(), + "My Description"); + } +}