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
+136
View File
@@ -0,0 +1,136 @@
#include "effects/blur.hpp"
#include <algorithm>
#include <cstring>
#include <vector>
#include "core/parallel.hpp"
namespace effector {
const std::vector<ParamSpec>& BlurEffect::params() const {
static const std::vector<ParamSpec> 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<std::size_t>(width) * 3;
const int copyWidth = std::min(width, src.width() - sx);
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
parallelFor(static_cast<std::size_t>(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<int>(y) % std::max(1, src.height() - sy));
const std::uint8_t* in = src.row(srcY) + static_cast<std::size_t>(sx) * 3;
std::uint8_t* out = canvas.row(static_cast<int>(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::size_t>(std::min(i, count - 1)) * stride + c];
}
}
for (int i = 0; i < count; ++i) {
for (int c = 0; c < 3; ++c) {
out[static_cast<std::size_t>(i) * stride + c] =
static_cast<std::uint8_t>(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<std::size_t>(add) * stride + c] -
in[static_cast<std::size_t>(drop) * stride + c];
}
}
}
void blurHorizontal(const Image& src, Image& dst, int radius) {
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
radius);
}
});
}
void blurVertical(const Image& src, Image& dst, int radius) {
const std::size_t stride = src.stride();
parallelFor(static_cast<std::size_t>(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<Image>& 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<int>(std::max(1L, paramInt(values, "radius", 8)));
const int passes = static_cast<int>(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<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(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