Merge branch 'develop' into lens-flare

This commit is contained in:
Jonathan Thomas
2025-05-28 20:34:51 -05:00
10 changed files with 36574 additions and 2 deletions

View File

@@ -166,6 +166,7 @@
%include "effects/Brightness.h"
%include "effects/Caption.h"
%include "effects/ChromaKey.h"
%include "effects/ColorMap.h"
%include "effects/ColorShift.h"
%include "effects/Crop.h"
%include "effects/Deinterlace.h"
@@ -175,7 +176,13 @@
%include "effects/Negate.h"
%include "effects/Pixelate.h"
%include "effects/Saturation.h"
%include "effects/Sharpen.h"
%include "effects/Shift.h"
%include "effects/SphericalProjection.cpp"
%include "effects/Wave.h"
#ifdef USE_OPENCV
%include "effects/Stabilizer.h"
%include "effects/Tracker.h"
%include "effects/ObjectDetection.h"
%include "effects/Outline.h"
#endif

View File

@@ -338,6 +338,7 @@
%include "effects/Brightness.h"
%include "effects/Caption.h"
%include "effects/ChromaKey.h"
%include "effects/ColorMap.h"
%include "effects/ColorShift.h"
%include "effects/Crop.h"
%include "effects/Deinterlace.h"
@@ -347,7 +348,9 @@
%include "effects/Negate.h"
%include "effects/Pixelate.h"
%include "effects/Saturation.h"
%include "effects/Sharpen.h"
%include "effects/Shift.h"
%include "effects/SphericalProjection.cpp"
%include "effects/Wave.h"
#ifdef USE_OPENCV
%include "effects/Stabilizer.h"

35940
examples/example-lut.cube Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -111,6 +111,7 @@ set(EFFECTS_SOURCES
effects/Brightness.cpp
effects/Caption.cpp
effects/ChromaKey.cpp
effects/ColorMap.cpp
effects/ColorShift.cpp
effects/Crop.cpp
effects/Deinterlace.cpp

View File

@@ -40,6 +40,9 @@ EffectBase* EffectInfo::CreateEffect(std::string effect_type) {
else if (effect_type == "ChromaKey")
return new ChromaKey();
else if (effect_type == "ColorMap")
return new ColorMap();
else if (effect_type == "ColorShift")
return new ColorShift();
@@ -135,6 +138,7 @@ Json::Value EffectInfo::JsonValue() {
root.append(Brightness().JsonInfo());
root.append(Caption().JsonInfo());
root.append(ChromaKey().JsonInfo());
root.append(ColorMap().JsonInfo());
root.append(ColorShift().JsonInfo());
root.append(Crop().JsonInfo());
root.append(Deinterlace().JsonInfo());

View File

@@ -19,6 +19,7 @@
#include "effects/Brightness.h"
#include "effects/Caption.h"
#include "effects/ChromaKey.h"
#include "effects/ColorMap.h"
#include "effects/ColorShift.h"
#include "effects/Crop.h"
#include "effects/Deinterlace.h"

307
src/effects/ColorMap.cpp Normal file
View File

@@ -0,0 +1,307 @@
/**
* @file
* @brief Source file for ColorMap (LUT) effect
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @ref License
*/
// Copyright (c) 2008-2025 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include "ColorMap.h"
#include "Exceptions.h"
#include <omp.h>
#include <QRegularExpression>
using namespace openshot;
void ColorMap::load_cube_file()
{
if (lut_path.empty()) {
lut_data.clear();
lut_size = 0;
needs_refresh = false;
return;
}
int parsed_size = 0;
std::vector<float> parsed_data;
#pragma omp critical(load_lut)
{
QFile file(QString::fromStdString(lut_path));
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
// leave parsed_size == 0
} else {
QTextStream in(&file);
QString line;
QRegularExpression ws_re("\\s+");
// 1) Find LUT_3D_SIZE
while (!in.atEnd()) {
line = in.readLine().trimmed();
if (line.startsWith("LUT_3D_SIZE")) {
auto parts = line.split(ws_re);
if (parts.size() >= 2) {
parsed_size = parts[1].toInt();
}
break;
}
}
// 2) Read N³ lines of R G B floats
if (parsed_size > 0) {
int total = parsed_size * parsed_size * parsed_size;
parsed_data.reserve(size_t(total * 3));
while (!in.atEnd() && int(parsed_data.size()) < total * 3) {
line = in.readLine().trimmed();
if (line.isEmpty() ||
line.startsWith("#") ||
line.startsWith("TITLE") ||
line.startsWith("DOMAIN"))
{
continue;
}
auto vals = line.split(ws_re);
if (vals.size() >= 3) {
// .cube file is R G B
parsed_data.push_back(vals[0].toFloat());
parsed_data.push_back(vals[1].toFloat());
parsed_data.push_back(vals[2].toFloat());
}
}
if (int(parsed_data.size()) != total * 3) {
parsed_data.clear();
parsed_size = 0;
}
}
}
}
if (parsed_size > 0) {
lut_size = parsed_size;
lut_data.swap(parsed_data);
} else {
lut_data.clear();
lut_size = 0;
}
needs_refresh = false;
}
void ColorMap::init_effect_details()
{
InitEffectInfo();
info.class_name = "ColorMap";
info.name = "Color Map / Lookup";
info.description = "Adjust colors using 3D LUT lookup tables (.cube format)";
info.has_video = true;
info.has_audio = false;
}
ColorMap::ColorMap()
: lut_path(""), lut_size(0), needs_refresh(true),
intensity(1.0), intensity_r(1.0), intensity_g(1.0), intensity_b(1.0)
{
init_effect_details();
load_cube_file();
}
ColorMap::ColorMap(const std::string &path,
const Keyframe &i,
const Keyframe &iR,
const Keyframe &iG,
const Keyframe &iB)
: lut_path(path),
lut_size(0),
needs_refresh(true),
intensity(i),
intensity_r(iR),
intensity_g(iG),
intensity_b(iB)
{
init_effect_details();
load_cube_file();
}
std::shared_ptr<openshot::Frame>
ColorMap::GetFrame(std::shared_ptr<openshot::Frame> frame, int64_t frame_number)
{
// Reload LUT when its path changed; no locking here
if (needs_refresh) {
load_cube_file();
needs_refresh = false;
}
if (lut_data.empty())
return frame;
auto image = frame->GetImage();
int w = image->width(), h = image->height();
unsigned char *pixels = image->bits();
float overall = float(intensity.GetValue(frame_number));
float tR = float(intensity_r.GetValue(frame_number)) * overall;
float tG = float(intensity_g.GetValue(frame_number)) * overall;
float tB = float(intensity_b.GetValue(frame_number)) * overall;
int pixel_count = w * h;
#pragma omp parallel for
for (int i = 0; i < pixel_count; ++i) {
int idx = i * 4;
int A = pixels[idx + 3];
float alpha = A / 255.0f;
if (alpha == 0.0f) continue;
// demultiply premultiplied RGBA
float R = pixels[idx + 0] / alpha;
float G = pixels[idx + 1] / alpha;
float B = pixels[idx + 2] / alpha;
// normalize to [0,1]
float Rn = R * (1.0f / 255.0f);
float Gn = G * (1.0f / 255.0f);
float Bn = B * (1.0f / 255.0f);
// map into LUT space [0 .. size-1]
float rf = Rn * (lut_size - 1);
float gf = Gn * (lut_size - 1);
float bf = Bn * (lut_size - 1);
int r0 = int(floor(rf)), r1 = std::min(r0 + 1, lut_size - 1);
int g0 = int(floor(gf)), g1 = std::min(g0 + 1, lut_size - 1);
int b0 = int(floor(bf)), b1 = std::min(b0 + 1, lut_size - 1);
float dr = rf - r0;
float dg = gf - g0;
float db = bf - b0;
// compute base offsets with red fastest, then green, then blue
int base000 = ((b0 * lut_size + g0) * lut_size + r0) * 3;
int base100 = ((b0 * lut_size + g0) * lut_size + r1) * 3;
int base010 = ((b0 * lut_size + g1) * lut_size + r0) * 3;
int base110 = ((b0 * lut_size + g1) * lut_size + r1) * 3;
int base001 = ((b1 * lut_size + g0) * lut_size + r0) * 3;
int base101 = ((b1 * lut_size + g0) * lut_size + r1) * 3;
int base011 = ((b1 * lut_size + g1) * lut_size + r0) * 3;
int base111 = ((b1 * lut_size + g1) * lut_size + r1) * 3;
// trilinear interpolation
// red
float c00 = lut_data[base000 + 0] * (1 - dr) + lut_data[base100 + 0] * dr;
float c01 = lut_data[base001 + 0] * (1 - dr) + lut_data[base101 + 0] * dr;
float c10 = lut_data[base010 + 0] * (1 - dr) + lut_data[base110 + 0] * dr;
float c11 = lut_data[base011 + 0] * (1 - dr) + lut_data[base111 + 0] * dr;
float c0 = c00 * (1 - dg) + c10 * dg;
float c1 = c01 * (1 - dg) + c11 * dg;
float lr = c0 * (1 - db) + c1 * db;
// green
c00 = lut_data[base000 + 1] * (1 - dr) + lut_data[base100 + 1] * dr;
c01 = lut_data[base001 + 1] * (1 - dr) + lut_data[base101 + 1] * dr;
c10 = lut_data[base010 + 1] * (1 - dr) + lut_data[base110 + 1] * dr;
c11 = lut_data[base011 + 1] * (1 - dr) + lut_data[base111 + 1] * dr;
c0 = c00 * (1 - dg) + c10 * dg;
c1 = c01 * (1 - dg) + c11 * dg;
float lg = c0 * (1 - db) + c1 * db;
// blue
c00 = lut_data[base000 + 2] * (1 - dr) + lut_data[base100 + 2] * dr;
c01 = lut_data[base001 + 2] * (1 - dr) + lut_data[base101 + 2] * dr;
c10 = lut_data[base010 + 2] * (1 - dr) + lut_data[base110 + 2] * dr;
c11 = lut_data[base011 + 2] * (1 - dr) + lut_data[base111 + 2] * dr;
c0 = c00 * (1 - dg) + c10 * dg;
c1 = c01 * (1 - dg) + c11 * dg;
float lb = c0 * (1 - db) + c1 * db;
// blend per-channel, re-premultiply alpha
float outR = (lr * tR + Rn * (1 - tR)) * alpha;
float outG = (lg * tG + Gn * (1 - tG)) * alpha;
float outB = (lb * tB + Bn * (1 - tB)) * alpha;
pixels[idx + 0] = constrain(outR * 255.0f);
pixels[idx + 1] = constrain(outG * 255.0f);
pixels[idx + 2] = constrain(outB * 255.0f);
// alpha left unchanged
}
return frame;
}
std::string ColorMap::Json() const
{
return JsonValue().toStyledString();
}
Json::Value ColorMap::JsonValue() const
{
Json::Value root = EffectBase::JsonValue();
root["type"] = info.class_name;
root["lut_path"] = lut_path;
root["intensity"] = intensity.JsonValue();
root["intensity_r"] = intensity_r.JsonValue();
root["intensity_g"] = intensity_g.JsonValue();
root["intensity_b"] = intensity_b.JsonValue();
return root;
}
void ColorMap::SetJson(const std::string value)
{
try {
const Json::Value root = openshot::stringToJson(value);
SetJsonValue(root);
}
catch (...) {
throw InvalidJSON("Invalid JSON for ColorMap effect");
}
}
void ColorMap::SetJsonValue(const Json::Value root)
{
EffectBase::SetJsonValue(root);
if (!root["lut_path"].isNull())
{
lut_path = root["lut_path"].asString();
needs_refresh = true;
}
if (!root["intensity"].isNull())
intensity.SetJsonValue(root["intensity"]);
if (!root["intensity_r"].isNull())
intensity_r.SetJsonValue(root["intensity_r"]);
if (!root["intensity_g"].isNull())
intensity_g.SetJsonValue(root["intensity_g"]);
if (!root["intensity_b"].isNull())
intensity_b.SetJsonValue(root["intensity_b"]);
}
std::string ColorMap::PropertiesJSON(int64_t requested_frame) const
{
Json::Value root = BasePropertiesJSON(requested_frame);
root["lut_path"] = add_property_json(
"LUT File", 0.0, "string", lut_path, nullptr, 0, 0, false, requested_frame);
root["intensity"] = add_property_json(
"Overall Intensity",
intensity.GetValue(requested_frame),
"float", "", &intensity, 0.0, 1.0, false, requested_frame);
root["intensity_r"] = add_property_json(
"Red Intensity",
intensity_r.GetValue(requested_frame),
"float", "", &intensity_r, 0.0, 1.0, false, requested_frame);
root["intensity_g"] = add_property_json(
"Green Intensity",
intensity_g.GetValue(requested_frame),
"float", "", &intensity_g, 0.0, 1.0, false, requested_frame);
root["intensity_b"] = add_property_json(
"Blue Intensity",
intensity_b.GetValue(requested_frame),
"float", "", &intensity_b, 0.0, 1.0, false, requested_frame);
return root.toStyledString();
}

94
src/effects/ColorMap.h Normal file
View File

@@ -0,0 +1,94 @@
/**
* @file
* @brief Header file for ColorMap (LUT) effect
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @ref License
*/
// Copyright (c) 2008-2025 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#ifndef OPENSHOT_COLORMAP_EFFECT_H
#define OPENSHOT_COLORMAP_EFFECT_H
#include "../EffectBase.h"
#include "../Json.h"
#include "../KeyFrame.h"
#include <QString>
#include <QFile>
#include <QTextStream>
#include <vector>
#include <string>
namespace openshot
{
/**
* @brief Applies a 3D LUT (.cube) color transform to each frame.
*
* Loads a .cube file (LUT_3D_SIZE N × N × N) into memory, then for each pixel
* uses nearestneighbor lookup and blends the result by keyframable perchannel intensities.
*/
class ColorMap : public EffectBase
{
private:
std::string lut_path; ///< Filesystem path to .cube LUT file
int lut_size; ///< Dimension N of the cube (LUT_3D_SIZE)
std::vector<float> lut_data; ///< Flat array [N³ × 3] RGB lookup table
bool needs_refresh; ///< Reload LUT on next frame
/// Populate info fields (class_name, name, description)
void init_effect_details();
/// Parse the .cube file into lut_size & lut_data
void load_cube_file();
public:
Keyframe intensity; ///< Overall intensity 01 (affects all channels)
Keyframe intensity_r; ///< Blend 01 for red channel
Keyframe intensity_g; ///< Blend 01 for green channel
Keyframe intensity_b; ///< Blend 01 for blue channel
/// Blank constructor (used by JSON loader)
ColorMap();
/**
* @brief Constructor with LUT path and perchannel intensities
*
* @param path Filesystem path to .cube file
* @param i Keyframe for overall intensity (01)
* @param iR Keyframe for red blend (01)
* @param iG Keyframe for green blend (01)
* @param iB Keyframe for blue blend (01)
*/
ColorMap(const std::string &path,
const Keyframe &i = Keyframe(1.0),
const Keyframe &iR = Keyframe(1.0),
const Keyframe &iG = Keyframe(1.0),
const Keyframe &iB = Keyframe(1.0));
/// Apply effect to a new frame
std::shared_ptr<openshot::Frame>
GetFrame(int64_t frame_number) override
{ return GetFrame(std::make_shared<openshot::Frame>(), frame_number); }
/// Apply effect to an existing frame
std::shared_ptr<openshot::Frame>
GetFrame(std::shared_ptr<openshot::Frame> frame,
int64_t frame_number) override;
// JSON serialization
std::string Json() const override;
Json::Value JsonValue() const override;
void SetJson(const std::string value) override;
void SetJsonValue(const Json::Value root) override;
/// Expose properties (for UI)
std::string PropertiesJSON(int64_t requested_frame) const override;
};
} // namespace openshot
#endif // OPENSHOT_COLORMAP_EFFECT_H

View File

@@ -43,6 +43,7 @@ set(OPENSHOT_TESTS
SphericalMetadata
Timeline
# Effects
ColorMap
ChromaKey
Crop
LensFlare

214
tests/ColorMap.cpp Normal file
View File

@@ -0,0 +1,214 @@
/**
* @file
* @brief Unit tests for ColorMap effect
* @author Jonathan Thomas
*
* @ref License
*/
// Copyright (c) 2008-2025 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include <memory>
#include <QImage>
#include <QColor>
#include <sstream>
#include "Frame.h"
#include "effects/ColorMap.h"
#include "openshot_catch.h"
using namespace openshot;
// allow Catch2 to print QColor on failure
static std::ostream& operator<<(std::ostream& os, QColor const& c)
{
os << "QColor(" << c.red() << "," << c.green()
<< "," << c.blue() << "," << c.alpha() << ")";
return os;
}
// Build a simple 2×2 frame with one distinct pixel
static std::shared_ptr<Frame> makeTestFrame()
{
QImage img(2, 2, QImage::Format_ARGB32);
img.fill(QColor(50,100,150,255));
img.setPixelColor(0,0, QColor(10,20,30,255));
auto frame = std::make_shared<Frame>();
*frame->GetImage() = img;
return frame;
}
// Helper to construct the LUT-path from TEST_MEDIA_PATH
static std::string lutPath()
{
std::stringstream path;
path << TEST_MEDIA_PATH << "example-lut.cube";
return path.str();
}
TEST_CASE("Default ColorMap with no LUT path leaves image unchanged", "[effect][colormap]")
{
ColorMap effect;
auto in = makeTestFrame();
QColor before = in->GetImage()->pixelColor(0,0);
auto out = effect.GetFrame(in, 0);
QColor after = out->GetImage()->pixelColor(0,0);
CHECK(after == before);
}
TEST_CASE("Overall intensity = 0 leaves image unchanged even when LUT is set", "[effect][colormap]")
{
ColorMap effect(
lutPath(),
Keyframe(0.0), // overall off
Keyframe(1.0),
Keyframe(1.0),
Keyframe(1.0)
);
auto in = makeTestFrame();
QColor before = in->GetImage()->pixelColor(0,0);
auto out = effect.GetFrame(in, 1);
QColor after = out->GetImage()->pixelColor(0,0);
CHECK(after == before);
}
TEST_CASE("JSON round-trip preserves LUT path and intensity keyframe values", "[effect][colormap][json]")
{
ColorMap A(
lutPath(),
Keyframe(0.3), // overall
Keyframe(0.4),
Keyframe(0.5),
Keyframe(0.6)
);
std::string serialized = A.Json();
ColorMap B;
B.SetJson(serialized);
CHECK(B.JsonValue()["lut_path"].asString() == lutPath());
CHECK( B.intensity. GetValue(0) == Approx(0.3) );
CHECK( B.intensity_r.GetValue(0) == Approx(0.4) );
CHECK( B.intensity_g.GetValue(0) == Approx(0.5) );
CHECK( B.intensity_b.GetValue(0) == Approx(0.6) );
}
TEST_CASE("Clearing LUT path via JSON leaves LUT path empty", "[effect][colormap][json]")
{
ColorMap effect(
lutPath(),
Keyframe(1.0),
Keyframe(1.0),
Keyframe(1.0),
Keyframe(1.0)
);
Json::Value clear;
clear["lut_path"] = std::string("");
effect.SetJsonValue(clear);
auto v = effect.JsonValue();
CHECK(v["lut_path"].asString() == "");
}
TEST_CASE("PropertiesJSON exposes all four intensity properties", "[effect][colormap][ui]")
{
ColorMap effect;
std::string props = effect.PropertiesJSON(0);
Json::CharReaderBuilder rb;
Json::Value root;
std::string errs;
std::istringstream is(props);
REQUIRE(Json::parseFromStream(rb, is, &root, &errs));
CHECK(root.isMember("lut_path"));
CHECK(root.isMember("intensity"));
CHECK(root.isMember("intensity_r"));
CHECK(root.isMember("intensity_g"));
CHECK(root.isMember("intensity_b"));
}
TEST_CASE("Full-intensity LUT changes pixel values", "[effect][colormap][lut]")
{
ColorMap effect(
lutPath(),
Keyframe(1.0), // full overall
Keyframe(1.0),
Keyframe(1.0),
Keyframe(1.0)
);
auto in = makeTestFrame();
QColor before = in->GetImage()->pixelColor(0,0);
auto out = effect.GetFrame(in, 2);
QColor after = out->GetImage()->pixelColor(0,0);
CHECK(after != before);
}
TEST_CASE("Half-intensity LUT changes pixel values less than full-intensity", "[effect][colormap][lut]")
{
auto in = makeTestFrame();
QColor before = in->GetImage()->pixelColor(0,0);
ColorMap half(
lutPath(),
Keyframe(0.5), // half overall
Keyframe(1.0),
Keyframe(1.0),
Keyframe(1.0)
);
auto out_half = half.GetFrame(in, 3);
QColor h = out_half->GetImage()->pixelColor(0,0);
ColorMap full(
lutPath(),
Keyframe(1.0),
Keyframe(1.0),
Keyframe(1.0),
Keyframe(1.0)
);
auto out_full = full.GetFrame(in, 3);
QColor f = out_full->GetImage()->pixelColor(0,0);
int diff_half = std::abs(h.red() - before.red())
+ std::abs(h.green() - before.green())
+ std::abs(h.blue() - before.blue());
int diff_full = std::abs(f.red() - before.red())
+ std::abs(f.green() - before.green())
+ std::abs(f.blue() - before.blue());
CHECK(diff_half < diff_full);
}
TEST_CASE("Disabling red channel produces different result than full-intensity", "[effect][colormap][lut]")
{
auto in = makeTestFrame();
QColor before = in->GetImage()->pixelColor(0,0);
ColorMap full(
lutPath(),
Keyframe(1.0),
Keyframe(1.0),
Keyframe(1.0),
Keyframe(1.0)
);
auto out_full = full.GetFrame(in, 4);
QColor f = out_full->GetImage()->pixelColor(0,0);
ColorMap red_off(
lutPath(),
Keyframe(1.0),
Keyframe(0.0), // red off
Keyframe(1.0),
Keyframe(1.0)
);
auto out_off = red_off.GetFrame(in, 4);
QColor r = out_off->GetImage()->pixelColor(0,0);
CHECK(r != f);
}