Adding new Denoise effect

- Adds a new DenoiseImage video effect for reducing luma grain and color speckles with adaptive spatial filtering, conservative temporal blending, motion protection, and brightness response controls.
- Added to openshot-benchmark
- New unit tests
This commit is contained in:
Jonathan Thomas
2026-05-05 21:19:26 -05:00
parent 235a5c8491
commit 51ee44ab33
11 changed files with 919 additions and 1 deletions
+2
View File
@@ -223,6 +223,7 @@ typedef struct OpenShotByteBuffer {
%include "QtPlayer.h"
%include "QtTextReader.h"
%include "KeyFrame.h"
%include "AnimatedCurve.h"
%include "RendererBase.h"
%include "Settings.h"
%include "TimelineBase.h"
@@ -256,6 +257,7 @@ typedef struct OpenShotByteBuffer {
%include "effects/Sharpen.h"
%include "effects/Shift.h"
%include "effects/SphericalProjection.cpp"
%include "effects/DenoiseImage.h"
%include "effects/Wave.h"
#ifdef USE_OPENCV
%include "effects/Stabilizer.h"
+2
View File
@@ -505,6 +505,7 @@ static int openshot_swig_is_qwidget(PyObject *obj) {
%include "QtPlayer.h"
%include "QtTextReader.h"
%include "KeyFrame.h"
%include "AnimatedCurve.h"
%include "RendererBase.h"
%include "Settings.h"
%include "TimelineBase.h"
@@ -544,6 +545,7 @@ static int openshot_swig_is_qwidget(PyObject *obj) {
%include "effects/Sharpen.h"
%include "effects/Shift.h"
%include "effects/SphericalProjection.cpp"
%include "effects/DenoiseImage.h"
%include "effects/Wave.h"
#ifdef USE_OPENCV
%include "effects/Stabilizer.h"
+2 -1
View File
@@ -251,6 +251,7 @@ typedef struct OpenShotByteBuffer {
%include "QtPlayer.h"
%include "QtTextReader.h"
%include "KeyFrame.h"
%include "AnimatedCurve.h"
%include "RendererBase.h"
%include "Settings.h"
%include "TimelineBase.h"
@@ -281,5 +282,5 @@ typedef struct OpenShotByteBuffer {
%include "effects/Pixelate.h"
%include "effects/Saturation.h"
%include "effects/Shift.h"
%include "effects/DenoiseImage.h"
%include "effects/Wave.h"
+1
View File
@@ -136,6 +136,7 @@ set(EFFECTS_SOURCES
effects/Shadow.cpp
effects/Shift.cpp
effects/SphericalProjection.cpp
effects/DenoiseImage.cpp
effects/Wave.cpp
audio_effects/STFT.cpp
audio_effects/Noise.cpp
+4
View File
@@ -104,6 +104,9 @@ EffectBase* EffectInfo::CreateEffect(std::string effect_type) {
else if (effect_type == "SphericalProjection")
return new SphericalProjection();
else if (effect_type == "DenoiseImage")
return new DenoiseImage();
else if (effect_type == "Wave")
return new Wave();
@@ -184,6 +187,7 @@ Json::Value EffectInfo::JsonValue() {
root.append(Shadow().JsonInfo());
root.append(Shift().JsonInfo());
root.append(SphericalProjection().JsonInfo());
root.append(DenoiseImage().JsonInfo());
root.append(Wave().JsonInfo());
/* Audio */
root.append(Noise().JsonInfo());
+1
View File
@@ -40,6 +40,7 @@
#include "effects/Shadow.h"
#include "effects/SphericalProjection.h"
#include "effects/Shift.h"
#include "effects/DenoiseImage.h"
#include "effects/Wave.h"
/* Audio Effects */
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
/**
* @file
* @brief Header file for DenoiseImage effect
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @ref License
*/
// Copyright (c) 2008-2026 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#ifndef OPENSHOT_DENOISE_IMAGE_EFFECT_H
#define OPENSHOT_DENOISE_IMAGE_EFFECT_H
#include "../AnimatedCurve.h"
#include "../EffectBase.h"
#include "../Frame.h"
#include "../Json.h"
#include "../KeyFrame.h"
#include <QImage>
#include <cstdint>
#include <memory>
#include <string>
namespace openshot
{
class DenoiseImage : public EffectBase
{
private:
QImage previous_input_;
int64_t last_frame_;
void init_effect_details();
void reset_temporal_history();
public:
Keyframe strength;
Keyframe detail;
Keyframe temporal;
Keyframe motion_safety;
Keyframe color_noise;
AnimatedCurve response_curve;
DenoiseImage();
DenoiseImage(Keyframe new_strength, Keyframe new_detail, Keyframe new_temporal,
Keyframe new_motion_safety, Keyframe new_color_noise);
std::shared_ptr<openshot::Frame> GetFrame(int64_t frame_number) override {
return GetFrame(std::make_shared<openshot::Frame>(), frame_number);
}
std::shared_ptr<openshot::Frame> GetFrame(std::shared_ptr<openshot::Frame> frame, int64_t frame_number) override;
std::string Json() const override;
Json::Value JsonValue() const override;
void SetJson(const std::string value) override;
void SetJsonValue(const Json::Value root) override;
std::string PropertiesJSON(int64_t requested_frame) const override;
};
}
#endif
+19
View File
@@ -92,6 +92,10 @@ extern "C" {
# include "effects/Sharpen.h"
# define OPENSHOT_HAS_SHARPEN
#endif
#if __has_include("effects/DenoiseImage.h")
# include "effects/DenoiseImage.h"
# define OPENSHOT_HAS_DENOISEIMAGE
#endif
#if __has_include("effects/Hue.h")
# include "effects/Hue.h"
# define OPENSHOT_HAS_HUE
@@ -602,6 +606,21 @@ int main(int argc, char* argv[]) {
});
#endif
#ifdef OPENSHOT_HAS_DENOISEIMAGE
trials.emplace_back("Effect_DenoiseImage", [&]() -> TrialResult {
FFmpegReader r(video);
r.Open();
Clip clip(&r);
clip.Open();
DenoiseImage d;
clip.AddEffect(&d);
TrialResult result = timed_read(clip);
clip.Close();
r.Close();
return result;
});
#endif
#ifdef OPENSHOT_HAS_HUE
trials.emplace_back("Effect_Hue", [&]() -> TrialResult {
FFmpegReader r(video);
+1
View File
@@ -69,6 +69,7 @@ set(OPENSHOT_TESTS
Sharpen
Shadow
SphericalEffect
DenoiseImage
WaveEffect
)
+222
View File
@@ -0,0 +1,222 @@
/**
* @file
* @brief Unit tests for DenoiseImage effect
*
* @ref License
*/
// Copyright (c) 2008-2026 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include <cmath>
#include <memory>
#include <sstream>
#include <QColor>
#include <QImage>
#include "EffectInfo.h"
#include "Frame.h"
#include "effects/DenoiseImage.h"
#include "openshot_catch.h"
using namespace openshot;
static std::shared_ptr<Frame> makeDenoiseFrame()
{
QImage img(5, 5, QImage::Format_ARGB32);
img.fill(QColor(42, 42, 42, 255));
img.setPixelColor(2, 2, QColor(84, 20, 90, 128));
auto frame = std::make_shared<Frame>();
*frame->GetImage() = img;
return frame;
}
static std::shared_ptr<Frame> makeFlatFrame(const QColor& color)
{
QImage img(5, 5, QImage::Format_ARGB32);
img.fill(color);
auto frame = std::make_shared<Frame>();
*frame->GetImage() = img;
return frame;
}
static std::shared_ptr<Frame> makeNativeDenoiseFrame()
{
QImage img(5, 5, QImage::Format_RGBA8888_Premultiplied);
img.fill(QColor(42, 42, 42, 255));
img.setPixelColor(2, 2, QColor(84, 20, 90, 255));
auto frame = std::make_shared<Frame>();
frame->AddImage(std::make_shared<QImage>(img));
return frame;
}
static AnimatedCurve makeShadowOnlyCurve()
{
AnimatedCurve curve;
curve.Nodes().clear();
curve.Nodes().emplace_back(0, 0.0, 1.0, LINEAR);
curve.Nodes().emplace_back(1, 0.5, 0.0, LINEAR);
curve.Nodes().emplace_back(2, 1.0, 0.0, LINEAR);
return curve;
}
TEST_CASE("DenoiseImage zero strength leaves pixels unchanged", "[effect][denoiseimage]")
{
DenoiseImage effect;
effect.strength = Keyframe(0.0);
auto frame = makeDenoiseFrame();
const QColor before = frame->GetImage()->pixelColor(2, 2);
auto out = effect.GetFrame(frame, 1);
const QColor after = out->GetImage()->pixelColor(2, 2);
CHECK(after == before);
}
TEST_CASE("DenoiseImage preserves alpha while reducing color speckles", "[effect][denoiseimage]")
{
DenoiseImage effect;
effect.strength = Keyframe(1.0);
effect.detail = Keyframe(0.0);
effect.temporal = Keyframe(0.0);
effect.color_noise = Keyframe(1.0);
auto frame = makeDenoiseFrame();
const QColor before = frame->GetImage()->pixelColor(2, 2);
auto out = effect.GetFrame(frame, 1);
const QColor after = out->GetImage()->pixelColor(2, 2);
CHECK(after.alpha() == before.alpha());
const int before_delta = std::abs(before.red() - 42) +
std::abs(before.green() - 42) +
std::abs(before.blue() - 42);
const int after_delta = std::abs(after.red() - 42) +
std::abs(after.green() - 42) +
std::abs(after.blue() - 42);
CHECK(after_delta < before_delta);
}
TEST_CASE("DenoiseImage default settings visibly reduce isolated speckles", "[effect][denoiseimage]")
{
DenoiseImage effect;
auto frame = makeDenoiseFrame();
const QColor before = frame->GetImage()->pixelColor(2, 2);
auto out = effect.GetFrame(frame, 1);
const QColor after = out->GetImage()->pixelColor(2, 2);
const int before_delta = std::abs(before.red() - 42) +
std::abs(before.green() - 42) +
std::abs(before.blue() - 42);
const int after_delta = std::abs(after.red() - 42) +
std::abs(after.green() - 42) +
std::abs(after.blue() - 42);
CHECK(after_delta < before_delta);
}
TEST_CASE("DenoiseImage changes native OpenShot frame format through ProcessFrame", "[effect][denoiseimage][integration]")
{
std::unique_ptr<EffectBase> effect(EffectInfo().CreateEffect("DenoiseImage"));
REQUIRE(effect != nullptr);
auto frame = makeNativeDenoiseFrame();
REQUIRE(frame->GetImage()->format() == QImage::Format_RGBA8888_Premultiplied);
const QColor before = frame->GetImage()->pixelColor(2, 2);
auto out = effect->ProcessFrame(frame, 1);
const QColor after = out->GetImage()->pixelColor(2, 2);
CHECK(out->GetImage()->format() == QImage::Format_RGBA8888_Premultiplied);
CHECK(after != before);
CHECK(std::abs(after.red() - 42) < std::abs(before.red() - 42));
}
TEST_CASE("DenoiseImage response curve favors shadows", "[effect][denoiseimage][curve]")
{
DenoiseImage effect;
effect.strength = Keyframe(1.0);
effect.detail = Keyframe(0.0);
effect.temporal = Keyframe(0.0);
effect.color_noise = Keyframe(1.0);
effect.response_curve = makeShadowOnlyCurve();
QImage img(5, 5, QImage::Format_ARGB32);
img.fill(QColor(40, 40, 40, 255));
img.setPixelColor(2, 2, QColor(90, 10, 90, 255));
img.setPixelColor(4, 4, QColor(240, 240, 240, 255));
img.setPixelColor(3, 3, QColor(255, 210, 255, 255));
auto frame = std::make_shared<Frame>();
*frame->GetImage() = img;
const QColor shadow_before = frame->GetImage()->pixelColor(2, 2);
const QColor highlight_before = frame->GetImage()->pixelColor(3, 3);
auto out = effect.GetFrame(frame, 1);
const QColor shadow_after = out->GetImage()->pixelColor(2, 2);
const QColor highlight_after = out->GetImage()->pixelColor(3, 3);
const int shadow_delta = std::abs(shadow_after.red() - shadow_before.red()) +
std::abs(shadow_after.green() - shadow_before.green()) +
std::abs(shadow_after.blue() - shadow_before.blue());
const int highlight_delta = std::abs(highlight_after.red() - highlight_before.red()) +
std::abs(highlight_after.green() - highlight_before.green()) +
std::abs(highlight_after.blue() - highlight_before.blue());
CHECK(shadow_delta > highlight_delta);
}
TEST_CASE("DenoiseImage temporal only applies to sequential frames", "[effect][denoiseimage][temporal]")
{
DenoiseImage effect;
effect.strength = Keyframe(1.0);
effect.detail = Keyframe(0.0);
effect.temporal = Keyframe(1.0);
effect.motion_safety = Keyframe(1.0);
effect.color_noise = Keyframe(1.0);
auto first = makeFlatFrame(QColor(80, 80, 80, 255));
effect.GetFrame(first, 1);
auto sequential = makeFlatFrame(QColor(65, 65, 65, 255));
auto sequential_out = effect.GetFrame(sequential, 2);
const QColor sequential_color = sequential_out->GetImage()->pixelColor(2, 2);
auto jumped = makeFlatFrame(QColor(65, 65, 65, 255));
auto jumped_out = effect.GetFrame(jumped, 10);
const QColor jumped_color = jumped_out->GetImage()->pixelColor(2, 2);
CHECK(sequential_color.red() > jumped_color.red());
}
TEST_CASE("DenoiseImage JSON and PropertiesJSON expose V1 controls", "[effect][denoiseimage][json]")
{
DenoiseImage effect;
effect.strength = Keyframe(0.25);
effect.detail = Keyframe(0.75);
effect.temporal = Keyframe(0.2);
effect.motion_safety = Keyframe(0.8);
effect.color_noise = Keyframe(0.9);
effect.response_curve = makeShadowOnlyCurve();
DenoiseImage copy;
copy.SetJson(effect.Json());
CHECK(copy.strength.GetValue(1) == Approx(0.25));
CHECK(copy.detail.GetValue(1) == Approx(0.75));
CHECK(copy.temporal.GetValue(1) == Approx(0.2));
CHECK(copy.motion_safety.GetValue(1) == Approx(0.8));
CHECK(copy.color_noise.GetValue(1) == Approx(0.9));
CHECK(copy.response_curve.Nodes().size() == 3);
std::istringstream props(effect.PropertiesJSON(1));
Json::CharReaderBuilder rb;
Json::Value root;
std::string errs;
REQUIRE(Json::parseFromStream(rb, props, &root, &errs));
CHECK(root["strength"]["name"].asString() == "Strength");
CHECK(root["color_noise"]["name"].asString() == "Color Noise");
CHECK(root["response_curve"]["type"].asString() == "colorgrade_curve");
CHECK(root["response_curve"].isMember("curve"));
}