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)};