From ee4b1038ac02f69e7565d9a7594bacdc29c89ba3 Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 15:05:15 +0200 Subject: [PATCH] Add a blur effect plugin Fills the canvas from one source, tiling it when it is smaller, then applies a separable box blur repeated `passes` times to approximate a gaussian. Params: radius, passes, direction and first_source, so the CLI flags and the GUI widgets come out of the ParamSpecs as usual. All random values (source index and crop offset) are drawn serially before the parallel pixel work, so a seed reproduces the image exactly; the box window clamps at both ends and the radius is clamped to the canvas so an oversized radius cannot read out of bounds. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 1 + src/core/registry.cpp | 2 + src/effects/blur.cpp | 136 ++++++++++++++++++++++++++++++++++++++++++ src/effects/blur.hpp | 23 +++++++ tests/core_tests.cpp | 72 ++++++++++++++++++++++ 5 files changed, 234 insertions(+) create mode 100644 src/effects/blur.cpp create mode 100644 src/effects/blur.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0139194..c14b919 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,7 @@ add_library(effector_core STATIC src/core/params.cpp src/core/registry.cpp src/core/rng.cpp + src/effects/blur.cpp src/effects/scanlines.cpp ) target_include_directories(effector_core PUBLIC src) diff --git a/src/core/registry.cpp b/src/core/registry.cpp index de5c25d..b540896 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -1,5 +1,6 @@ #include "core/registry.hpp" +#include "effects/blur.hpp" #include "effects/scanlines.hpp" namespace effector { @@ -8,6 +9,7 @@ namespace effector { const std::vector& effects() { static const std::vector registry = { std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/blur.cpp b/src/effects/blur.cpp new file mode 100644 index 0000000..81b2e0b --- /dev/null +++ b/src/effects/blur.cpp @@ -0,0 +1,136 @@ +#include "effects/blur.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" + +namespace effector { + +const std::vector& BlurEffect::params() const { + static const std::vector specs = { + ParamSpec::makeInt("radius", "Radius", 8, 1, 256, + "Half width in pixels of the blur window."), + ParamSpec::makeInt("passes", "Passes", 2, 1, 8, + "How often the box blur is repeated; more passes look more gaussian."), + ParamSpec::makeChoice("direction", "Direction", "both", {"both", "horizontal", "vertical"}, + "Axis the blur is applied along."), + ParamSpec::makeBool("first_source", "Use the first image", false, + "Always blur the first source instead of picking one at random."), + }; + return specs; +} + +namespace { + +// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so +// that every pixel is covered even when the source is smaller than the canvas. +void fillFromSource(Image& canvas, const Image& src, int sx, int sy) { + const int width = canvas.width(); + const std::size_t total = static_cast(width) * 3; + const int copyWidth = std::min(width, src.width() - sx); + const std::size_t copyBytes = static_cast(copyWidth) * 3; + + parallelFor(static_cast(canvas.height()), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + const int srcY = sy + (static_cast(y) % std::max(1, src.height() - sy)); + const std::uint8_t* in = src.row(srcY) + static_cast(sx) * 3; + std::uint8_t* out = canvas.row(static_cast(y)); + + std::memcpy(out, in, copyBytes); + std::size_t filled = copyBytes; + while (filled < total) { + const std::size_t chunk = std::min(filled, total - filled); + std::memcpy(out + filled, out, chunk); + filled += chunk; + } + } + }); +} + +// One separable box blur pass. `stride` is the distance in bytes between two +// neighbouring samples along the blurred axis, `count` how many of them there +// are; the window is clamped at both ends. +void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride, int radius) { + const int window = 2 * radius + 1; + int sum[3] = {0, 0, 0}; + + for (int c = 0; c < 3; ++c) { + sum[c] = in[c] * (radius + 1); + for (int i = 1; i <= radius; ++i) { + sum[c] += in[static_cast(std::min(i, count - 1)) * stride + c]; + } + } + + for (int i = 0; i < count; ++i) { + for (int c = 0; c < 3; ++c) { + out[static_cast(i) * stride + c] = + static_cast(sum[c] / window); + } + const int add = std::min(i + radius + 1, count - 1); + const int drop = std::max(i - radius, 0); + for (int c = 0; c < 3; ++c) { + sum[c] += in[static_cast(add) * stride + c] - + in[static_cast(drop) * stride + c]; + } + } +} + +void blurHorizontal(const Image& src, Image& dst, int radius) { + parallelFor(static_cast(src.height()), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + boxPass(src.row(static_cast(y)), dst.row(static_cast(y)), src.width(), 3, + radius); + } + }); +} + +void blurVertical(const Image& src, Image& dst, int radius) { + const std::size_t stride = src.stride(); + parallelFor(static_cast(src.width()), [&](std::size_t begin, std::size_t end) { + for (std::size_t x = begin; x < end; ++x) { + const std::size_t offset = x * 3; + boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius); + } + }); +} + +} // namespace + +Image BlurEffect::render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const { + if (sources.empty()) throw EffectError("No source images."); + if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); + + const int radius = static_cast(std::max(1L, paramInt(values, "radius", 8))); + const int passes = static_cast(std::max(1L, paramInt(values, "passes", 2))); + const std::string direction = paramChoice(values, "direction", "both"); + const bool firstSource = paramBool(values, "first_source", false); + + // Every random value is drawn here, before any threading, so that a seed + // always gives the same image. + const std::size_t index = firstSource ? 0 : rng.below(sources.size() - 1); + const Image& src = sources[index]; + const int sx = static_cast(rng.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - height))); + + Image canvas(width, height); + fillFromSource(canvas, src, sx, sy); + + Image scratch(width, height); + for (int pass = 0; pass < passes; ++pass) { + if (direction != "vertical") { + blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1))); + std::swap(canvas, scratch); + } + if (direction != "horizontal") { + blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1))); + std::swap(canvas, scratch); + } + } + + return canvas; +} + +} // namespace effector diff --git a/src/effects/blur.hpp b/src/effects/blur.hpp new file mode 100644 index 0000000..57cf3d2 --- /dev/null +++ b/src/effects/blur.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Fills the canvas with one of the sources and softens it with a separable box +// blur, repeated a few times to approximate a gaussian. +class BlurEffect : public Effect { +public: + std::string name() const override { return "blur"; } + std::string title() const override { return "Blur"; } + std::string description() const override { + return "Fills the canvas from a randomly chosen source and softens it with a repeated " + "box blur, horizontally, vertically or both."; + } + const std::vector& params() const override; + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index d913488..3b0e38e 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -11,6 +11,7 @@ #include "core/registry.hpp" #include "core/rng.hpp" +#include "effects/blur.hpp" #include "effects/scanlines.hpp" namespace { @@ -44,6 +45,29 @@ bool sameBytes(const effector::Image& a, const effector::Image& b) { return std::equal(a.data(), a.data() + a.byteSize(), b.data()); } +// Alternating black and white columns: any blur along x has to pull these +// towards mid grey. +effector::Image stripes(int width, int height) { + effector::Image image(width, height); + for (int y = 0; y < height; ++y) { + auto* row = image.row(y); + for (int x = 0; x < width; ++x) { + const std::uint8_t value = (x % 2 == 0) ? 0 : 255; + row[x * 3 + 0] = row[x * 3 + 1] = row[x * 3 + 2] = value; + } + } + return image; +} + +effector::Image renderBlur(const std::vector& sources, int width, int height, + std::uint64_t seed, const effector::ParamValues& values) { + effector::BlurEffect effect; + effector::Rng rng(seed); + auto merged = effect.defaults(); + for (const auto& [key, value] : values) merged[key] = value; + return effect.render(sources, width, height, rng, merged); +} + effector::Image render(const std::vector& sources, int width, int height, std::uint64_t seed, const effector::ParamValues& values) { effector::ScanlinesEffect effect; @@ -128,6 +152,54 @@ int main() { check(threw, "no sources throws"); } + std::cout << "blur\n"; + { + std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; + + const auto first = renderBlur(sources, 400, 300, 4, {{"radius", 10L}}); + const auto again = renderBlur(sources, 400, 300, 4, {{"radius", 10L}}); + const auto other = renderBlur(sources, 400, 300, 11, {{"radius", 10L}}); + check(first.width() == 400 && first.height() == 300, "canvas has the requested size"); + check(sameBytes(first, again), "same seed reproduces the image"); + check(!sameBytes(first, other), "a different seed changes the image"); + + // A wide blur over one-pixel stripes has to land near mid grey, and only + // along the axis it is applied to. + std::vector striped = {stripes(200, 100)}; + const auto flattened = renderBlur(striped, 200, 100, 1, + {{"radius", 8L}, {"direction", std::string("horizontal")}}); + // The clamped edges ramp in over radius * passes pixels on each side. + bool grey = true; + for (int x = 32; x < 168; ++x) grey = grey && std::abs(flattened.row(50)[x * 3] - 127) <= 2; + check(grey, "a horizontal blur evens out vertical stripes"); + + const auto untouched = renderBlur(striped, 200, 100, 1, + {{"radius", 8L}, {"direction", std::string("vertical")}}); + check(untouched.row(50)[0] == 0 && untouched.row(50)[3] == 255, + "a vertical blur leaves columns alone"); + + // Every column is uniform, so a vertical blur must not change anything. + const auto vertical = renderBlur(striped, 200, 100, 1, + {{"radius", 4L}, {"passes", 3L}, {"direction", std::string("vertical")}}); + check(sameBytes(untouched, vertical), "repeated vertical passes stay stable"); + + // Radius larger than the canvas must clamp rather than read out of bounds. + const auto tiny = renderBlur(striped, 3, 3, 2, {{"radius", 200L}}); + check(tiny.width() == 3 && tiny.height() == 3, "radius wider than the canvas is clamped"); + + const auto pinned = renderBlur(sources, 300, 200, 6, {{"first_source", true}}); + const auto pinnedAgain = renderBlur(sources, 300, 200, 6, {{"first_source", true}}); + check(sameBytes(pinned, pinnedAgain), "first_source is reproducible"); + + bool threw = false; + try { + renderBlur({}, 100, 100, 1, {}); + } catch (const EffectError&) { + threw = true; + } + check(threw, "no sources throws"); + } + std::cout << "image io\n"; { const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";