Add Tile swap, a video effect built from the source images
Tile swap lays tiles cut from the photographs over the canvas as Tiles does, in a grid, in bricks or scattered, and swaps each one for another cut at a moment of its own, so the mosaic keeps changing piece by piece. The layout stays put for the whole clip; what a tile shows depends only on the tile and how many times it has swapped, counted round the clip, so it holds still between swaps and the clip loops without a jump. On the command line it is -e tileswap with --tileswap-rate and the rest. VideoEffect learns a second kind: an effect that answers needsSources() writes compose() instead of animate(), and the base then takes no crop, only the one seed, so it sees the photographs when it leads the chain and the stage before it otherwise. Transforms render exactly as before. The tile cutting and painting move out of tiles.cpp into tile_plan.hpp for both effects to share; Tiles renders byte for byte as before. Version 0.8.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
#include "effects/slitshift.hpp"
|
||||
#include "effects/text.hpp"
|
||||
#include "effects/tiles.hpp"
|
||||
#include "effects/tileswap.hpp"
|
||||
#include "effects/tracking.hpp"
|
||||
#include "effects/warp.hpp"
|
||||
|
||||
@@ -39,6 +40,7 @@ const std::vector<EffectPtr>& effects() {
|
||||
std::make_shared<FeedbackEffect>(),
|
||||
// Video effects, offered only in a video project.
|
||||
std::make_shared<TrackingEffect>(),
|
||||
std::make_shared<TileSwapEffect>(),
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy)
|
||||
// and laid down at (dstX, dstY). Shared by the tiles and the tile swap effects.
|
||||
struct Tile {
|
||||
int dstX;
|
||||
int dstY;
|
||||
int w;
|
||||
int h;
|
||||
int sourceIndex;
|
||||
int sx;
|
||||
int sy;
|
||||
};
|
||||
|
||||
// Splits `span` into consecutive pieces of `base` pixels give or take `jitter`,
|
||||
// the last one clipped so that the pieces cover the span exactly.
|
||||
inline std::vector<int> planSpans(Rng& rng, int base, int jitter, int span) {
|
||||
std::vector<int> spans;
|
||||
spans.reserve(static_cast<std::size_t>(span / base) + 8);
|
||||
int used = 0;
|
||||
while (used < span) {
|
||||
int size = base;
|
||||
if (jitter > 0) size += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||
size = std::max(1, std::min(size, span - used));
|
||||
spans.push_back(size);
|
||||
used += size;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
inline void paintTile(Image& canvas, const Image& src, const Tile& tile) {
|
||||
const int copyWidth = std::min(tile.w, src.width() - tile.sx);
|
||||
const int copyHeight = std::min(tile.h, src.height() - tile.sy);
|
||||
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||
const std::size_t total = static_cast<std::size_t>(tile.w) * 3;
|
||||
const std::size_t offset = static_cast<std::size_t>(tile.dstX) * 3;
|
||||
|
||||
for (int line = 0; line < copyHeight; ++line) {
|
||||
const std::uint8_t* in = src.row(tile.sy + line) + static_cast<std::size_t>(tile.sx) * 3;
|
||||
std::uint8_t* out = canvas.row(tile.dstY + line) + offset;
|
||||
|
||||
std::memcpy(out, in, copyBytes);
|
||||
// Repeat the cut sideways when the source is narrower than the tile,
|
||||
// doubling the filled span on each pass.
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Same again downwards, out of the lines already written.
|
||||
for (int line = copyHeight; line < tile.h; ++line) {
|
||||
std::memcpy(canvas.row(tile.dstY + line) + offset,
|
||||
canvas.row(tile.dstY + (line % copyHeight)) + offset, total);
|
||||
}
|
||||
}
|
||||
|
||||
// Paints the whole plan. Overlapping tiles (the scattered layout) are painted
|
||||
// in plan order on one thread: letting threads race for the same pixels would
|
||||
// make the output depend on their scheduling.
|
||||
inline void paintTiles(Image& canvas, const std::vector<Image>& sources,
|
||||
const std::vector<Tile>& plan, bool overlapping) {
|
||||
if (overlapping) {
|
||||
for (const Tile& tile : plan) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(tile.sourceIndex)], tile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(plan[i].sourceIndex)], plan[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
+2
-76
@@ -1,11 +1,10 @@
|
||||
#include "effects/tiles.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
#include "effects/tile_plan.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -29,66 +28,6 @@ const std::vector<ParamSpec>& TilesEffect::params() const {
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy)
|
||||
// and laid down at (dstX, dstY).
|
||||
struct Tile {
|
||||
int dstX;
|
||||
int dstY;
|
||||
int w;
|
||||
int h;
|
||||
int sourceIndex;
|
||||
int sx;
|
||||
int sy;
|
||||
};
|
||||
|
||||
// Splits `span` into consecutive pieces of `base` pixels give or take `jitter`,
|
||||
// the last one clipped so that the pieces cover the span exactly.
|
||||
std::vector<int> planSpans(Rng& rng, int base, int jitter, int span) {
|
||||
std::vector<int> spans;
|
||||
spans.reserve(static_cast<std::size_t>(span / base) + 8);
|
||||
int used = 0;
|
||||
while (used < span) {
|
||||
int size = base;
|
||||
if (jitter > 0) size += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||
size = std::max(1, std::min(size, span - used));
|
||||
spans.push_back(size);
|
||||
used += size;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
void paintTile(Image& canvas, const Image& src, const Tile& tile) {
|
||||
const int copyWidth = std::min(tile.w, src.width() - tile.sx);
|
||||
const int copyHeight = std::min(tile.h, src.height() - tile.sy);
|
||||
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||
const std::size_t total = static_cast<std::size_t>(tile.w) * 3;
|
||||
const std::size_t offset = static_cast<std::size_t>(tile.dstX) * 3;
|
||||
|
||||
for (int line = 0; line < copyHeight; ++line) {
|
||||
const std::uint8_t* in = src.row(tile.sy + line) + static_cast<std::size_t>(tile.sx) * 3;
|
||||
std::uint8_t* out = canvas.row(tile.dstY + line) + offset;
|
||||
|
||||
std::memcpy(out, in, copyBytes);
|
||||
// Repeat the cut sideways when the source is narrower than the tile,
|
||||
// doubling the filled span on each pass.
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Same again downwards, out of the lines already written.
|
||||
for (int line = copyHeight; line < tile.h; ++line) {
|
||||
std::memcpy(canvas.row(tile.dstY + line) + offset,
|
||||
canvas.row(tile.dstY + (line % copyHeight)) + offset, total);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TilesEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (sources.empty()) throw EffectError("No source images.");
|
||||
@@ -159,20 +98,7 @@ Image TilesEffect::render(const std::vector<Image>& sources, int width, int heig
|
||||
}
|
||||
}
|
||||
|
||||
if (layout == "scatter") {
|
||||
// Scattered tiles overlap, so they are painted in plan order on one
|
||||
// thread: letting threads race for the same pixels would make the
|
||||
// output depend on their scheduling.
|
||||
for (const Tile& tile : plan) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(tile.sourceIndex)], tile);
|
||||
}
|
||||
} else {
|
||||
parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(plan[i].sourceIndex)], plan[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
paintTiles(canvas, sources, plan, layout == "scatter");
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
#include "effects/tileswap.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
#include "effects/source_fill.hpp"
|
||||
#include "effects/tile_plan.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& TileSwapEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeChoice("tileswap_layout", "Layout", "grid", {"grid", "brick", "scatter"},
|
||||
"grid lines the tiles up in rows and columns; brick packs every row "
|
||||
"on its own so the seams do not meet; scatter drops loose tiles over "
|
||||
"a filled background, overlapping."),
|
||||
ParamSpec::makeInt("tileswap_width", "Tile width", 96, 1, 2048,
|
||||
"Width in pixels of each cut-out tile."),
|
||||
ParamSpec::makeInt("tileswap_height", "Tile height", 96, 1, 2048,
|
||||
"Height in pixels of each cut-out tile."),
|
||||
ParamSpec::makeInt("tileswap_jitter", "Size jitter", 0, 0, 512,
|
||||
"Random +/- variation added to both tile sizes (0 = uniform)."),
|
||||
ParamSpec::makeInt("tileswap_count", "Tile count", 64, 1, 4096,
|
||||
"How many tiles are dropped on the canvas; scattered layout only."),
|
||||
ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false,
|
||||
"Take tiles from the images in order instead of picking randomly."),
|
||||
ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 30.0,
|
||||
"Swaps per second of each tile, rounded so that the clip loops "
|
||||
"without a jump; 0 for none.",
|
||||
0.1, 1),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Settings {
|
||||
std::string layout;
|
||||
int tileWidth;
|
||||
int tileHeight;
|
||||
int jitter;
|
||||
long count;
|
||||
bool keepOrder;
|
||||
double rate;
|
||||
|
||||
explicit Settings(const ParamValues& values)
|
||||
: layout(paramChoice(values, "tileswap_layout", "grid")),
|
||||
tileWidth(static_cast<int>(std::max(1L, paramInt(values, "tileswap_width", 96)))),
|
||||
tileHeight(static_cast<int>(std::max(1L, paramInt(values, "tileswap_height", 96)))),
|
||||
jitter(static_cast<int>(std::max(0L, paramInt(values, "tileswap_jitter", 0)))),
|
||||
count(std::max(1L, paramInt(values, "tileswap_count", 64))),
|
||||
keepOrder(paramBool(values, "tileswap_keep_order", false)),
|
||||
rate(std::max(0.0, paramDouble(values, "tileswap_rate", 1.0))) {}
|
||||
};
|
||||
|
||||
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
|
||||
|
||||
// Where a tile sits for the whole clip, when in its swaps it turns over, and
|
||||
// the seed its cuts come from.
|
||||
struct Cell {
|
||||
int x;
|
||||
int y;
|
||||
int w;
|
||||
int h;
|
||||
double phase;
|
||||
std::uint64_t seed;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TileSwapEffect::compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const {
|
||||
const Settings settings(values);
|
||||
const bool scatter = settings.layout == "scatter";
|
||||
Image canvas(width, height);
|
||||
|
||||
// What stays put for the whole clip: the cells, and for loose tiles the
|
||||
// backdrop they lie on.
|
||||
Rng steady = at.steady();
|
||||
std::vector<Cell> cells;
|
||||
auto addCell = [&](int x, int y, int w, int h) {
|
||||
cells.push_back(Cell{x, y, w, h, 0.0, 0});
|
||||
};
|
||||
if (scatter) {
|
||||
const Image& src = sources[steady.below(sources.size() - 1)];
|
||||
const int sx = static_cast<int>(steady.inRange(0, std::max(0, src.width() - width)));
|
||||
const int sy = static_cast<int>(steady.inRange(0, std::max(0, src.height() - height)));
|
||||
fillFromSource(canvas, src, sx, sy);
|
||||
|
||||
cells.reserve(static_cast<std::size_t>(settings.count));
|
||||
for (long i = 0; i < settings.count; ++i) {
|
||||
int w = settings.tileWidth;
|
||||
int h = settings.tileHeight;
|
||||
if (settings.jitter > 0) {
|
||||
w += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
||||
h += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
||||
}
|
||||
const int x = static_cast<int>(steady.inRange(0, width - 1));
|
||||
const int y = static_cast<int>(steady.inRange(0, height - 1));
|
||||
addCell(x, y, std::max(1, std::min(w, width - x)), std::max(1, std::min(h, height - y)));
|
||||
}
|
||||
} else {
|
||||
const bool brick = settings.layout == "brick";
|
||||
const std::vector<int> rows = planSpans(steady, settings.tileHeight, settings.jitter, height);
|
||||
std::vector<int> columns;
|
||||
if (!brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
|
||||
int y = 0;
|
||||
for (const int rowHeight : rows) {
|
||||
if (brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
|
||||
int x = 0;
|
||||
for (const int cellWidth : columns) {
|
||||
addCell(x, y, cellWidth, rowHeight);
|
||||
x += cellWidth;
|
||||
}
|
||||
y += rowHeight;
|
||||
}
|
||||
}
|
||||
for (Cell& cell : cells) {
|
||||
cell.phase = unit(steady);
|
||||
cell.seed = steady.next();
|
||||
}
|
||||
|
||||
// Where this frame is in time: each tile has swapped a whole number of
|
||||
// times, counted round the clip so that the frame after the last shows
|
||||
// what the first does.
|
||||
long swaps = 0;
|
||||
double perSecond = 0.0;
|
||||
if (settings.rate > 0.0) {
|
||||
perSecond = at.loopRate(settings.rate);
|
||||
swaps = std::max(1L, std::lround(perSecond * at.length()));
|
||||
}
|
||||
|
||||
// What a tile shows depends only on the tile and its swap, so it holds
|
||||
// still between swaps and any frame can be drawn on its own.
|
||||
std::vector<Tile> plan;
|
||||
plan.reserve(cells.size());
|
||||
for (std::size_t i = 0; i < cells.size(); ++i) {
|
||||
const Cell& cell = cells[i];
|
||||
std::uint64_t turn = 0;
|
||||
if (swaps > 0) {
|
||||
const auto passed = static_cast<long>(std::floor(at.seconds() * perSecond + cell.phase));
|
||||
turn = static_cast<std::uint64_t>(passed % swaps);
|
||||
}
|
||||
Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL);
|
||||
const std::size_t sourceIndex =
|
||||
settings.keepOrder ? (i + turn) % sources.size() : cut.below(sources.size() - 1);
|
||||
const Image& src = sources[sourceIndex];
|
||||
const int sx = static_cast<int>(cut.inRange(0, std::max(0, src.width() - cell.w)));
|
||||
const int sy = static_cast<int>(cut.inRange(0, std::max(0, src.height() - cell.h)));
|
||||
plan.push_back(Tile{cell.x, cell.y, cell.w, cell.h, static_cast<int>(sourceIndex), sx, sy});
|
||||
}
|
||||
|
||||
paintTiles(canvas, sources, plan, scatter);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "effects/video_effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The tiles effect in motion: the canvas is laid with tiles cut from the
|
||||
// source images, and each tile is swapped for another cut, at a moment of its
|
||||
// own, so the mosaic keeps changing piece by piece.
|
||||
//
|
||||
// The video effect built from the sources rather than transforming its input:
|
||||
// it answers needsSources() and writes compose(). The layout is drawn from
|
||||
// at.steady() and stays put; what a tile shows depends only on the tile and
|
||||
// how many times it has swapped, so it holds still between its swaps.
|
||||
class TileSwapEffect : public VideoEffect {
|
||||
public:
|
||||
std::string name() const override { return "tileswap"; }
|
||||
std::string title() const override { return "Tile swap"; }
|
||||
std::string description() const override {
|
||||
return "Lays tiles cut from the source images over the canvas, as Tiles does, and swaps "
|
||||
"each one for another cut at a moment of its own.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return true; }
|
||||
|
||||
protected:
|
||||
Image compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "core/video.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
@@ -35,6 +36,14 @@ Image VideoEffect::renderFrame(const std::vector<Image>& sources, int width, int
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
const FrameTime at = frame.video() ? frame : FrameTime{0, kDefaultFps, kDefaultFps};
|
||||
|
||||
// An effect built from the sources takes no crop, only the one seed
|
||||
// everything it draws comes from -- the same value on every frame.
|
||||
if (needsSources()) {
|
||||
if (sources.empty()) throw EffectError("No source images.");
|
||||
const std::uint64_t seed = rng.next();
|
||||
return compose(sources, width, height, values, Moment(at, seed));
|
||||
}
|
||||
|
||||
// Both of these take the same values from the shared rng on every frame:
|
||||
// the crop a leading stage starts from, and the one seed everything the
|
||||
// effect draws comes from.
|
||||
@@ -44,4 +53,14 @@ Image VideoEffect::renderFrame(const std::vector<Image>& sources, int width, int
|
||||
return animate(std::move(input), values, Moment(at, seed));
|
||||
}
|
||||
|
||||
// A video effect writes one of these two; the other is never called.
|
||||
Image VideoEffect::animate(Image, const ParamValues&, const Moment&) const {
|
||||
throw std::logic_error(name() + " is a transform but writes no animate()");
|
||||
}
|
||||
|
||||
Image VideoEffect::compose(const std::vector<Image>&, int, int, const ParamValues&,
|
||||
const Moment&) const {
|
||||
throw std::logic_error(name() + " needs sources but writes no compose()");
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
|
||||
@@ -52,17 +52,22 @@ private:
|
||||
// hands the effect generators of its own through the Moment. The stages
|
||||
// after it therefore see the rng in one state throughout and hold still
|
||||
// while it moves.
|
||||
// - It is a transform: it needs no source images, and leading a chain it
|
||||
// starts from a crop of one, as the other transforms do.
|
||||
// - It is one of two kinds. A transform (the default) writes animate(): it
|
||||
// needs no source images, and leading a chain it starts from a crop of
|
||||
// one, as the other transforms do. One that builds its frames out of the
|
||||
// sources answers needsSources() and writes compose() instead: it is
|
||||
// handed the loaded photographs when it leads a chain and the stage before
|
||||
// it otherwise, as the image effects are, and takes no crop.
|
||||
// - It never runs in a still project (the chain skips it). Asked for a still
|
||||
// directly, it gives the first frame of a one-second clip.
|
||||
//
|
||||
// A new video effect subclasses this, declares its params like any effect,
|
||||
// and writes animate(); see TrackingEffect for one.
|
||||
// and writes animate() or compose(); see TrackingEffect for a transform and
|
||||
// TileSwapEffect for one built from the sources.
|
||||
class VideoEffect : public Effect {
|
||||
public:
|
||||
bool video() const final { return true; }
|
||||
bool needsSources() const final { return false; }
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const final;
|
||||
@@ -72,8 +77,14 @@ public:
|
||||
protected:
|
||||
// One frame of the clip: `input` is the picture this stage is handed,
|
||||
// already at the canvas size, and what comes back is the same size.
|
||||
// Every random value comes from `at`, never from anywhere else.
|
||||
virtual Image animate(Image input, const ParamValues& values, const Moment& at) const = 0;
|
||||
// Every random value comes from `at`, never from anywhere else. A
|
||||
// transform writes this.
|
||||
virtual Image animate(Image input, const ParamValues& values, const Moment& at) const;
|
||||
|
||||
// One frame built out of `sources` (never empty) at the canvas size, for
|
||||
// an effect that answers needsSources(); the same rule on randomness.
|
||||
virtual Image compose(const std::vector<Image>& sources, int width, int height,
|
||||
const ParamValues& values, const Moment& at) const;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
|
||||
Reference in New Issue
Block a user