From efee1cf22413cdefa2cd0122b7d3be741bba8b8d Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 16:55:00 +0200 Subject: [PATCH] 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 --- CMakeLists.txt | 1 + src/core/registry.cpp | 2 + src/effects/monochrome.cpp | 105 +++++++++++++++++++++++++++++++++++++ src/effects/monochrome.hpp | 28 ++++++++++ tests/core_tests.cpp | 92 ++++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+) create mode 100644 src/effects/monochrome.cpp create mode 100644 src/effects/monochrome.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 75540c1..f47c058 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,6 +29,7 @@ add_library(effector_core STATIC src/core/rng.cpp src/effects/blur.cpp src/effects/grain.cpp + src/effects/monochrome.cpp src/effects/scanlines.cpp ) target_include_directories(effector_core PUBLIC src) diff --git a/src/core/registry.cpp b/src/core/registry.cpp index 03633d7..efd0358 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -2,6 +2,7 @@ #include "effects/blur.hpp" #include "effects/grain.hpp" +#include "effects/monochrome.hpp" #include "effects/scanlines.hpp" namespace effector { @@ -12,6 +13,7 @@ const std::vector& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/monochrome.cpp b/src/effects/monochrome.cpp new file mode 100644 index 0000000..dd291a4 --- /dev/null +++ b/src/effects/monochrome.cpp @@ -0,0 +1,105 @@ +#include "effects/monochrome.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& MonochromeEffect::params() const { + static const std::vector specs = { + ParamSpec::makeChoice("mono_weighting", "Weighting", "luminance", + {"luminance", "average", "red", "green", "blue"}, + "How the three channels are mixed into one grey; the colour names " + "weigh that channel most, like a filter over the lens."), + ParamSpec::makeDouble("mono_contrast", "Contrast", 1.0, 0.1, 4.0, + "Pull of the greys away from mid grey; 1 leaves them as they are."), + ParamSpec::makeChoice("mono_tone", "Toner", "none", + {"none", "sepia", "selenium", "cyanotype"}, + "Tints the finished greys, as a darkroom toner would."), + ParamSpec::makeDouble("mono_strength", "Strength", 1.0, 0.0, 1.0, + "How far the result is mixed back over the colour input."), + }; + return specs; +} + +namespace { + +// Channel weights summing to 1024, so a grey is one integer multiply-add away. +std::array weightsFor(const std::string& weighting) { + if (weighting == "average") return {341, 342, 341}; + if (weighting == "red") return {819, 154, 51}; + if (weighting == "green") return {154, 819, 51}; + if (weighting == "blue") return {154, 51, 819}; + return {218, 732, 74}; // Rec. 709 luminance. +} + +// Tint multipliers per channel, as 256ths. +std::array tintFor(const std::string& tone) { + if (tone == "sepia") return {282, 251, 205}; + if (tone == "selenium") return {261, 251, 271}; + if (tone == "cyanotype") return {205, 243, 307}; + return {256, 256, 256}; +} + +} // namespace + +Image MonochromeEffect::render(const std::vector& 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."); + + const auto weights = weightsFor(paramChoice(values, "mono_weighting", "luminance")); + const auto tint = tintFor(paramChoice(values, "mono_tone", "none")); + const double contrast = std::clamp(paramDouble(values, "mono_contrast", 1.0), 0.1, 4.0); + const double strength = std::clamp(paramDouble(values, "mono_strength", 1.0), 0.0, 1.0); + + 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(rng.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - height))); + fillFromSource(canvas, src, sx, sy); + } + + if (strength <= 0.0) return canvas; + + // Contrast and toner only ever see one grey, so the whole tail of the + // conversion is a table: grey in, the three toned channels out. + std::array, 3> table{}; + for (int grey = 0; grey < 256; ++grey) { + const int pulled = + std::clamp(static_cast(128.0 + (grey - 128) * contrast + 0.5), 0, 255); + for (int c = 0; c < 3; ++c) { + table[static_cast(c)][static_cast(grey)] = + static_cast( + std::clamp(pulled * tint[static_cast(c)] / 256, 0, 255)); + } + } + + const int mix = static_cast(strength * 256.0 + 0.5); + + parallelFor(static_cast(height), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + std::uint8_t* out = canvas.row(static_cast(y)); + for (int x = 0; x < width; ++x) { + std::uint8_t* px = out + static_cast(x) * 3; + const int grey = + (px[0] * weights[0] + px[1] * weights[1] + px[2] * weights[2]) / 1024; + for (int c = 0; c < 3; ++c) { + const int toned = table[static_cast(c)] + [static_cast(std::clamp(grey, 0, 255))]; + px[c] = static_cast(px[c] + (toned - px[c]) * mix / 256); + } + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/monochrome.hpp b/src/effects/monochrome.hpp new file mode 100644 index 0000000..d116b02 --- /dev/null +++ b/src/effects/monochrome.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Converts its input to black and white, with the choice of channel weighting +// a photographer would make behind the lens (a red filter darkening a sky, for +// instance), an optional contrast pull and an optional toner. Like the blur it +// needs no source images of its own, so it can lead a chain; given sources it +// fills the canvas from one of them first. +class MonochromeEffect : public Effect { + public: + std::string name() const override { return "monochrome"; } + std::string title() const override { return "Black and white"; } + std::string description() const override { + return "Converts its input to black and white with a choice of channel weighting, an " + "optional contrast pull and an optional toner; with sources loaded it fills the " + "canvas from a randomly chosen one first."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index eb4541b..ea4ed87 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -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& sources, int wid return effect.render(sources, width, height, rng, merged); } +effector::Image renderMono(const std::vector& 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 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 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 sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};