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:
@@ -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<EffectPtr>& effects() {
|
||||
std::make_shared<ScanlinesEffect>(),
|
||||
std::make_shared<BlurEffect>(),
|
||||
std::make_shared<GrainEffect>(),
|
||||
std::make_shared<MonochromeEffect>(),
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user