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:
Si
2026-09-18 16:55:00 +02:00
co-authored by Claude Opus 5
parent 67417727e7
commit efee1cf224
5 changed files with 228 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
#include "effects/monochrome.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& MonochromeEffect::params() const {
static const std::vector<ParamSpec> 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<int, 3> 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<int, 3> 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<Image>& 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<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(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<std::array<std::uint8_t, 256>, 3> table{};
for (int grey = 0; grey < 256; ++grey) {
const int pulled =
std::clamp(static_cast<int>(128.0 + (grey - 128) * contrast + 0.5), 0, 255);
for (int c = 0; c < 3; ++c) {
table[static_cast<std::size_t>(c)][static_cast<std::size_t>(grey)] =
static_cast<std::uint8_t>(
std::clamp(pulled * tint[static_cast<std::size_t>(c)] / 256, 0, 255));
}
}
const int mix = static_cast<int>(strength * 256.0 + 0.5);
parallelFor(static_cast<std::size_t>(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<int>(y));
for (int x = 0; x < width; ++x) {
std::uint8_t* px = out + static_cast<std::size_t>(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<std::size_t>(c)]
[static_cast<std::size_t>(std::clamp(grey, 0, 255))];
px[c] = static_cast<std::uint8_t>(px[c] + (toned - px[c]) * mix / 256);
}
}
}
});
return canvas;
}
} // namespace effector
+28
View File
@@ -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<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector