Files
OmaPhoto/tests/DitherKernelTests.cpp
T

327 lines
15 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "Document/BrushStroke.h"
#include "Document/Dither.h"
#include <QPainter>
#include <QPainterPath>
#include <QtTest>
#include <set>
// Dither's styles and colours, pixel for pixel, by hand.
namespace {
QImage filled(int width, int height, QColor colour)
{
QImage image = BrushRaster::context(width, height, false);
image.fill(colour);
return image;
}
DitherSettings style(DitherStyle each, double pixelSize = 1)
{
DitherSettings settings;
settings.style = each;
settings.pixelSize = pixelSize;
return settings;
}
// White pixels as ones, row by row.
QString bits(const QImage &image)
{
QString text;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x)
text += qRed(image.pixel(x, y)) == 255 ? u'1' : u'0';
text += u'/';
}
return text;
}
std::array<int, 4> bytes(const QImage &image, int x, int y)
{
const uchar *pixel = image.constScanLine(y) + x * 4;
return {pixel[0], pixel[1], pixel[2], pixel[3]};
}
}
class DitherKernelTests : public QObject {
Q_OBJECT
private slots:
void eachDiffusionPassesItsOwnShare();
void eachBayerMatrixHasItsOwnPattern();
void eachHalftoneShapeMarksItsOwnPixels();
void macPatternsFillByTheirTone();
void marksTakeTheOriginalOrTwoColours();
void aBlockAveragesPremultipliedPixels();
void coloursRoundAndOriginalDotsGapBlack();
void eachRangeClampsAndRounds();
void aStraightSourceIsPremultipliedFirst();
void theCellSizeReachesTheScreen();
void overlappingOutlinesStayInked();
};
void DitherKernelTests::eachDiffusionPassesItsOwnShare()
{
// Half gray: Atkinson passes 1/8 on, Floyd–Steinberg 7/16.
QCOMPARE(bits(style(DitherStyle::atkinson).apply(filled(3, 1, QColor(128, 128, 128)))), QString("100/"));
QCOMPARE(bits(style(DitherStyle::floydSteinberg).apply(filled(3, 1, QColor(128, 128, 128)))), QString("101/"));
}
void DitherKernelTests::eachBayerMatrixHasItsOwnPattern()
{
// Half gray: white where the threshold passes one half.
QCOMPARE(bits(style(DitherStyle::bayer4).apply(filled(4, 4, QColor(128, 128, 128)))), QString("0101/1010/0101/1010/"));
QCOMPARE(bits(style(DitherStyle::bayer8).apply(filled(8, 2, QColor(128, 128, 128)))), QString("01010101/10101010/"));
// Gray 96 tells the nested matrices apart.
QCOMPARE(bits(style(DitherStyle::bayer2).apply(filled(4, 4, QColor(96, 96, 96)))), QString("0101/1010/0101/1010/"));
QCOMPARE(bits(style(DitherStyle::bayer4).apply(filled(4, 4, QColor(96, 96, 96)))), QString("0001/1010/0100/1010/"));
}
void DitherKernelTests::eachHalftoneShapeMarksItsOwnPixels()
{
// Gray 87, 0°, cells of 8: dots, diamonds, lines differ.
const QImage source = filled(8, 8, QColor(87, 87, 87));
const auto lit = [&source](DitherStyle each, int x, int y) {
DitherSettings halftone = style(each);
halftone.angle = 0;
halftone.cellSize = 8;
return qRed(halftone.apply(source).pixel(x, y)) == 255;
};
QVERIFY(lit(DitherStyle::dots, 1, 4) && lit(DitherStyle::dots, 4, 1));
QVERIFY(!lit(DitherStyle::diamonds, 1, 4) && !lit(DitherStyle::diamonds, 4, 1));
QVERIFY(lit(DitherStyle::lines, 1, 4) && !lit(DitherStyle::lines, 4, 1));
}
void DitherKernelTests::macPatternsFillByTheirTone()
{
// Half gray lights pattern 8: rows 0x88, 0x55, 0x22.
QCOMPARE(bits(style(DitherStyle::patterns).apply(filled(8, 4, QColor(128, 128, 128)))), QString("10001000/01010101/00100010/01010101/"));
}
void DitherKernelTests::marksTakeTheOriginalOrTwoColours()
{
const QImage source = filled(8, 8, QColor(200, 100, 50));
// Original: marks in the pixel's colour on black or white.
for (const bool glowing : {true, false}) {
DitherSettings dots = style(DitherStyle::dots);
dots.colors = DitherColors::original;
dots.lightOnDark = glowing;
const QImage out = dots.apply(source);
std::set<QRgb> found;
for (int y = 0; y < 8; ++y)
for (int x = 0; x < 8; ++x)
found.insert(out.pixel(x, y));
QCOMPARE(found, (std::set<QRgb>{qRgb(200, 100, 50), glowing ? qRgb(0, 0, 0) : qRgb(255, 255, 255)}));
}
// Two Colors: light ink on dark, dark on light.
DitherSettings two = style(DitherStyle::dots);
two.colors = DitherColors::twoColors;
two.dark = AdjustmentColor(0, 0, 1);
two.light = AdjustmentColor(1, 1, 0);
two.angle = 0;
const QImage glowing = two.apply(filled(8, 8, QColor(87, 87, 87)));
two.lightOnDark = false;
const QImage inked = two.apply(filled(8, 8, QColor(87, 87, 87)));
QCOMPARE(glowing.pixel(4, 4), qRgb(255, 255, 0));
QCOMPARE(glowing.pixel(0, 0), qRgb(0, 0, 255));
QCOMPARE(inked.pixel(4, 4), qRgb(0, 0, 255));
QCOMPARE(inked.pixel(0, 0), qRgb(255, 255, 0));
}
void DitherKernelTests::aBlockAveragesPremultipliedPixels()
{
// A straight (64, 128, 192, 128) beside three clear.
QImage source = BrushRaster::context(2, 2, false);
source.setPixelColor(0, 0, QColor(64, 128, 192, 128));
QCOMPARE(bytes(source, 0, 0), (std::array<int, 4>{32, 64, 96, 128}));
DitherSettings original = style(DitherStyle::atkinson, 2);
original.colors = DitherColors::original;
original.levels = 8;
original.diffusion = 0;
// Averaged (8, 16, 24, 32); tones 2/7, 4/7, 5/7.
const QImage out = original.apply(source);
for (int y = 0; y < 2; ++y)
for (int x = 0; x < 2; ++x)
QCOMPARE(bytes(out, x, y), (std::array<int, 4>{9, 18, 23, 32}));
}
void DitherKernelTests::coloursRoundAndOriginalDotsGapBlack()
{
// (0.5, 0.1, 0.3) is (128, 26, 77), rounded.
DitherSettings two = style(DitherStyle::bayer2);
two.colors = DitherColors::twoColors;
two.dark = AdjustmentColor(0.5, 0.1, 0.3);
QCOMPARE(two.apply(filled(2, 2, Qt::black)).pixel(0, 0), qRgb(128, 26, 77));
// Original dots gap black, whatever dark colour is kept.
DitherSettings dots = style(DitherStyle::bayer2, 8);
dots.pixelShape = DitherPixelShape::dot;
dots.colors = DitherColors::original;
dots.dark = AdjustmentColor(1, 0, 0);
const QImage out = dots.apply(filled(8, 8, Qt::white));
QCOMPARE(out.pixel(0, 0), qRgb(0, 0, 0));
QCOMPARE(out.pixel(4, 4), qRgb(255, 255, 255));
// A dot's rim is smoothed: part colour, part gap.
const int rim = qRed(out.pixel(0, 4));
QVERIFY2(rim > 0 && rim < 255, qPrintable(QString::number(rim)));
// One-pixel dots are squares: no gaps.
dots.pixelSize = 1;
QCOMPARE(dots.apply(filled(8, 8, Qt::white)).pixel(0, 0), qRgb(255, 255, 255));
}
void DitherKernelTests::eachRangeClampsAndRounds()
{
const auto normal = [](const std::function<void(DitherSettings &)> &change) {
DitherSettings settings;
change(settings);
return settings.normalized();
};
QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = 2.5; }).pixelSize, 3.0);
QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = 32; }).pixelSize, 32.0);
QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = 0; }).pixelSize, 1.0);
QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = -std::numeric_limits<double>::infinity(); }).pixelSize, 2.0);
QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 64; }).cellSize, 64.0);
QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 65; }).cellSize, 64.0);
QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 12.5; }).cellSize, 13.0);
QCOMPARE(normal([](DitherSettings &d) { d.levels = 8; }).levels, 8.0);
QCOMPARE(normal([](DitherSettings &d) { d.levels = 9; }).levels, 8.0);
QCOMPARE(normal([](DitherSettings &d) { d.levels = 1; }).levels, 2.0);
// Angle, diffusion, density and contrast keep their fractions.
const DitherSettings fractions = normal([](DitherSettings &d) {
d.angle = 12.25;
d.diffusion = 33.5;
d.density = -7.75;
d.contrast = 99.5;
});
QVERIFY(fractions.angle == 12.25 && fractions.diffusion == 33.5 && fractions.density == -7.75 && fractions.contrast == 99.5);
QCOMPARE(normal([](DitherSettings &d) { d.angle = -91; }).angle, -90.0);
QCOMPARE(normal([](DitherSettings &d) { d.angle = 180; }).angle, 90.0);
QCOMPARE(normal([](DitherSettings &d) { d.angle = std::numeric_limits<double>::infinity(); }).angle, 45.0);
QCOMPARE(normal([](DitherSettings &d) { d.cellSize = std::nan(""); }).cellSize, 8.0);
QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 1; }).cellSize, 4.0);
QCOMPARE(normal([](DitherSettings &d) { d.levels = std::nan(""); }).levels, 2.0);
QCOMPARE(normal([](DitherSettings &d) { d.diffusion = std::nan(""); }).diffusion, 100.0);
QCOMPARE(normal([](DitherSettings &d) { d.diffusion = 101; }).diffusion, 100.0);
QCOMPARE(normal([](DitherSettings &d) { d.density = std::nan(""); }).density, 0.0);
QCOMPARE(normal([](DitherSettings &d) { d.density = -101; }).density, -100.0);
QCOMPARE(normal([](DitherSettings &d) { d.contrast = -200; }).contrast, -100.0);
QCOMPARE(normal([](DitherSettings &d) { d.contrast = 101; }).contrast, 100.0);
// A lone CR is a line break too.
QCOMPARE(normal([](DitherSettings &d) { d.characters = QStringLiteral("a\rb"); }).characters, QString("ab"));
}
void DitherKernelTests::aStraightSourceIsPremultipliedFirst()
{
// Straight alpha, clear neighbours hiding green: premultiplied first.
QImage straight(2, 2, QImage::Format_RGBA8888);
straight.fill(QColor(0, 255, 0, 0));
uchar *first = straight.scanLine(0);
first[0] = 64;
first[1] = 128;
first[2] = 192;
first[3] = 128;
DitherSettings original = style(DitherStyle::atkinson, 2);
original.colors = DitherColors::original;
original.levels = 8;
original.diffusion = 0;
const QImage out = original.apply(straight);
for (int y = 0; y < 2; ++y)
for (int x = 0; x < 2; ++x)
QCOMPARE(bytes(out, x, y), (std::array<int, 4>{9, 18, 23, 32}));
}
void DitherKernelTests::theCellSizeReachesTheScreen()
{
// Lines at gray 87, 0°: cells 12 and 8 differ.
const auto lit = [](double cell, int x, int y) {
DitherSettings lines = style(DitherStyle::lines);
lines.angle = 0;
lines.cellSize = cell;
return qRed(lines.apply(filled(16, 16, QColor(87, 87, 87))).pixel(x, y)) == 255;
};
QVERIFY(!lit(12, 4, 3) && lit(12, 4, 7));
QVERIFY(lit(8, 4, 3) && !lit(8, 4, 7));
// ASCII: one character tiled a letter wide, a line tall.
DitherSettings ascii = style(DitherStyle::ascii);
ascii.textSize = 12;
ascii.characters = QStringLiteral("#");
const DitherSettings::Glyphs glyph = DitherSettings::glyphs(QStringLiteral("#"), 12);
const int w = glyph.width, h = glyph.height;
QCOMPARE(h, 12);
const QImage out = ascii.apply(filled(4 * w, 2 * h, Qt::white));
for (int y = 0; y < h; ++y)
for (int x = 0; x < w; ++x) {
QCOMPARE(out.pixel(x + w, y + h), out.pixel(x, y));
QCOMPARE(qRed(out.pixel(x, y)), int(std::lround(glyph.maps[size_t(y * w + x)] / 255.0f * 255.0f)));
}
// Two characters over dark and light columns, cell by cell.
DitherSettings two = style(DitherStyle::ascii);
two.textSize = 12;
two.characters = QStringLiteral(" #");
QImage halves(4 * w, 3 * h, QImage::Format_RGBA8888_Premultiplied);
halves.fill(Qt::black);
QPainter(&halves).fillRect(QRect(2 * w, 0, 2 * w, 3 * h), Qt::white);
const QImage picked = two.apply(halves);
for (int y = 0; y < 3 * h; ++y)
for (int x = 0; x < 4 * w; ++x) {
// Light columns draw the hash, dark ones the paper.
const int expected = x < 2 * w ? 0 : int(std::lround(glyph.maps[size_t((y % h) * w + x % w)] / 255.0f * 255.0f));
QCOMPARE(qRed(picked.pixel(x, y)), expected);
}
// Each cell's pick from its whole cell's ink, edges included.
DitherSettings many = style(DitherStyle::ascii);
many.textSize = 12;
const DitherSettings::Glyphs set = DitherSettings::glyphs(DitherSettings::defaultCharacters(), 12);
const int pictureWidth = 5 * w + 3, pictureHeight = 3 * h + 5;
QImage pattern(pictureWidth, pictureHeight, QImage::Format_RGBA8888_Premultiplied);
for (int y = 0; y < pictureHeight; ++y)
for (int x = 0; x < pictureWidth; ++x)
pattern.setPixelColor(x, y, (x * 7 + y * 13) % 17 < 9 ? Qt::white : Qt::black);
const QImage drawn = many.apply(pattern);
for (int cellY = 0; cellY * h < pictureHeight; ++cellY)
for (int cellX = 0; cellX * w < pictureWidth; ++cellX) {
int white = 0, count = 0;
for (int y = cellY * h; y < std::min(pictureHeight, (cellY + 1) * h); ++y)
for (int x = cellX * w; x < std::min(pictureWidth, (cellX + 1) * w); ++x) {
white += qRed(pattern.pixel(x, y)) == 255;
++count;
}
const float wanted = float(white) / float(count) * set.coverage.back();
size_t best = 0;
for (size_t index = 1; index < set.coverage.size(); ++index)
best = std::abs(set.coverage[index] - wanted) < std::abs(set.coverage[best] - wanted) ? index : best;
for (int y = cellY * h; y < std::min(pictureHeight, (cellY + 1) * h); ++y)
for (int x = cellX * w; x < std::min(pictureWidth, (cellX + 1) * w); ++x)
QCOMPARE(qRed(drawn.pixel(x, y)), int(std::lround(set.maps[best * size_t(w * h) + size_t((y % h) * w + x % w)] / 255.0f * 255.0f)));
}
}
void DitherKernelTests::overlappingOutlinesStayInked()
{
// An accent over a letter: text rendering inks their overlap.
const QString character = QStringLiteral("A\u0340");
const DitherSettings::Glyphs glyph = DitherSettings::glyphs(character, 64);
QFont font(QStringLiteral("monospace"));
font.setWeight(QFont::Bold);
font.setPixelSize(53);
const QFontMetricsF metrics(font);
// Text drawn on the glyph's own baseline and place.
const double baseline = 64 - std::round((64 - (metrics.ascent() + metrics.descent())) / 2 + metrics.descent());
QImage text(glyph.width, glyph.height, QImage::Format_Grayscale8);
text.fill(0);
{
QPainter painter(&text);
painter.setRenderHint(QPainter::Antialiasing);
painter.setFont(font);
painter.setPen(Qt::white);
painter.drawText(QPointF(std::round((glyph.width - metrics.horizontalAdvance(character)) / 2), baseline), character);
}
int holes = 0, inked = 0;
for (int y = 0; y < glyph.height; ++y)
for (int x = 0; x < glyph.width; ++x) {
holes += text.constScanLine(y)[x] > 200 && glyph.maps[size_t(y * glyph.width + x)] < 50;
inked += text.constScanLine(y)[x] > 200;
}
QVERIFY(inked > 100);
QVERIFY2(holes <= 2, qPrintable(QString::number(holes)));
}
QTEST_MAIN(DitherKernelTests)
#include "DitherKernelTests.moc"