Use 10-bit packed preview format and add OCIO LUT regression tests
Renderer / preview: - Switch the preview/display readback format from F16 to packed 10-bit RGBA (PixelFormat::U10) to halve GPU->CPU/IPC bandwidth while keeping 10-bit panel precision. - Add U10 support to VideoParams, FFmpeg/OIIO/OCIO utility mappings, OpenGL (GL_RGB10_A2), Vulkan (VK_FORMAT_A2B10G10R10_UNORM_PACK32), and plugin bit-depth lookups. - Update preview autocacher comment to reflect the new behavior. OCIO LUT tests: - Add four E2E-style ColorLutNode gtests that drive a SolidGenerator -> OCIOLutNode graph through NodeTraverser and compare resulting pixels on the CPU. They cover forward/inverse transforms and verify that switching LUT direction and LUT file updates both the processor and the output pixels. Cleanup: - Remove a leftover SolidGenerator::Value debug fprintf. - Capture render worker stderr in RenderWorkerFootageTest for better diagnostics.
This commit is contained in:
@@ -11,6 +11,9 @@
|
||||
#include "node/color/ociolut/ociolut.h"
|
||||
#include "node/color/threewaycolor/threewaycolor.h"
|
||||
#include "node/factory.h"
|
||||
#include "node/generator/solid/solid.h"
|
||||
#include "node/project.h"
|
||||
#include "node/traverser.h"
|
||||
#include "render/colorprocessor.h"
|
||||
|
||||
namespace OCIO = OCIO_NAMESPACE;
|
||||
@@ -46,6 +49,117 @@ QString WriteTestCube(QTemporaryDir *dir)
|
||||
return path;
|
||||
}
|
||||
|
||||
// CPU-only traverser that resolves SolidGenerator and ColorTransformJob jobs
|
||||
// into real frames so we can compare pixels without a GPU/worker process.
|
||||
class PixelColorTransformTraverser : public olive::NodeTraverser {
|
||||
public:
|
||||
void Resolve(olive::NodeValue &value)
|
||||
{
|
||||
ResolveJobs(value);
|
||||
}
|
||||
|
||||
olive::FramePtr source_frame;
|
||||
olive::FramePtr output_frame;
|
||||
|
||||
protected:
|
||||
virtual void ProcessShader(olive::TexturePtr destination,
|
||||
const olive::Node *node,
|
||||
const olive::ShaderJob *job) override
|
||||
{
|
||||
Q_UNUSED(destination)
|
||||
Q_UNUSED(node)
|
||||
|
||||
const olive::Color c =
|
||||
job->GetValues().value(olive::SolidGenerator::kColorInput).toColor();
|
||||
|
||||
olive::VideoParams p = destination->params();
|
||||
p.set_format(olive::core::PixelFormat::F32);
|
||||
p.set_channel_count(olive::VideoParams::kRGBAChannelCount);
|
||||
|
||||
source_frame = olive::Frame::Create();
|
||||
source_frame->set_video_params(p);
|
||||
source_frame->allocate();
|
||||
|
||||
for (int y = 0; y < p.effective_height(); ++y) {
|
||||
for (int x = 0; x < p.effective_width(); ++x) {
|
||||
source_frame->set_pixel(x, y, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
virtual void ProcessColorTransform(olive::TexturePtr destination,
|
||||
const olive::Node *node,
|
||||
const olive::ColorTransformJob *job) override
|
||||
{
|
||||
Q_UNUSED(destination)
|
||||
Q_UNUSED(node)
|
||||
|
||||
olive::ColorProcessorPtr processor = job->GetColorProcessor();
|
||||
if (!processor || !source_frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
output_frame = olive::Frame::Create();
|
||||
output_frame->set_video_params(source_frame->video_params());
|
||||
output_frame->allocate();
|
||||
|
||||
std::memcpy(output_frame->data(), source_frame->const_data(),
|
||||
source_frame->allocated_size());
|
||||
|
||||
processor->ConvertFrame(output_frame);
|
||||
}
|
||||
};
|
||||
|
||||
QString WriteTestCubeLut(QTemporaryDir *dir, const char *title,
|
||||
float low, float high)
|
||||
{
|
||||
const QString path = QDir(dir->path()).filePath(
|
||||
QStringLiteral("%1.cube").arg(QString::fromUtf8(title)));
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QTextStream stream(&file);
|
||||
stream << "TITLE \"" << title << "\"\n";
|
||||
stream << "LUT_1D_SIZE 2\n";
|
||||
stream << "DOMAIN_MIN 0.0 0.0 0.0\n";
|
||||
stream << "DOMAIN_MAX 1.0 1.0 1.0\n";
|
||||
stream << high << " " << high << " " << high << "\n";
|
||||
stream << low << " " << low << " " << low << "\n";
|
||||
file.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
// Non-symmetric 1D LUT so forward and inverse produce different, predictable
|
||||
// results. Table:
|
||||
// 0.0 -> 0.00
|
||||
// 0.5 -> 0.75
|
||||
// 1.0 -> 1.00
|
||||
// Forward: (0.25, 0.50, 0.75) -> (0.375, 0.750, 0.875)
|
||||
// Inverse: (0.25, 0.50, 0.75) -> (0.167, 0.333, 0.500)
|
||||
QString WriteAsymmetricCube(QTemporaryDir *dir)
|
||||
{
|
||||
const QString path =
|
||||
QDir(dir->path()).filePath(QStringLiteral("asymmetric.cube"));
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
const QByteArray data =
|
||||
"TITLE \"Oak test asymmetric\"\n"
|
||||
"LUT_1D_SIZE 3\n"
|
||||
"DOMAIN_MIN 0.0 0.0 0.0\n"
|
||||
"DOMAIN_MAX 1.0 1.0 1.0\n"
|
||||
"0.00 0.00 0.00\n"
|
||||
"0.75 0.75 0.75\n"
|
||||
"1.00 1.00 1.00\n";
|
||||
file.write(data);
|
||||
file.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ColorLut, OcioSupportsCubeAnd3dlExtensions)
|
||||
@@ -111,3 +225,263 @@ TEST(ColorV04, FactoryCreatesColorNodes)
|
||||
EXPECT_FLOAT_EQ(neutral.blue(), 0.5f);
|
||||
EXPECT_FLOAT_EQ(neutral.alpha(), 1.0f);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// E2E-style regression tests for OCIOLutNode.
|
||||
//
|
||||
// These tests build a tiny node graph (SolidGenerator -> OCIOLutNode), drive it
|
||||
// through NodeTraverser, and compare the resulting pixels. They specifically
|
||||
// exercise the previously broken paths:
|
||||
// * OCIOLutNode::Value() must ensure the processor exists before rendering.
|
||||
// * Switching the LUT direction must update the processor and change pixels.
|
||||
// * Switching the LUT file must update the processor and change pixels.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
TEST(ColorLutNode, ForwardDirectionInvertsPixels)
|
||||
{
|
||||
olive::ColorManager::SetUpDefaultConfig();
|
||||
|
||||
olive::Project project;
|
||||
project.Initialize();
|
||||
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = WriteTestCubeLut(&dir, "invert", 0.0f, 1.0f);
|
||||
ASSERT_FALSE(path.isEmpty());
|
||||
|
||||
auto *solid = new olive::SolidGenerator();
|
||||
solid->setParent(&project);
|
||||
solid->SetStandardValue(
|
||||
olive::SolidGenerator::kColorInput,
|
||||
QVariant::fromValue(olive::Color(0.25f, 0.50f, 0.75f, 1.0f)));
|
||||
|
||||
auto *lut = new olive::OCIOLutNode();
|
||||
lut->setParent(&project);
|
||||
lut->SetStandardValue(olive::OCIOLutNode::kFileInput, path);
|
||||
lut->SetStandardValue(olive::OCIOLutNode::kDirectionInput, 0); // Forward
|
||||
|
||||
olive::Node::ConnectEdge(
|
||||
solid, olive::NodeInput(lut, olive::OCIOLutNode::kTextureInput));
|
||||
|
||||
const olive::VideoParams params(
|
||||
16, 16, olive::core::PixelFormat::F32,
|
||||
olive::VideoParams::kRGBAChannelCount);
|
||||
|
||||
PixelColorTransformTraverser traverser;
|
||||
traverser.SetCacheVideoParams(params);
|
||||
|
||||
olive::NodeValueTable table = traverser.GenerateTable(
|
||||
lut, olive::TimeRange(olive::core::rational(0),
|
||||
olive::core::rational(1, 30)));
|
||||
olive::NodeValue tex_val = table.Get(olive::NodeValue::kTexture);
|
||||
traverser.Resolve(tex_val);
|
||||
|
||||
ASSERT_TRUE(traverser.output_frame);
|
||||
const olive::Color out = traverser.output_frame->get_pixel(0, 0);
|
||||
|
||||
EXPECT_NEAR(out.red(), 0.75f, 0.02f);
|
||||
EXPECT_NEAR(out.green(), 0.50f, 0.02f);
|
||||
EXPECT_NEAR(out.blue(), 0.25f, 0.02f);
|
||||
EXPECT_NEAR(out.alpha(), 1.0f, 0.001f);
|
||||
}
|
||||
|
||||
TEST(ColorLutNode, InverseDirectionReversesForwardTransform)
|
||||
{
|
||||
olive::ColorManager::SetUpDefaultConfig();
|
||||
|
||||
olive::Project project;
|
||||
project.Initialize();
|
||||
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = WriteTestCubeLut(&dir, "invert", 0.0f, 1.0f);
|
||||
ASSERT_FALSE(path.isEmpty());
|
||||
|
||||
auto *solid = new olive::SolidGenerator();
|
||||
solid->setParent(&project);
|
||||
solid->SetStandardValue(
|
||||
olive::SolidGenerator::kColorInput,
|
||||
QVariant::fromValue(olive::Color(0.75f, 0.50f, 0.25f, 1.0f)));
|
||||
|
||||
auto *lut = new olive::OCIOLutNode();
|
||||
lut->setParent(&project);
|
||||
lut->SetStandardValue(olive::OCIOLutNode::kFileInput, path);
|
||||
lut->SetStandardValue(olive::OCIOLutNode::kDirectionInput, 1); // Inverse
|
||||
|
||||
olive::Node::ConnectEdge(
|
||||
solid, olive::NodeInput(lut, olive::OCIOLutNode::kTextureInput));
|
||||
|
||||
const olive::VideoParams params(
|
||||
16, 16, olive::core::PixelFormat::F32,
|
||||
olive::VideoParams::kRGBAChannelCount);
|
||||
|
||||
PixelColorTransformTraverser traverser;
|
||||
traverser.SetCacheVideoParams(params);
|
||||
|
||||
olive::NodeValueTable table = traverser.GenerateTable(
|
||||
lut, olive::TimeRange(olive::core::rational(0),
|
||||
olive::core::rational(1, 30)));
|
||||
olive::NodeValue tex_val = table.Get(olive::NodeValue::kTexture);
|
||||
traverser.Resolve(tex_val);
|
||||
|
||||
ASSERT_TRUE(traverser.output_frame);
|
||||
const olive::Color out = traverser.output_frame->get_pixel(0, 0);
|
||||
|
||||
EXPECT_NEAR(out.red(), 0.25f, 0.02f);
|
||||
EXPECT_NEAR(out.green(), 0.50f, 0.02f);
|
||||
EXPECT_NEAR(out.blue(), 0.75f, 0.02f);
|
||||
EXPECT_NEAR(out.alpha(), 1.0f, 0.001f);
|
||||
}
|
||||
|
||||
TEST(ColorLutNode, SwitchingDirectionUpdatesProcessorAndPixels)
|
||||
{
|
||||
olive::ColorManager::SetUpDefaultConfig();
|
||||
|
||||
olive::Project project;
|
||||
project.Initialize();
|
||||
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = WriteAsymmetricCube(&dir);
|
||||
ASSERT_FALSE(path.isEmpty());
|
||||
|
||||
auto *solid = new olive::SolidGenerator();
|
||||
solid->setParent(&project);
|
||||
solid->SetStandardValue(
|
||||
olive::SolidGenerator::kColorInput,
|
||||
QVariant::fromValue(olive::Color(0.25f, 0.50f, 0.75f, 1.0f)));
|
||||
|
||||
auto *lut = new olive::OCIOLutNode();
|
||||
lut->setParent(&project);
|
||||
lut->SetStandardValue(olive::OCIOLutNode::kFileInput, path);
|
||||
lut->SetStandardValue(olive::OCIOLutNode::kDirectionInput, 0); // Forward
|
||||
|
||||
olive::Node::ConnectEdge(
|
||||
solid, olive::NodeInput(lut, olive::OCIOLutNode::kTextureInput));
|
||||
|
||||
const olive::VideoParams params(
|
||||
16, 16, olive::core::PixelFormat::F32,
|
||||
olive::VideoParams::kRGBAChannelCount);
|
||||
|
||||
// First render: forward direction.
|
||||
PixelColorTransformTraverser forward_traverser;
|
||||
forward_traverser.SetCacheVideoParams(params);
|
||||
olive::NodeValueTable forward_table = forward_traverser.GenerateTable(
|
||||
lut, olive::TimeRange(olive::core::rational(0),
|
||||
olive::core::rational(1, 30)));
|
||||
olive::NodeValue forward_tex =
|
||||
forward_table.Get(olive::NodeValue::kTexture);
|
||||
forward_traverser.Resolve(forward_tex);
|
||||
|
||||
ASSERT_TRUE(forward_traverser.output_frame);
|
||||
const olive::Color forward_out =
|
||||
forward_traverser.output_frame->get_pixel(0, 0);
|
||||
EXPECT_NEAR(forward_out.red(), 0.375f, 0.02f);
|
||||
EXPECT_NEAR(forward_out.green(), 0.750f, 0.02f);
|
||||
EXPECT_NEAR(forward_out.blue(), 0.875f, 0.02f);
|
||||
|
||||
// Switch direction. Before the Value()/EnsureProcessor() fix, the node
|
||||
// would keep using the old forward processor.
|
||||
lut->SetStandardValue(olive::OCIOLutNode::kDirectionInput, 1); // Inverse
|
||||
|
||||
PixelColorTransformTraverser inverse_traverser;
|
||||
inverse_traverser.SetCacheVideoParams(params);
|
||||
olive::NodeValueTable inverse_table = inverse_traverser.GenerateTable(
|
||||
lut, olive::TimeRange(olive::core::rational(0),
|
||||
olive::core::rational(1, 30)));
|
||||
olive::NodeValue inverse_tex =
|
||||
inverse_table.Get(olive::NodeValue::kTexture);
|
||||
inverse_traverser.Resolve(inverse_tex);
|
||||
|
||||
ASSERT_TRUE(inverse_traverser.output_frame);
|
||||
const olive::Color inverse_out =
|
||||
inverse_traverser.output_frame->get_pixel(0, 0);
|
||||
|
||||
// Inverse of the asymmetric LUT moves the same color to a darker result.
|
||||
EXPECT_NEAR(inverse_out.red(), 0.167f, 0.02f);
|
||||
EXPECT_NEAR(inverse_out.green(), 0.333f, 0.02f);
|
||||
EXPECT_NEAR(inverse_out.blue(), 0.500f, 0.02f);
|
||||
|
||||
// The two outputs must differ from each other.
|
||||
EXPECT_GT(std::abs(forward_out.red() - inverse_out.red()), 0.1f);
|
||||
EXPECT_GT(std::abs(forward_out.green() - inverse_out.green()), 0.1f);
|
||||
EXPECT_GT(std::abs(forward_out.blue() - inverse_out.blue()), 0.1f);
|
||||
}
|
||||
|
||||
TEST(ColorLutNode, SwitchingFileUpdatesProcessorAndPixels)
|
||||
{
|
||||
olive::ColorManager::SetUpDefaultConfig();
|
||||
|
||||
olive::Project project;
|
||||
project.Initialize();
|
||||
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
|
||||
const QString invert_path = WriteTestCubeLut(
|
||||
&dir, "invert", 0.0f, 1.0f);
|
||||
const QString boost_path = WriteTestCubeLut(
|
||||
&dir, "boost", 0.5f, 1.0f);
|
||||
ASSERT_FALSE(invert_path.isEmpty());
|
||||
ASSERT_FALSE(boost_path.isEmpty());
|
||||
|
||||
auto *solid = new olive::SolidGenerator();
|
||||
solid->setParent(&project);
|
||||
solid->SetStandardValue(
|
||||
olive::SolidGenerator::kColorInput,
|
||||
QVariant::fromValue(olive::Color(0.25f, 0.50f, 0.75f, 1.0f)));
|
||||
|
||||
auto *lut = new olive::OCIOLutNode();
|
||||
lut->setParent(&project);
|
||||
lut->SetStandardValue(olive::OCIOLutNode::kFileInput, invert_path);
|
||||
lut->SetStandardValue(olive::OCIOLutNode::kDirectionInput, 0); // Forward
|
||||
|
||||
olive::Node::ConnectEdge(
|
||||
solid, olive::NodeInput(lut, olive::OCIOLutNode::kTextureInput));
|
||||
|
||||
const olive::VideoParams params(
|
||||
16, 16, olive::core::PixelFormat::F32,
|
||||
olive::VideoParams::kRGBAChannelCount);
|
||||
|
||||
// Render with invert LUT.
|
||||
PixelColorTransformTraverser invert_traverser;
|
||||
invert_traverser.SetCacheVideoParams(params);
|
||||
olive::NodeValueTable invert_table = invert_traverser.GenerateTable(
|
||||
lut, olive::TimeRange(olive::core::rational(0),
|
||||
olive::core::rational(1, 30)));
|
||||
olive::NodeValue invert_tex = invert_table.Get(olive::NodeValue::kTexture);
|
||||
invert_traverser.Resolve(invert_tex);
|
||||
|
||||
ASSERT_TRUE(invert_traverser.output_frame);
|
||||
const olive::Color invert_out =
|
||||
invert_traverser.output_frame->get_pixel(0, 0);
|
||||
EXPECT_NEAR(invert_out.red(), 0.75f, 0.02f);
|
||||
|
||||
// Switch to a different LUT file. Before the fix, the node could keep the
|
||||
// old invert processor cached and the output would not change.
|
||||
lut->SetStandardValue(olive::OCIOLutNode::kFileInput, boost_path);
|
||||
|
||||
PixelColorTransformTraverser boost_traverser;
|
||||
boost_traverser.SetCacheVideoParams(params);
|
||||
olive::NodeValueTable boost_table = boost_traverser.GenerateTable(
|
||||
lut, olive::TimeRange(olive::core::rational(0),
|
||||
olive::core::rational(1, 30)));
|
||||
olive::NodeValue boost_tex = boost_table.Get(olive::NodeValue::kTexture);
|
||||
boost_traverser.Resolve(boost_tex);
|
||||
|
||||
ASSERT_TRUE(boost_traverser.output_frame);
|
||||
const olive::Color boost_out =
|
||||
boost_traverser.output_frame->get_pixel(0, 0);
|
||||
|
||||
// boost LUT maps:
|
||||
// 0.0 -> 1.0, 1.0 -> 0.5 (linear: f(x) = 1 - 0.5*x)
|
||||
// so (0.25, 0.50, 0.75) -> (0.875, 0.750, 0.625).
|
||||
EXPECT_NEAR(boost_out.red(), 0.875f, 0.02f);
|
||||
EXPECT_NEAR(boost_out.green(), 0.750f, 0.02f);
|
||||
EXPECT_NEAR(boost_out.blue(), 0.625f, 0.02f);
|
||||
|
||||
// The two outputs must differ (boost raises all channels vs invert).
|
||||
EXPECT_GT(std::abs(boost_out.red() - invert_out.red()), 0.1f);
|
||||
EXPECT_GT(std::abs(boost_out.green() - invert_out.green()), 0.1f);
|
||||
EXPECT_GT(std::abs(boost_out.blue() - invert_out.blue()), 0.1f);
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ protected:
|
||||
}
|
||||
|
||||
// ---- spawn worker ----
|
||||
worker_.setProcessChannelMode(QProcess::ForwardedErrorChannel);
|
||||
worker_.setProcessChannelMode(QProcess::SeparateChannels);
|
||||
worker_.start(worker_path_, QStringList{QStringLiteral("--backend"), backend});
|
||||
if (!worker_.waitForStarted(kTimeoutMs)) {
|
||||
return false;
|
||||
@@ -362,6 +362,13 @@ protected:
|
||||
if (worker_.state() == QProcess::NotRunning) {
|
||||
std::cerr << "WaitForMessage: worker exited with code "
|
||||
<< worker_.exitCode() << std::endl;
|
||||
std::cerr << "Worker stdout buffer: "
|
||||
<< read_buffer_.toStdString() << std::endl;
|
||||
QByteArray err = worker_.readAllStandardError();
|
||||
if (!err.isEmpty()) {
|
||||
std::cerr << "Worker stderr:\n"
|
||||
<< err.toStdString() << std::endl;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user