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:
@@ -1,5 +1,6 @@
|
||||
// Small self-contained checks for the pieces that have no GUI dependency.
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
@@ -20,6 +21,7 @@
|
||||
#include "effects/blur.hpp"
|
||||
#include "effects/grain.hpp"
|
||||
#include "effects/monochrome.hpp"
|
||||
#include "effects/overlay.hpp"
|
||||
#include "effects/scanlines.hpp"
|
||||
|
||||
namespace {
|
||||
@@ -116,6 +118,24 @@ effector::Image renderMono(const std::vector<effector::Image>& sources, int widt
|
||||
return effect.render(sources, width, height, rng, merged);
|
||||
}
|
||||
|
||||
effector::Image renderOverlay(const std::vector<effector::Image>& sources, int width, int height,
|
||||
const effector::ParamValues& values) {
|
||||
effector::OverlayEffect effect;
|
||||
effector::Rng rng(7);
|
||||
auto merged = effect.defaults();
|
||||
for (const auto& [key, value] : values) merged[key] = value;
|
||||
return effect.render(sources, width, height, rng, merged);
|
||||
}
|
||||
|
||||
// The three channels of one pixel, to read a placement off directly.
|
||||
std::array<int, 3> pixel(const effector::Image& image, int x, int y) {
|
||||
const auto* px = image.row(y) + static_cast<std::size_t>(x) * 3;
|
||||
return {px[0], px[1], px[2]};
|
||||
}
|
||||
|
||||
bool isRed(const std::array<int, 3>& px) { return px[0] > 200 && px[1] < 60 && px[2] < 60; }
|
||||
bool isBlue(const std::array<int, 3>& px) { return px[2] > 200 && px[0] < 60 && px[1] < 60; }
|
||||
|
||||
// A flat field of one colour, to read the channel weighting off directly.
|
||||
effector::Image solid(int width, int height, std::uint8_t r, std::uint8_t g, std::uint8_t b) {
|
||||
effector::Image image(width, height);
|
||||
@@ -404,6 +424,113 @@ int main() {
|
||||
check(allBlack, "without sources monochrome renders an empty canvas");
|
||||
}
|
||||
|
||||
std::cout << "overlay\n";
|
||||
{
|
||||
const auto dir = std::filesystem::temp_directory_path() / "effector_test_overlay";
|
||||
std::filesystem::create_directories(dir);
|
||||
const auto logo = (dir / "logo.png").string();
|
||||
solid(40, 20, 255, 0, 0).save(logo); // wider than it is tall, to tell the fittings apart
|
||||
|
||||
const std::vector<Image> blue = {solid(100, 100, 0, 0, 255)};
|
||||
const ParamValues at = {{"overlay_image", logo}, {"overlay_fit", std::string("original")}};
|
||||
|
||||
const auto centred = renderOverlay(blue, 100, 100, at);
|
||||
check(isRed(pixel(centred, 50, 50)), "the overlay lands in the middle of the canvas");
|
||||
check(isBlue(pixel(centred, 2, 2)), "and leaves the rest of the input alone");
|
||||
check(isRed(pixel(centred, 30, 40)) && isBlue(pixel(centred, 29, 40)),
|
||||
"centred, a 40x20 image starts 30 pixels in");
|
||||
|
||||
ParamValues corner = at;
|
||||
corner["overlay_center"] = false;
|
||||
const auto topLeft = renderOverlay(blue, 100, 100, corner);
|
||||
check(isRed(pixel(topLeft, 0, 0)) && isBlue(pixel(topLeft, 41, 0)),
|
||||
"without centring it sits in the top left corner");
|
||||
|
||||
ParamValues nudged = corner;
|
||||
nudged["overlay_offset_x"] = 10L;
|
||||
nudged["overlay_offset_y"] = 5L;
|
||||
const auto moved = renderOverlay(blue, 100, 100, nudged);
|
||||
check(isRed(pixel(moved, 10, 5)) && isBlue(pixel(moved, 9, 5)) && isBlue(pixel(moved, 10, 4)),
|
||||
"the offset moves it right and down");
|
||||
|
||||
ParamValues back = at;
|
||||
back["overlay_offset_x"] = -25L;
|
||||
const auto half = renderOverlay(blue, 100, 100, back);
|
||||
check(isRed(pixel(half, 5, 50)) && isBlue(pixel(half, 96, 50)),
|
||||
"a negative offset moves it off the left edge, and it is clipped");
|
||||
|
||||
// Fitting: fit shows the whole image and keeps its proportions, so a
|
||||
// 40x20 logo becomes 100x50 with a band of input above and below; fill
|
||||
// and stretch and tile all cover the canvas.
|
||||
ParamValues fitted = at;
|
||||
fitted["overlay_fit"] = std::string("fit");
|
||||
const auto fit = renderOverlay(blue, 100, 100, fitted);
|
||||
check(isBlue(pixel(fit, 50, 10)) && isRed(pixel(fit, 50, 50)) && isRed(pixel(fit, 1, 50)),
|
||||
"fit shows the whole image, in its own proportions");
|
||||
|
||||
for (const char* mode : {"fill", "stretch", "tile"}) {
|
||||
ParamValues covering = at;
|
||||
covering["overlay_fit"] = std::string(mode);
|
||||
const auto image = renderOverlay(blue, 100, 100, covering);
|
||||
bool everywhere = true;
|
||||
for (int y = 0; y < 100; y += 7) {
|
||||
for (int x = 0; x < 100; x += 7) everywhere = everywhere && isRed(pixel(image, x, y));
|
||||
}
|
||||
check(everywhere, std::string(mode) + " covers the whole canvas");
|
||||
}
|
||||
|
||||
// Scale resizes what the fitting worked out: half of 100x50 is 50x25.
|
||||
ParamValues scaled = fitted;
|
||||
scaled["overlay_scale"] = 0.5;
|
||||
const auto smaller = renderOverlay(blue, 100, 100, scaled);
|
||||
check(isRed(pixel(smaller, 50, 50)) && isBlue(pixel(smaller, 20, 50)),
|
||||
"the scale shrinks the fitted image about its centre");
|
||||
|
||||
// Opacity mixes over the input rather than replacing it.
|
||||
ParamValues faint = at;
|
||||
faint["overlay_opacity"] = 0.5;
|
||||
const auto mixed = pixel(renderOverlay(blue, 100, 100, faint), 50, 50);
|
||||
check(mixed[0] > 100 && mixed[0] < 155 && mixed[2] > 100 && mixed[2] < 155,
|
||||
"half opacity sits between the overlay and the input");
|
||||
check(sameBytes(renderOverlay(blue, 100, 100, {{"overlay_image", logo},
|
||||
{"overlay_opacity", 0.0}}),
|
||||
solid(100, 100, 0, 0, 255)),
|
||||
"zero opacity leaves the input untouched");
|
||||
|
||||
// No image chosen is the state the interface starts in: a pass through.
|
||||
check(sameBytes(renderOverlay(blue, 100, 100, {}), solid(100, 100, 0, 0, 255)),
|
||||
"with no image chosen the stage passes its input through");
|
||||
|
||||
// Like blur and monochrome it needs no sources of its own.
|
||||
check(!OverlayEffect().needsSources(), "the overlay can lead a chain");
|
||||
const auto alone = renderOverlay({}, 60, 40, {{"overlay_image", logo},
|
||||
{"overlay_fit", std::string("stretch")}});
|
||||
check(alone.width() == 60 && isRed(pixel(alone, 30, 20)),
|
||||
"without sources it draws on an empty canvas");
|
||||
|
||||
bool threw = false;
|
||||
try {
|
||||
renderOverlay(blue, 100, 100, {{"overlay_image", std::string("/nope/missing.png")}});
|
||||
} catch (const EffectError&) {
|
||||
threw = true;
|
||||
}
|
||||
check(threw, "a missing overlay image is reported");
|
||||
|
||||
// The path travels with the project, written relative to its folder.
|
||||
Project project;
|
||||
project.width = 100;
|
||||
project.height = 100;
|
||||
project.stages.push_back({effectByName("overlay"), {{"overlay_image", logo}}});
|
||||
const auto json = projectToJson(project, dir.string());
|
||||
check(json.find("\"logo.png\"") != std::string::npos,
|
||||
"an overlay image inside the project folder is written relative");
|
||||
const auto reread = projectFromJson(json, dir.string());
|
||||
check(paramText(reread.stages[0].values, "overlay_image", "") == logo,
|
||||
"and comes back as the same file");
|
||||
|
||||
std::filesystem::remove_all(dir);
|
||||
}
|
||||
|
||||
std::cout << "chain\n";
|
||||
{
|
||||
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||
|
||||
Reference in New Issue
Block a user