Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
114 changes: 112 additions & 2 deletions src/OpenColorIO/fileformats/ctf/CTFTransform.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <regex>

#include "BitDepthUtils.h"
#include "MathUtils.h"
#include "fileformats/ctf/CTFReaderUtils.h"
#include "fileformats/ctf/CTFTransform.h"
#include "fileformats/xmlutils/XMLReaderUtils.h"
Expand Down Expand Up @@ -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)
{
}

Expand All @@ -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;
Expand Down Expand Up @@ -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<RangeOpData>(0., 1., NOCLAMP_RANGE_MIN_OUT_VALUE, NOCLAMP_RANGE_MAX_OUT_VALUE)
: std::make_shared<RangeOpData>(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
Expand Down Expand Up @@ -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();
Expand Down
83 changes: 83 additions & 0 deletions tests/cpu/fileformats/FileFormatCTF_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<const OCIO::MatrixOpData>(opList[0]);
auto mat2 = OCIO::DynamicPtrCast<const OCIO::MatrixOpData>(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, "<Matrix"), 0);
OCIO_CHECK_EQUAL(countOccurrences(clfString, "<Range"), 2);
OCIO_CHECK_EQUAL(countOccurrences(clfString, "style=\"noClamp\""), 2);
OCIO_CHECK_EQUAL(countOccurrences(clfString, "<LUT3D"), 1);

// Reading the written file back should reproduce the original Matrix ops (within
// floating-point precision), confirming the round trip is lossless.
OCIO::LocalCachedFileRcPtr cachedFile2;
OCIO_CHECK_NO_THROW(cachedFile2 = ParseString(clfString));
OCIO_REQUIRE_ASSERT((bool)cachedFile2);

const OCIO::ConstOpDataVec & opList2 = cachedFile2->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<const OCIO::MatrixOpData>(opList2[0]);
auto mat2b = OCIO::DynamicPtrCast<const OCIO::MatrixOpData>(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");
Expand Down
136 changes: 136 additions & 0 deletions tests/cpu/fileformats/ctf/CTFTransform_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<OCIO::MatrixOpData>();
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::MatrixOpData>();
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");
}
}
Loading