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 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-18 15:05:15 +02:00
co-authored by Claude Opus 5
parent 887a079c70
commit ee4b1038ac
5 changed files with 234 additions and 0 deletions
+72
View File
@@ -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<effector::Image>& 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<effector::Image>& 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<Image> 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<Image> 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";