Add a black and white effect
Monochrome is the fourth effect and the third that declares needsSources() false, so it works as a filter stage at the end of a cascade and can also lead one, filling the canvas from a source the way blur and grain do. Its knobs are the channel weighting, the contrast, a toner and the strength: the weighting offers the mixes a photographer would make behind the lens, so the colour names weigh that channel most and "red" darkens a sky the way a red filter does; contrast pulls the greys away from mid grey; the toners tint the finished greys warm (sepia), barely (selenium) or cold (cyanotype); and strength mixes the result back over the colour input, which at 0 is a pass through. Contrast and toner only ever see one grey, so the whole tail of the conversion is folded into a 256-entry table per channel, built once before the parallel pass; the per-pixel work is then integer only, with the weights as 1024ths and the tints as 256ths. The Rng is touched only for the source pick and the crop offset, serially, before any threading. The tests gain a `monochrome` section: neutrality of the output, each weighting measured on a pure red field, the contrast pivot at mid grey, both toner directions, strength as a pass through and as a midpoint, and the sourceless render that stays black. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
#include "core/rng.hpp"
|
||||
#include "effects/blur.hpp"
|
||||
#include "effects/grain.hpp"
|
||||
#include "effects/monochrome.hpp"
|
||||
#include "effects/scanlines.hpp"
|
||||
|
||||
namespace {
|
||||
@@ -106,6 +107,29 @@ effector::Image renderGrain(const std::vector<effector::Image>& sources, int wid
|
||||
return effect.render(sources, width, height, rng, merged);
|
||||
}
|
||||
|
||||
effector::Image renderMono(const std::vector<effector::Image>& sources, int width, int height,
|
||||
std::uint64_t seed, const effector::ParamValues& values) {
|
||||
effector::MonochromeEffect 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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
auto* row = image.row(y);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
row[x * 3 + 0] = r;
|
||||
row[x * 3 + 1] = g;
|
||||
row[x * 3 + 2] = b;
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
// A flat field of one shade, to measure what the grain does to it.
|
||||
effector::Image flat(int width, int height, std::uint8_t shade) {
|
||||
effector::Image image(width, height);
|
||||
@@ -312,6 +336,74 @@ int main() {
|
||||
check(speckled && any, "without sources grain works on an empty canvas");
|
||||
}
|
||||
|
||||
std::cout << "monochrome\n";
|
||||
{
|
||||
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||
|
||||
const auto first = renderMono(sources, 400, 300, 4, {});
|
||||
const auto again = renderMono(sources, 400, 300, 4, {});
|
||||
check(first.width() == 400 && first.height() == 300, "canvas has the requested size");
|
||||
check(sameBytes(first, again), "same seed reproduces the image");
|
||||
|
||||
bool grey = true;
|
||||
for (std::size_t i = 0; i + 2 < first.byteSize(); i += 3) {
|
||||
grey = grey && first.data()[i] == first.data()[i + 1] &&
|
||||
first.data()[i + 1] == first.data()[i + 2];
|
||||
}
|
||||
check(grey, "every pixel comes out neutral grey");
|
||||
|
||||
// A pure red field: the luminance weighting keeps little of it, the red
|
||||
// weighting nearly all, and the blue weighting almost none.
|
||||
std::vector<Image> red = {solid(64, 64, 255, 0, 0)};
|
||||
const auto viaLuma = renderMono(red, 64, 64, 1, {});
|
||||
const auto viaRed = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("red")}});
|
||||
const auto viaBlue = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("blue")}});
|
||||
check(viaLuma.data()[0] > 40 && viaLuma.data()[0] < 70, "luminance darkens a pure red");
|
||||
check(viaRed.data()[0] > 190, "the red weighting keeps a pure red light");
|
||||
check(viaBlue.data()[0] < 50, "the blue weighting renders a pure red dark");
|
||||
|
||||
// Contrast works about mid grey: it leaves that alone and pushes the
|
||||
// shades either side of it apart.
|
||||
const auto midHard =
|
||||
renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1, {{"mono_contrast", 2.0}});
|
||||
check(midHard.data()[0] >= 127 && midHard.data()[0] <= 129, "contrast pivots on mid grey");
|
||||
const auto darkFlat = renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {});
|
||||
const auto darkHard =
|
||||
renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {{"mono_contrast", 2.0}});
|
||||
check(darkHard.data()[0] < darkFlat.data()[0], "contrast deepens the shadows");
|
||||
|
||||
// The toners tint the greys, each in its own direction.
|
||||
const auto sepia = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1,
|
||||
{{"mono_tone", std::string("sepia")}});
|
||||
check(sepia.data()[0] > sepia.data()[1] && sepia.data()[1] > sepia.data()[2],
|
||||
"sepia warms the greys");
|
||||
const auto cyan = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1,
|
||||
{{"mono_tone", std::string("cyanotype")}});
|
||||
check(cyan.data()[2] > cyan.data()[1] && cyan.data()[1] > cyan.data()[0],
|
||||
"the cyanotype toner cools them");
|
||||
|
||||
// Strength mixes back towards the colour input: zero is a pass through,
|
||||
// half sits between the two.
|
||||
check(sameBytes(
|
||||
renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.0}}),
|
||||
solid(32, 32, 200, 40, 10)),
|
||||
"zero strength leaves the colours untouched");
|
||||
const auto half =
|
||||
renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.5}});
|
||||
const auto full = renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {});
|
||||
check(half.data()[0] > full.data()[0] && half.data()[0] < 200,
|
||||
"half strength sits between the grey and the colour");
|
||||
|
||||
// Like blur and grain it needs no sources: with none it converts the
|
||||
// empty canvas, which stays black.
|
||||
const auto blank = renderMono({}, 50, 50, 1, {});
|
||||
bool allBlack = blank.width() == 50 && blank.height() == 50;
|
||||
for (std::size_t i = 0; i < blank.byteSize(); ++i) {
|
||||
allBlack = allBlack && blank.data()[i] == 0;
|
||||
}
|
||||
check(allBlack, "without sources monochrome renders an empty canvas");
|
||||
}
|
||||
|
||||
std::cout << "chain\n";
|
||||
{
|
||||
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||
|
||||
Reference in New Issue
Block a user