Add an image overlay effect

Lays a single picture from disk over its input: fitted, filled, stretched,
left at its own size or tiled, centred or pinned to the top left corner,
nudged by an offset and mixed in by its opacity.

The file it needs is a new kind of param, Path, so the interface shows a
chooser and the CLI takes a file name, both without knowing an overlay
exists. Projects write it relative to their own folder, as they do a
source, and Image::load can now hand back the alpha channel instead of
flattening it onto white, so a cut-out PNG keeps its cut-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-18 19:31:09 +02:00
co-authored by Claude Opus 5
parent 2a64ab115e
commit e2252ecd30
15 changed files with 520 additions and 8 deletions
+3
View File
@@ -110,6 +110,9 @@ Options parseOptions(int argc, char** argv) {
}
break;
}
case ParamSpec::Type::Path:
options.params[spec->key] = requireValue(argc, argv, i, arg);
break;
case ParamSpec::Type::Choice: {
const std::string text = requireValue(argc, argv, i, arg);
bool valid = false;
+1
View File
@@ -16,6 +16,7 @@ ParamValues Effect::defaults() const {
values[spec.key] = spec.defaultValue != 0.0;
break;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Path:
values[spec.key] = spec.defaultChoice;
break;
}
+13 -1
View File
@@ -43,7 +43,7 @@ Image::Image(int width, int height)
pixels_(static_cast<std::size_t>(std::max(width, 0)) * 3 *
static_cast<std::size_t>(std::max(height, 0))) {}
Image Image::load(const std::string& path) {
Image Image::load(const std::string& path, std::vector<std::uint8_t>* alphaOut) {
GError* error = nullptr;
PixbufPtr pixbuf = wrap(gdk_pixbuf_new_from_file(path.c_str(), &error));
if (!pixbuf) throw std::runtime_error(takeError(error));
@@ -59,11 +59,23 @@ Image Image::load(const std::string& path) {
const guint8* src = gdk_pixbuf_get_pixels(pixbuf.get());
Image image(width, height);
if (alphaOut != nullptr) {
alphaOut->assign(static_cast<std::size_t>(std::max(width, 0)) *
static_cast<std::size_t>(std::max(height, 0)),
255);
}
for (int y = 0; y < height; ++y) {
const guint8* in = src + static_cast<std::size_t>(y) * srcStride;
std::uint8_t* out = image.row(y);
if (channels == 3) {
std::memcpy(out, in, static_cast<std::size_t>(width) * 3);
} else if (alphaOut != nullptr) {
// Kept apart rather than flattened: the caller does the compositing.
std::uint8_t* alpha = alphaOut->data() + static_cast<std::size_t>(y) * width;
for (int x = 0; x < width; ++x) {
for (int c = 0; c < 3; ++c) out[x * 3 + c] = in[x * channels + c];
alpha[x] = in[x * channels + 3];
}
} else {
// Flatten alpha onto white, matching what a viewer would show.
for (int x = 0; x < width; ++x) {
+5 -1
View File
@@ -34,7 +34,11 @@ public:
// Decodes via GdkPixbuf (PNG, JPEG, WebP, TIFF, BMP, ...), applying the
// embedded EXIF orientation. Throws std::runtime_error on failure.
static Image load(const std::string& path);
// Transparency is flattened onto white, as a viewer would show it, unless
// `alphaOut` is given: it then receives one byte per pixel (255 throughout
// for a file without an alpha channel) and the colours are left untouched,
// which is what an effect compositing the image needs.
static Image load(const std::string& path, std::vector<std::uint8_t>* alphaOut = nullptr);
// Encodes by file extension; png is used when the extension is unknown.
void save(const std::string& path) const;
+14
View File
@@ -63,6 +63,15 @@ ParamSpec ParamSpec::makeChoice(std::string key, std::string label, std::string
return spec;
}
ParamSpec ParamSpec::makePath(std::string key, std::string label, std::string help) {
ParamSpec spec;
spec.type = Type::Path;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
return spec;
}
namespace {
const ParamValue* find(const ParamValues& values, const std::string& key) {
@@ -99,6 +108,11 @@ bool paramBool(const ParamValues& values, const std::string& key, bool fallback)
std::string paramChoice(const ParamValues& values, const std::string& key,
const std::string& fallback) {
return paramText(values, key, fallback);
}
std::string paramText(const ParamValues& values, const std::string& key,
const std::string& fallback) {
if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<std::string>(value)) return *v;
}
+7 -1
View File
@@ -10,7 +10,7 @@ namespace effector {
// Declarative description of one effect knob. The GUI turns these into widgets
// and the CLI turns them into flags, so neither knows about any effect.
struct ParamSpec {
enum class Type { Int, Double, Bool, Choice };
enum class Type { Int, Double, Bool, Choice, Path };
Type type = Type::Int;
std::string key;
@@ -35,6 +35,10 @@ struct ParamSpec {
std::string help = {});
static ParamSpec makeChoice(std::string key, std::string label, std::string defaultChoice,
std::vector<std::string> choices, std::string help = {});
// A path to a file on disk, empty by default: a chooser in the interface, a
// flag taking any string on the command line, written relative to the
// project folder so that a folder still opens after it is moved.
static ParamSpec makePath(std::string key, std::string label, std::string help = {});
};
using ParamValue = std::variant<long, double, bool, std::string>;
@@ -45,5 +49,7 @@ double paramDouble(const ParamValues& values, const std::string& key, double fal
bool paramBool(const ParamValues& values, const std::string& key, bool fallback);
std::string paramChoice(const ParamValues& values, const std::string& key,
const std::string& fallback);
std::string paramText(const ParamValues& values, const std::string& key,
const std::string& fallback);
} // namespace effector
+13
View File
@@ -348,6 +348,13 @@ std::string projectToJson(const Project& project, const std::string& directory)
case ParamSpec::Type::Choice:
out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice));
break;
case ParamSpec::Type::Path: {
// Written relative to the project folder, exactly as the
// sources are, so a folder still opens once it is moved.
const auto path = paramText(stage.values, spec.key, "");
out << quote(path.empty() ? path : relativeTo(path, directory));
break;
}
}
}
out << (specs.empty() ? "}" : "\n }") << "\n }";
@@ -418,6 +425,12 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
case ParamSpec::Type::Double:
if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number;
break;
case ParamSpec::Type::Path:
if (value->kind == Json::Kind::String) {
stage.values[spec.key] =
value->text.empty() ? value->text : resolve(value->text, directory);
}
break;
case ParamSpec::Type::Choice:
for (const auto& choice : spec.choices) {
if (value->kind == Json::Kind::String && value->text == choice) {
+2
View File
@@ -3,6 +3,7 @@
#include "effects/blur.hpp"
#include "effects/grain.hpp"
#include "effects/monochrome.hpp"
#include "effects/overlay.hpp"
#include "effects/scanlines.hpp"
namespace effector {
@@ -14,6 +15,7 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<BlurEffect>(),
std::make_shared<GrainEffect>(),
std::make_shared<MonochromeEffect>(),
std::make_shared<OverlayEffect>(),
};
return registry;
}
+220
View File
@@ -0,0 +1,220 @@
#include "effects/overlay.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& OverlayEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makePath("overlay_image", "Image",
"The picture laid over the input: a logo, a texture, a frame."),
ParamSpec::makeChoice("overlay_fit", "Fitting", "fit",
{"fit", "fill", "stretch", "original", "tile"},
"fit keeps the proportions and shows the whole image; fill keeps "
"them and covers the canvas, cropping what does not fit; stretch "
"distorts it to the canvas exactly; original leaves it at its own "
"size; tile repeats it at its own size over the whole canvas."),
ParamSpec::makeDouble("overlay_scale", "Scale", 1.0, 0.05, 8.0,
"Resizes the fitted image (the tile, for tiling) by this factor.",
0.05),
ParamSpec::makeBool("overlay_center", "Centre", true,
"Places the image in the middle of the canvas rather than at its top "
"left corner; the offset is counted from there either way."),
ParamSpec::makeInt("overlay_offset_x", "Offset X", 0, -20000, 20000,
"Moves the image right by this many pixels (left when negative)."),
ParamSpec::makeInt("overlay_offset_y", "Offset Y", 0, -20000, 20000,
"Moves the image down by this many pixels (up when negative)."),
ParamSpec::makeDouble("overlay_opacity", "Opacity", 1.0, 0.0, 1.0,
"How strongly the image is mixed over the input; its own "
"transparency is taken into account as well."),
};
return specs;
}
namespace {
// Where the overlay lands on the canvas, in canvas pixels.
struct Placement {
int x = 0;
int y = 0;
int width = 1;
int height = 1;
};
Placement placeOverlay(const std::string& fit, double scale, bool centre, int offsetX, int offsetY,
int srcWidth, int srcHeight, int width, int height) {
double drawWidth = srcWidth;
double drawHeight = srcHeight;
if (fit == "stretch") {
drawWidth = width;
drawHeight = height;
} else if (fit == "fit" || fit == "fill") {
const double byWidth = static_cast<double>(width) / srcWidth;
const double byHeight = static_cast<double>(height) / srcHeight;
const double factor = fit == "fit" ? std::min(byWidth, byHeight) : std::max(byWidth, byHeight);
drawWidth = srcWidth * factor;
drawHeight = srcHeight * factor;
}
// "original" and "tile" keep the file's own size; the scale below applies
// to every mode, so a fitted image can still be nudged a little larger.
Placement placement;
placement.width = std::max(1, static_cast<int>(std::lround(drawWidth * scale)));
placement.height = std::max(1, static_cast<int>(std::lround(drawHeight * scale)));
placement.x = centre ? (width - placement.width) / 2 : 0;
placement.y = centre ? (height - placement.height) / 2 : 0;
placement.x += offsetX;
placement.y += offsetY;
return placement;
}
int wrapIndex(int value, int limit) {
const int wrapped = value % limit;
return wrapped < 0 ? wrapped + limit : wrapped;
}
// Bilinear sample of the overlay at a point in its own pixel coordinates,
// clamped at the edges or wrapped around when the image is being tiled.
void sampleOverlay(const Image& src, const std::uint8_t* alpha, double u, double v, bool wrap,
int rgb[3], int& outAlpha) {
const int w = src.width();
const int h = src.height();
const double fu = std::floor(u);
const double fv = std::floor(v);
const int u0 = static_cast<int>(fu);
const int v0 = static_cast<int>(fv);
const double du = u - fu;
const double dv = v - fv;
int xs[2];
int ys[2];
for (int i = 0; i < 2; ++i) {
xs[i] = wrap ? wrapIndex(u0 + i, w) : std::clamp(u0 + i, 0, w - 1);
ys[i] = wrap ? wrapIndex(v0 + i, h) : std::clamp(v0 + i, 0, h - 1);
}
double accum[3] = {0.0, 0.0, 0.0};
double accumAlpha = 0.0;
for (int j = 0; j < 2; ++j) {
const double wy = j == 0 ? 1.0 - dv : dv;
const std::uint8_t* row = src.row(ys[j]);
const std::uint8_t* alphaRow = alpha + static_cast<std::size_t>(ys[j]) * w;
for (int i = 0; i < 2; ++i) {
const double weight = wy * (i == 0 ? 1.0 - du : du);
const std::uint8_t* px = row + static_cast<std::size_t>(xs[i]) * 3;
const double alphaWeight = weight * alphaRow[xs[i]];
// Colour weighted by alpha: a transparent pixel in a PNG is usually
// black, and averaging it in unweighted draws a dark fringe around
// everything the image cuts out.
for (int c = 0; c < 3; ++c) accum[c] += px[c] * alphaWeight;
accumAlpha += alphaWeight;
}
}
outAlpha = std::clamp(static_cast<int>(accumAlpha + 0.5), 0, 255);
for (int c = 0; c < 3; ++c) {
rgb[c] = accumAlpha <= 0.0 ? 0
: std::clamp(static_cast<int>(accum[c] / accumAlpha + 0.5), 0,
255);
}
}
} // namespace
void OverlayEffect::loadOverlay(const std::string& path, Image& image,
std::vector<std::uint8_t>& alpha) const {
std::lock_guard<std::mutex> lock(cacheMutex_);
if (cachedPath_ != path || cached_.empty()) {
std::vector<std::uint8_t> loadedAlpha;
Image loaded;
try {
loaded = Image::load(path, &loadedAlpha);
} catch (const std::exception& error) {
throw EffectError("Cannot read the overlay image '" + path + "': " + error.what());
}
if (loaded.empty()) throw EffectError("The overlay image '" + path + "' is empty.");
cached_ = std::move(loaded);
cachedAlpha_ = std::move(loadedAlpha);
cachedPath_ = path;
}
image = cached_;
alpha = cachedAlpha_;
}
Image OverlayEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
Image canvas(width, height);
if (!sources.empty()) {
// Drawn before any threading, so that a seed always gives the same image.
const std::size_t index = 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)));
fillFromSource(canvas, src, sx, sy);
}
const std::string path = paramText(values, "overlay_image", "");
const double opacity = std::clamp(paramDouble(values, "overlay_opacity", 1.0), 0.0, 1.0);
// No image chosen yet is the state the interface starts in, not an error:
// the stage simply passes its input through.
if (path.empty() || opacity <= 0.0) return canvas;
Image overlay;
std::vector<std::uint8_t> alpha;
loadOverlay(path, overlay, alpha);
const std::string fit = paramChoice(values, "overlay_fit", "fit");
const Placement place = placeOverlay(
fit, std::clamp(paramDouble(values, "overlay_scale", 1.0), 0.05, 8.0),
paramBool(values, "overlay_center", true),
static_cast<int>(paramInt(values, "overlay_offset_x", 0)),
static_cast<int>(paramInt(values, "overlay_offset_y", 0)), overlay.width(),
overlay.height(), width, height);
const bool tiling = fit == "tile";
// Source pixels per canvas pixel, the same both ways for every mode but
// stretch, where the two axes are scaled apart on purpose.
const double stepX = static_cast<double>(overlay.width()) / place.width;
const double stepY = static_cast<double>(overlay.height()) / place.height;
const int firstRow = tiling ? 0 : std::max(0, place.y);
const int lastRow = tiling ? height : std::min(height, place.y + place.height);
const int firstCol = tiling ? 0 : std::max(0, place.x);
const int lastCol = tiling ? width : std::min(width, place.x + place.width);
if (firstRow >= lastRow || firstCol >= lastCol) return canvas; // entirely off the canvas
const int mix = static_cast<int>(opacity * 255.0 + 0.5);
const std::uint8_t* alphaData = alpha.data();
parallelFor(static_cast<std::size_t>(lastRow - firstRow), [&](std::size_t begin,
std::size_t end) {
int rgb[3];
int pixelAlpha = 255;
for (std::size_t i = begin; i < end; ++i) {
const int y = firstRow + static_cast<int>(i);
const double v = (y - place.y + 0.5) * stepY - 0.5;
std::uint8_t* out = canvas.row(y);
for (int x = firstCol; x < lastCol; ++x) {
const double u = (x - place.x + 0.5) * stepX - 0.5;
sampleOverlay(overlay, alphaData, u, v, tiling, rgb, pixelAlpha);
const int weight = pixelAlpha * mix / 255;
if (weight == 0) continue;
std::uint8_t* px = out + static_cast<std::size_t>(x) * 3;
for (int c = 0; c < 3; ++c) {
px[c] = static_cast<std::uint8_t>(px[c] + (rgb[c] - px[c]) * weight / 255);
}
}
}
});
return canvas;
}
} // namespace effector
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <cstdint>
#include <mutex>
#include <string>
#include <vector>
#include "core/effect.hpp"
namespace effector {
// Lays one picture from disk over its input: a logo, a texture, a frame. The
// overlay is fitted to the canvas in the chosen way, placed from the top left
// or from the centre, nudged by an offset and mixed in by its opacity. Like the
// blur it needs no source images of its own, so it can lead a chain; given
// sources it fills the canvas from a randomly chosen one first.
class OverlayEffect : public Effect {
public:
std::string name() const override { return "overlay"; }
std::string title() const override { return "Image overlay"; }
std::string description() const override {
return "Lays a single image from disk over its input, fitted to the canvas, centred or "
"not, nudged by an offset and mixed in by its opacity.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
private:
// The same file is re-read on every param change otherwise, and a render is
// a keystroke away in the interface. Renders can overlap, so it is guarded.
// Returns the decoded file and its alpha, reading it only when the path
// differs from the one already held.
void loadOverlay(const std::string& path, Image& image,
std::vector<std::uint8_t>& alpha) const;
mutable std::mutex cacheMutex_;
mutable std::string cachedPath_;
mutable Image cached_;
mutable std::vector<std::uint8_t> cachedAlpha_;
};
} // namespace effector
+59 -1
View File
@@ -293,6 +293,28 @@ void EffectorWindow::buildParamWidgets() {
widget = std::move(spin);
break;
}
case ParamSpec::Type::Path: {
// An entry holding the path, with a chooser beside it; the
// entry is what paramValues() reads back.
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
auto* entry = Gtk::make_managed<Gtk::Entry>();
entry->set_hexpand(true);
entry->set_text(paramText(stage.values, spec.key, ""));
entry->set_placeholder_text("No image chosen");
entry->signal_activate().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
auto* choose = Gtk::make_managed<Gtk::Button>("Choose…");
const int index = selectedStage_;
const std::string key = spec.key;
const std::string title = spec.label;
choose->signal_clicked().connect(
[this, index, key, title] { onChoosePath(index, key, title); });
box->append(*entry);
box->append(*choose);
paramWidgets_.push_back({spec, entry});
widget = std::move(box);
break;
}
case ParamSpec::Type::Choice: {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>();
@@ -311,7 +333,9 @@ void EffectorWindow::buildParamWidgets() {
widget->set_hexpand(true);
paramGrid_.attach(*label, 0, row);
paramGrid_.attach(*widget, 1, row);
paramWidgets_.push_back({spec, widget.get()});
// A path attaches a box but reads back from the entry inside it, which
// it has registered itself.
if (spec.type != ParamSpec::Type::Path) paramWidgets_.push_back({spec, widget.get()});
ownedWidgets_.push_back(std::move(label));
ownedWidgets_.push_back(std::move(widget));
++row;
@@ -333,6 +357,10 @@ ParamValues EffectorWindow::paramValues() const {
case ParamSpec::Type::Double:
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
break;
case ParamSpec::Type::Path:
values[entry.spec.key] =
std::string(static_cast<Gtk::Entry*>(entry.widget)->get_text());
break;
case ParamSpec::Type::Choice: {
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
@@ -809,6 +837,36 @@ void EffectorWindow::onParamChanged() {
requestRender();
}
void EffectorWindow::onChoosePath(int stage, const std::string& key, const std::string& label) {
auto dialog = Gtk::FileDialog::create();
dialog->set_title("Select the " + label);
auto filter = Gtk::FileFilter::create();
filter->set_name("Images");
for (const char* pattern : kImagePatterns) filter->add_pattern(pattern);
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
filters->append(filter);
dialog->set_filters(filters);
dialog->set_default_filter(filter);
dialog->open(*this, [this, dialog, stage, key](const Glib::RefPtr<Gio::AsyncResult>& result) {
std::string path;
try {
const auto file = dialog->open_finish(result);
if (file) path = file->get_path();
} catch (const Glib::Error&) {
return; // cancelled
}
if (path.empty() || stage < 0 || static_cast<std::size_t>(stage) >= stages_.size()) return;
// The widgets may be showing another stage by now: store what they hold
// first, then write the path into the stage that asked for it.
storeParams();
stages_[static_cast<std::size_t>(stage)].values[key] = path;
if (stage == selectedStage_) buildParamWidgets();
requestRender();
});
}
void EffectorWindow::onSeedEntered() {
try {
seed_ = std::stoull(seedEntry_.get_text());
+4
View File
@@ -75,6 +75,10 @@ private:
void updateTitle();
void onParamChanged();
// The chooser behind a Path param. The chosen file goes straight into the
// stage's values rather than into the widget, which may well have been
// rebuilt by the time the dialog comes back.
void onChoosePath(int stage, const std::string& key, const std::string& label);
void onSeedEntered();
void onRecreate();
void onSave();