Filter › Dither, as Compositor 1.3.3 (12.15)
This commit is contained in:
1 parent
aa5a44e517
commit
bc9d43f1e8
25 files changed
+1973
-17
No files matched your search
@@ -68,7 +68,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
|
||||
QStringList filters;
|
||||
for (QAction *entry : menus[5]->actions())
|
||||
filters << entry->text();
|
||||
QCOMPARE(filters, (QStringList{"Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"}));
|
||||
QCOMPARE(filters, (QStringList{"Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Dither…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"}));
|
||||
QCOMPARE(menus[6]->actions().size(), 22);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
#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 at its own cell size.
|
||||
DitherSettings ascii = style(DitherStyle::ascii);
|
||||
ascii.cellSize = 12;
|
||||
ascii.characters = QStringLiteral("#");
|
||||
const QImage out = ascii.apply(filled(24, 24, Qt::white));
|
||||
for (int y = 0; y < 12; ++y)
|
||||
for (int x = 0; x < 12; ++x)
|
||||
QCOMPARE(out.pixel(x + 12, y + 12), out.pixel(x, y));
|
||||
const std::vector<uint8_t> map = DitherSettings::glyphs(QStringLiteral("#"), 12).maps;
|
||||
for (int index = 0; index < 144; ++index)
|
||||
QCOMPARE(qRed(out.pixel(index % 12, index / 12)), int(std::lround(map[size_t(index)] / 255.0f * 255.0f)));
|
||||
}
|
||||
|
||||
void DitherKernelTests::overlappingOutlinesStayInked()
|
||||
{
|
||||
// An accent over a letter: text rendering inks their overlap.
|
||||
const QString character = QStringLiteral("A\u0340");
|
||||
const int cell = 64;
|
||||
const std::vector<uint8_t> map = DitherSettings::glyphs(character, cell).maps;
|
||||
QFont font(QStringLiteral("monospace"));
|
||||
font.setWeight(QFont::Bold);
|
||||
font.setPixelSize(74);
|
||||
QPainterPath path;
|
||||
path.addText(0, 0, font, character);
|
||||
const QRectF bounds = path.boundingRect();
|
||||
QImage text(cell, cell, QImage::Format_Grayscale8);
|
||||
text.fill(0);
|
||||
{
|
||||
QPainter painter(&text);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
painter.setFont(font);
|
||||
painter.setPen(Qt::white);
|
||||
painter.drawText(QPointF((cell - bounds.width()) / 2 - bounds.left(), (cell - bounds.height()) / 2 - bounds.top()), character);
|
||||
}
|
||||
int holes = 0;
|
||||
for (int y = 0; y < cell; ++y)
|
||||
for (int x = 0; x < cell; ++x)
|
||||
holes += text.constScanLine(y)[x] > 200 && map[size_t(y * cell + x)] < 50;
|
||||
QVERIFY2(holes <= 2, qPrintable(QString::number(holes)));
|
||||
}
|
||||
|
||||
QTEST_MAIN(DitherKernelTests)
|
||||
#include "DitherKernelTests.moc"
|
||||
@@ -0,0 +1,440 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "UI/ColorPaletteControls.h"
|
||||
#include "UI/FilterSheet.h"
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QLabel>
|
||||
#include <QLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QProgressBar>
|
||||
#include <QSlider>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift 1.3.3's Dither: the kind, its colours, its sheet.
|
||||
namespace {
|
||||
struct Shown {
|
||||
EditorSession session;
|
||||
QImage image;
|
||||
std::unique_ptr<FilterSheet> sheet;
|
||||
explicit Shown(int width = 40, int height = 30)
|
||||
{
|
||||
session.createDocument(width, height);
|
||||
image = BrushRaster::context(width, height, false);
|
||||
for (int y = 0; y < height; ++y)
|
||||
for (int x = 0; x < width; ++x)
|
||||
image.setPixelColor(x, y, QColor(x * 255 / std::max(1, width - 1), 120, 200));
|
||||
session.insert(ImportedImage(image, image, QStringLiteral("Colour")));
|
||||
session.beginFilter(FilterKind::dither);
|
||||
sheet = std::make_unique<FilterSheet>(session);
|
||||
sheet->show();
|
||||
}
|
||||
const DitherSettings &dither() const { return session.filterEdit().value().settings.dither; }
|
||||
void set(const std::function<void(DitherSettings &)> &change)
|
||||
{
|
||||
FilterSettings settings = session.filterEdit().value().settings;
|
||||
change(settings.dither);
|
||||
session.updateFilter(settings, true);
|
||||
}
|
||||
template <typename Widget> Widget &child(const char *name) const
|
||||
{
|
||||
auto *found = sheet->findChild<Widget *>(QString::fromLatin1(name));
|
||||
if (!found)
|
||||
throw std::runtime_error(name);
|
||||
return *found;
|
||||
}
|
||||
// The rows shown, by their titles, sorted.
|
||||
QStringList shown() const
|
||||
{
|
||||
QStringList titles;
|
||||
for (const QLabel *label : sheet->findChildren<QLabel *>()) {
|
||||
if (label->buddy() && !label->parentWidget()->isHidden() && label->isVisibleTo(sheet.get()))
|
||||
titles << label->text();
|
||||
}
|
||||
for (const QCheckBox *box : sheet->findChildren<QCheckBox *>()) {
|
||||
if (box->isVisibleTo(sheet.get()) && box->text() != QLatin1String("Preview"))
|
||||
titles << box->text();
|
||||
}
|
||||
if (child<QWidget>("ditherDark").isVisibleTo(sheet.get()))
|
||||
titles << QStringLiteral("Swatches");
|
||||
titles.sort();
|
||||
return titles;
|
||||
}
|
||||
};
|
||||
|
||||
QStringList sorted(QStringList titles)
|
||||
{
|
||||
titles.sort();
|
||||
return titles;
|
||||
}
|
||||
|
||||
void pick(EditorSession &session, const PaletteColor &color)
|
||||
{
|
||||
PickerHSB hsb = session.colorPicker().value().hsb;
|
||||
hsb.setRGB(color);
|
||||
session.setColorPickerHSB(hsb);
|
||||
}
|
||||
}
|
||||
|
||||
class DitherSheetTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void theKindSitsUnderBloomAndPreviewsWhole();
|
||||
void itsCommitIsTheSettingsApplied();
|
||||
void theTwoColoursHaveTheirPicker();
|
||||
void theControlsFollowTheStyle();
|
||||
void theMenusAndFieldsWriteTheSettings();
|
||||
void theSheetPreviewsThePickersColour();
|
||||
void eachNumberWritesItsOwnSetting();
|
||||
void leftTheCharactersShowWhatWasKept();
|
||||
void theSlidersReachTheirEnds();
|
||||
void aCommitSaysItIsApplying();
|
||||
void thePickerRefusesWhenItCannot();
|
||||
void anAsciiCommitDrawsOnTheWorker();
|
||||
void rowsKeepSwiftsOrderHelpAndScale();
|
||||
};
|
||||
|
||||
void DitherSheetTests::theKindSitsUnderBloomAndPreviewsWhole()
|
||||
{
|
||||
QCOMPARE(rawValue(FilterKind::dither), QString("Dither"));
|
||||
const auto at = std::find(allFilterKinds.begin(), allFilterKinds.end(), FilterKind::dither);
|
||||
QVERIFY(at != allFilterKinds.end() && *(at - 1) == FilterKind::bloomGlow && *(at + 1) == FilterKind::tonalContrast);
|
||||
QVERIFY(!isAutomatic(FilterKind::dither) && !isImageAdjustment(FilterKind::dither));
|
||||
// Past the preview limit, Dither still previews at full size.
|
||||
EditorSession session;
|
||||
session.createDocument(3000, 10);
|
||||
const QImage wide = BrushRaster::context(3000, 10, false);
|
||||
session.insert(ImportedImage(wide, wide, QStringLiteral("Wide")));
|
||||
session.beginFilter(FilterKind::dither);
|
||||
QCOMPARE(session.filterEdit().value().previewScale, 1.0);
|
||||
QCOMPARE(session.filterEdit().value().previewSource.size(), QSize(3000, 10));
|
||||
session.cancelFilter();
|
||||
session.beginFilter(FilterKind::gaussianBlur);
|
||||
QVERIFY(session.filterEdit().value().previewScale < 1);
|
||||
}
|
||||
|
||||
void DitherSheetTests::itsCommitIsTheSettingsApplied()
|
||||
{
|
||||
Shown shown;
|
||||
shown.set([](DitherSettings &dither) {
|
||||
dither.style = DitherStyle::bayer4;
|
||||
dither.pixelSize = 3;
|
||||
});
|
||||
const DitherSettings settings = shown.dither();
|
||||
bool done = false;
|
||||
shown.session.commitFilter([&done] { done = true; });
|
||||
QTRY_VERIFY(done);
|
||||
QCOMPARE(shown.session.history.undoName(), QString("Dither"));
|
||||
QCOMPARE(shown.session.activeLayer().value().asset.value().image(), settings.apply(shown.image));
|
||||
}
|
||||
|
||||
void DitherSheetTests::theTwoColoursHaveTheirPicker()
|
||||
{
|
||||
Shown shown;
|
||||
shown.session.openDitherColorPicker(false);
|
||||
QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Dither Dark Color)"));
|
||||
QVERIFY(shown.session.colorPicker().value().original == PaletteColor::black());
|
||||
pick(shown.session, PaletteColor{0, 0, 1});
|
||||
shown.session.previewDitherColor();
|
||||
QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 1));
|
||||
// Cancel puts it back; OK on the light keeps it.
|
||||
shown.session.closeColorPicker(false);
|
||||
QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0));
|
||||
shown.session.openDitherColorPicker(true);
|
||||
QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Dither Light Color)"));
|
||||
pick(shown.session, PaletteColor{1, 1, 0});
|
||||
shown.session.closeColorPicker(true);
|
||||
QVERIFY(shown.dither().light == AdjustmentColor(1, 1, 0) && shown.dither().dark == AdjustmentColor(0, 0, 0));
|
||||
// The swatches open it; the panel's close takes it along.
|
||||
shown.set([](DitherSettings &dither) { dither.colors = DitherColors::twoColors; });
|
||||
QTest::mouseClick(&shown.child<SwatchButton>("ditherDark"), Qt::LeftButton);
|
||||
QVERIFY(shown.session.colorPicker().has_value() && !shown.session.colorPicker().value().target.light);
|
||||
pick(shown.session, PaletteColor{1, 0, 0});
|
||||
shown.session.previewDitherColor();
|
||||
shown.session.cancelFilter();
|
||||
QVERIFY(!shown.session.colorPicker() && !shown.session.filterEdit());
|
||||
// Not over another filter.
|
||||
shown.session.beginFilter(FilterKind::gaussianBlur);
|
||||
shown.session.openDitherColorPicker(true);
|
||||
QVERIFY(!shown.session.colorPicker());
|
||||
}
|
||||
|
||||
void DitherSheetTests::theControlsFollowTheStyle()
|
||||
{
|
||||
Shown shown;
|
||||
const QStringList common{"Style", "Pixel Size", "Density", "Contrast", "Colors", "Pixel Shape"};
|
||||
QCOMPARE(shown.shown(), sorted(common + QStringList{"Tones", "Diffusion"}));
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::bayer8; });
|
||||
QCOMPARE(shown.shown(), sorted(common + QStringList{"Tones"}));
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::lines; });
|
||||
QCOMPARE(shown.shown(), sorted(common + QStringList{"Cell Size", "Angle", "Light on Dark"}));
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
|
||||
QCOMPARE(shown.shown(), sorted(common + QStringList{"Cell Size", "Characters", "Light on Dark"}));
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::patterns; });
|
||||
QCOMPARE(shown.shown(), sorted(common + QStringList{"Light on Dark"}));
|
||||
// Pixel Shape needs chunky pixels; the swatches, Two Colors.
|
||||
shown.set([](DitherSettings &dither) {
|
||||
dither.pixelSize = 1;
|
||||
dither.colors = DitherColors::twoColors;
|
||||
});
|
||||
QCOMPARE(shown.shown(), sorted(QStringList{"Style", "Pixel Size", "Density", "Contrast", "Colors", "Light on Dark", "Swatches"}));
|
||||
// Style's menu: ten looks in four groups.
|
||||
QComboBox &style = shown.child<QComboBox>("ditherStyle");
|
||||
QStringList items;
|
||||
for (int index = 0; index < style.count(); ++index)
|
||||
items << (style.itemText(index).isEmpty() ? QStringLiteral("—") : style.itemText(index));
|
||||
QCOMPARE(items, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "—", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "—", "Halftone Dots",
|
||||
"Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII"}));
|
||||
QCOMPARE(style.currentText(), QString("Mac Patterns"));
|
||||
// Its quick preview never shows the busy line.
|
||||
QVERIFY(!shown.child<QProgressBar>("filterSpinner").isVisibleTo(shown.sheet.get()));
|
||||
}
|
||||
|
||||
void DitherSheetTests::theMenusAndFieldsWriteTheSettings()
|
||||
{
|
||||
Shown shown;
|
||||
QComboBox &style = shown.child<QComboBox>("ditherStyle");
|
||||
const int ascii = style.findData(int(DitherStyle::ascii));
|
||||
style.setCurrentIndex(ascii);
|
||||
emit style.activated(ascii);
|
||||
QVERIFY(shown.dither().style == DitherStyle::ascii);
|
||||
QComboBox &colors = shown.child<QComboBox>("ditherColors");
|
||||
colors.setCurrentIndex(2);
|
||||
emit colors.activated(2);
|
||||
QVERIFY(shown.dither().colors == DitherColors::original);
|
||||
QComboBox &shape = shown.child<QComboBox>("ditherPixelShape");
|
||||
shape.setCurrentIndex(1);
|
||||
emit shape.activated(1);
|
||||
QVERIFY(shown.dither().pixelShape == DitherPixelShape::dot);
|
||||
// Characters as typed, stored without line breaks.
|
||||
QLineEdit &characters = shown.child<QLineEdit>("ditherCharacters");
|
||||
QCOMPARE(characters.text(), DitherSettings::defaultCharacters());
|
||||
characters.setFocus();
|
||||
characters.selectAll();
|
||||
QTest::keyClicks(&characters, QStringLiteral("#o."));
|
||||
QCOMPARE(shown.dither().characters, QString("#o."));
|
||||
// Unfocused, it shows the settings as they stand.
|
||||
characters.clearFocus();
|
||||
shown.set([](DitherSettings &dither) { dither.characters = QStringLiteral("xy"); });
|
||||
QCOMPARE(characters.text(), QString("xy"));
|
||||
}
|
||||
|
||||
void DitherSheetTests::theSheetPreviewsThePickersColour()
|
||||
{
|
||||
Shown shown;
|
||||
shown.set([](DitherSettings &dither) { dither.colors = DitherColors::twoColors; });
|
||||
for (const bool light : {false, true}) {
|
||||
QTest::mouseClick(&shown.child<SwatchButton>(light ? "ditherLight" : "ditherDark"), Qt::LeftButton);
|
||||
QVERIFY(shown.session.colorPicker().has_value() && shown.session.colorPicker().value().target.light == light);
|
||||
// The sheet previews the working colour itself.
|
||||
pick(shown.session, PaletteColor{0, 1, 0});
|
||||
QVERIFY((light ? shown.dither().light : shown.dither().dark) == AdjustmentColor(0, 1, 0));
|
||||
shown.session.closeColorPicker(false);
|
||||
QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0) && shown.dither().light == AdjustmentColor(1, 1, 1));
|
||||
}
|
||||
// The filter's OK takes an open picker's colour along.
|
||||
QTest::mouseClick(&shown.child<SwatchButton>("ditherLight"), Qt::LeftButton);
|
||||
pick(shown.session, PaletteColor{1, 0, 0});
|
||||
DitherSettings expected = shown.dither();
|
||||
expected.light = AdjustmentColor(1, 0, 0);
|
||||
bool done = false;
|
||||
shown.session.commitFilter([&done] { done = true; });
|
||||
QTRY_VERIFY(done);
|
||||
QVERIFY(!shown.session.colorPicker());
|
||||
QCOMPARE(shown.session.activeLayer().value().asset.value().image(), expected.apply(shown.image));
|
||||
}
|
||||
|
||||
void DitherSheetTests::eachNumberWritesItsOwnSetting()
|
||||
{
|
||||
Shown shown;
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::lines; });
|
||||
struct Row {
|
||||
const char *field;
|
||||
double DitherSettings::*setting;
|
||||
const char *typed;
|
||||
double expected;
|
||||
};
|
||||
const Row rows[] = {{"pixelSizeField", &DitherSettings::pixelSize, "5", 5}, {"cellSizeField", &DitherSettings::cellSize, "12", 12},
|
||||
{"angleField", &DitherSettings::angle, "-30", -30}, {"densityField", &DitherSettings::density, "25", 25},
|
||||
{"contrastField", &DitherSettings::contrast, "-40", -40}};
|
||||
for (const Row &row : rows) {
|
||||
const DitherSettings before = shown.dither();
|
||||
QLineEdit &field = shown.child<QLineEdit>(row.field);
|
||||
field.setFocus();
|
||||
field.selectAll();
|
||||
QTest::keyClicks(&field, QString::fromLatin1(row.typed));
|
||||
QTest::keyClick(&field, Qt::Key_Return);
|
||||
DitherSettings expected = before;
|
||||
expected.*row.setting = row.expected;
|
||||
QVERIFY2(shown.dither() == expected, row.field);
|
||||
}
|
||||
// Tones and Diffusion under a diffusion style.
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::floydSteinberg; });
|
||||
for (const auto &[name, setting, value] : {std::tuple("tonesField", &DitherSettings::levels, 4.0), std::tuple("diffusionField", &DitherSettings::diffusion, 60.0)}) {
|
||||
QLineEdit &field = shown.child<QLineEdit>(name);
|
||||
field.setFocus();
|
||||
field.selectAll();
|
||||
QTest::keyClicks(&field, QString::number(value));
|
||||
QTest::keyClick(&field, Qt::Key_Return);
|
||||
QCOMPARE(shown.dither().*setting, value);
|
||||
}
|
||||
// Light on Dark, under marks.
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::dots; });
|
||||
QCheckBox *glow = nullptr;
|
||||
for (QCheckBox *box : shown.sheet->findChildren<QCheckBox *>())
|
||||
glow = box->text() == QLatin1String("Light on Dark") ? box : glow;
|
||||
QVERIFY(glow && glow->isChecked());
|
||||
glow->click();
|
||||
QVERIFY(!shown.dither().lightOnDark);
|
||||
}
|
||||
|
||||
void DitherSheetTests::leftTheCharactersShowWhatWasKept()
|
||||
{
|
||||
Shown shown;
|
||||
QVERIFY(QTest::qWaitForWindowActive(shown.sheet.get()));
|
||||
shown.session.updateFilter(shown.session.filterEdit().value().settings, false);
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
|
||||
shown.session.updateFilter(shown.session.filterEdit().value().settings, false);
|
||||
QLineEdit &characters = shown.child<QLineEdit>("ditherCharacters");
|
||||
characters.setFocus();
|
||||
QTRY_VERIFY(characters.hasFocus());
|
||||
characters.selectAll();
|
||||
QTest::keyClicks(&characters, QString(70, u'x'));
|
||||
// Kept to 64; the typing shows until left.
|
||||
QCOMPARE(shown.dither().characters, QString(64, u'x'));
|
||||
QCOMPARE(characters.text(), QString(70, u'x'));
|
||||
shown.child<QLineEdit>("densityField").setFocus();
|
||||
QTRY_VERIFY(!characters.hasFocus());
|
||||
QCOMPARE(characters.text(), QString(64, u'x'));
|
||||
QVERIFY(!shown.session.filterEdit().value().preview);
|
||||
}
|
||||
|
||||
void DitherSheetTests::theSlidersReachTheirEnds()
|
||||
{
|
||||
Shown shown;
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::lines; });
|
||||
const std::tuple<const char *, double DitherSettings::*, double, double> rows[] = {
|
||||
{"pixelSizeSlider", &DitherSettings::pixelSize, 1, 32}, {"cellSizeSlider", &DitherSettings::cellSize, 4, 64},
|
||||
{"angleSlider", &DitherSettings::angle, -90, 90}, {"densitySlider", &DitherSettings::density, -100, 100},
|
||||
{"contrastSlider", &DitherSettings::contrast, -100, 100}};
|
||||
for (const auto &[name, setting, low, high] : rows) {
|
||||
QSlider &slider = shown.child<QSlider>(name);
|
||||
slider.setValue(slider.maximum());
|
||||
QCOMPARE(shown.dither().*setting, high);
|
||||
slider.setValue(slider.minimum());
|
||||
QCOMPARE(shown.dither().*setting, low);
|
||||
}
|
||||
QCOMPARE(shown.child<QSlider>("pixelSizeSlider").parentWidget()->toolTip(),
|
||||
QString("Make each dithered pixel this many pixels across, for a chunky old-screen look"));
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::atkinson; });
|
||||
for (const auto &[name, setting, low, high] : {std::tuple("tonesSlider", &DitherSettings::levels, 2.0, 8.0),
|
||||
std::tuple("diffusionSlider", &DitherSettings::diffusion, 0.0, 100.0)}) {
|
||||
QSlider &slider = shown.child<QSlider>(name);
|
||||
slider.setValue(slider.maximum());
|
||||
QCOMPARE(shown.dither().*setting, high);
|
||||
slider.setValue(slider.minimum());
|
||||
QCOMPARE(shown.dither().*setting, low);
|
||||
}
|
||||
}
|
||||
|
||||
void DitherSheetTests::aCommitSaysItIsApplying()
|
||||
{
|
||||
// Quick previews never show the line; a commit does.
|
||||
Shown shown;
|
||||
bool done = false;
|
||||
shown.session.commitFilter([&done] { done = true; });
|
||||
QVERIFY(shown.child<QProgressBar>("filterSpinner").isVisibleTo(shown.sheet.get()));
|
||||
QCOMPARE(shown.child<QLabel>("filterActivity").text(), QString("Applying…"));
|
||||
QTRY_VERIFY(done);
|
||||
}
|
||||
|
||||
void DitherSheetTests::thePickerRefusesWhenItCannot()
|
||||
{
|
||||
Shown shown;
|
||||
// Busy, the palette rests.
|
||||
shown.session.setIsProjectBusy(true);
|
||||
shown.session.openDitherColorPicker(false);
|
||||
QVERIFY(!shown.session.colorPicker());
|
||||
shown.session.setIsProjectBusy(false);
|
||||
// Another picker open stays so.
|
||||
shown.session.openDitherColorPicker(true);
|
||||
shown.session.openDitherColorPicker(false);
|
||||
QVERIFY(shown.session.colorPicker().value().target.light);
|
||||
shown.session.closeColorPicker(false);
|
||||
// A style change keeps an open picker on its colour.
|
||||
shown.session.openDitherColorPicker(false);
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
|
||||
pick(shown.session, PaletteColor{1, 0, 1});
|
||||
shown.session.previewDitherColor();
|
||||
QVERIFY(shown.dither().dark == AdjustmentColor(1, 0, 1) && shown.dither().style == DitherStyle::ascii);
|
||||
shown.session.closeColorPicker(false);
|
||||
QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0));
|
||||
}
|
||||
|
||||
void DitherSheetTests::anAsciiCommitDrawsOnTheWorker()
|
||||
{
|
||||
// Glyphs are drawn off the UI thread, in the commit.
|
||||
Shown shown(96, 48);
|
||||
shown.set([](DitherSettings &dither) {
|
||||
dither.style = DitherStyle::ascii;
|
||||
dither.cellSize = 12;
|
||||
dither.pixelSize = 1;
|
||||
});
|
||||
const DitherSettings settings = shown.dither();
|
||||
bool done = false;
|
||||
shown.session.commitFilter([&done] { done = true; });
|
||||
QTRY_VERIFY(done);
|
||||
QCOMPARE(shown.session.activeLayer().value().asset.value().image(), settings.apply(shown.image));
|
||||
}
|
||||
|
||||
void DitherSheetTests::rowsKeepSwiftsOrderHelpAndScale()
|
||||
{
|
||||
Shown shown;
|
||||
// The shown rows, in the column's order, with their help.
|
||||
const auto rows = [&shown] {
|
||||
QStringList titles;
|
||||
QLayout *column = shown.sheet->layout();
|
||||
for (int index = 0; index < column->count(); ++index) {
|
||||
QWidget *row = column->itemAt(index)->widget();
|
||||
if (!row || row->isHidden())
|
||||
continue;
|
||||
if (auto *box = qobject_cast<QCheckBox *>(row); box && box->text() != QLatin1String("Preview"))
|
||||
titles << box->text() + u'|' + box->toolTip();
|
||||
for (QLabel *label : row->findChildren<QLabel *>(QString(), Qt::FindDirectChildrenOnly)) {
|
||||
if (label->buddy())
|
||||
titles << label->text() + u'|' + row->toolTip();
|
||||
}
|
||||
}
|
||||
return titles;
|
||||
};
|
||||
QCOMPARE(rows(), (QStringList{"Style|", "Pixel Size|Make each dithered pixel this many pixels across, for a chunky old-screen look",
|
||||
"Tones|Tones per channel: 2 is pure black and white",
|
||||
"Diffusion|How much of each pixel's error spreads to its neighbors. Less gives flatter areas",
|
||||
"Density|More ink (darker) or less before dithering", "Contrast|", "Colors|",
|
||||
"Pixel Shape|Draw each chunky pixel as a solid square, or as a round dot like a dot-matrix screen"}));
|
||||
shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; });
|
||||
QCOMPARE(rows().mid(2, 2), (QStringList{"Cell Size|", "Characters|The characters to draw with, in any order: each spot gets the one whose ink best matches its tone"}));
|
||||
QCOMPARE(rows().last(), QString("Light on Dark|Draw the marks for the light tones on the dark color, like a glowing screen"));
|
||||
// Linear: half the Pixel Size travel is 16.5, so 17.
|
||||
QSlider &pixels = shown.child<QSlider>("pixelSizeSlider");
|
||||
pixels.setValue(pixels.maximum() / 2);
|
||||
QCOMPARE(shown.dither().pixelSize, 17.0);
|
||||
// Across Colors a picker keeps on; without an edit, none.
|
||||
shown.set([](DitherSettings &dither) { dither.colors = DitherColors::twoColors; });
|
||||
shown.session.updateFilter(shown.session.filterEdit().value().settings, false);
|
||||
QTest::mouseClick(&shown.child<SwatchButton>("ditherDark"), Qt::LeftButton);
|
||||
FilterSettings original = shown.session.filterEdit().value().settings;
|
||||
original.dither.colors = DitherColors::original;
|
||||
shown.session.updateFilter(original, false);
|
||||
pick(shown.session, PaletteColor{0, 1, 1});
|
||||
QVERIFY(shown.dither().dark == AdjustmentColor(0, 1, 1) && shown.dither().colors == DitherColors::original);
|
||||
QVERIFY(!shown.session.filterEdit().value().preview);
|
||||
shown.session.closeColorPicker(false);
|
||||
QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0));
|
||||
shown.session.cancelFilter();
|
||||
shown.session.openDitherColorPicker(false);
|
||||
QVERIFY(!shown.session.colorPicker());
|
||||
}
|
||||
|
||||
QTEST_MAIN(DitherSheetTests)
|
||||
#include "DitherSheetTests.moc"
|
||||
@@ -0,0 +1,363 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/Dither.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include <QtTest>
|
||||
#include <set>
|
||||
|
||||
// Swift 1.3.3's Dither: the model and the kernel through `apply`.
|
||||
namespace {
|
||||
QImage filled(int width, int height, QColor colour)
|
||||
{
|
||||
QImage image = BrushRaster::context(width, height, false);
|
||||
image.fill(colour);
|
||||
return image;
|
||||
}
|
||||
|
||||
// A horizontal ramp of grays, left black, right white.
|
||||
QImage ramp(int width, int height)
|
||||
{
|
||||
QImage image = BrushRaster::context(width, height, false);
|
||||
for (int y = 0; y < height; ++y)
|
||||
for (int x = 0; x < width; ++x)
|
||||
image.setPixelColor(x, y, QColor::fromRgbF(float(x) / float(width - 1), float(x) / float(width - 1), float(x) / float(width - 1)));
|
||||
return image;
|
||||
}
|
||||
|
||||
std::set<QRgb> colours(const QImage &image)
|
||||
{
|
||||
std::set<QRgb> found;
|
||||
for (int y = 0; y < image.height(); ++y)
|
||||
for (int x = 0; x < image.width(); ++x)
|
||||
found.insert(image.pixel(x, y));
|
||||
return found;
|
||||
}
|
||||
|
||||
DitherSettings style(DitherStyle each, double pixelSize = 1)
|
||||
{
|
||||
DitherSettings settings;
|
||||
settings.style = each;
|
||||
settings.pixelSize = pixelSize;
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
class DitherTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void stylesKeepSwiftsNamesOrderAndGroups();
|
||||
void normalizingClampsRoundsAndTrimsTheCharacters();
|
||||
void bayerThresholdsAGrayIntoItsPattern();
|
||||
void diffusionMakesTwoTonesOrMore();
|
||||
void twoColorsAndTheOriginalOnes();
|
||||
void alphaIsKeptAndClearPixelsLeftAlone();
|
||||
void chunkyPixelsFillTheirBlocks();
|
||||
void dotsLeaveTheirCornersToTheDarkColour();
|
||||
void marksDrawLightOnDarkOrDarkOnLight();
|
||||
void glyphsAreSortedByTheirInk();
|
||||
void densityAndContrastMoveTheThreshold();
|
||||
void theScreenTurnsWithItsAngle();
|
||||
void asciiDrawsWithItsOwnCharacters();
|
||||
};
|
||||
|
||||
void DitherTests::stylesKeepSwiftsNamesOrderAndGroups()
|
||||
{
|
||||
QStringList names;
|
||||
for (const DitherStyle each : allDitherStyles)
|
||||
names << rawValue(each);
|
||||
QCOMPARE(names, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "Halftone Dots",
|
||||
"Halftone Lines", "Halftone Diamonds", "Mac Patterns", "ASCII"}));
|
||||
QString groups;
|
||||
for (const DitherStyle each : allDitherStyles)
|
||||
groups += QString::number(ditherGroup(each));
|
||||
QCOMPARE(groups, QString("0011122233"));
|
||||
QVERIFY(diffuses(DitherStyle::floydSteinberg) && !diffuses(DitherStyle::bayer2));
|
||||
QVERIFY(hasTones(DitherStyle::bayer8) && !hasTones(DitherStyle::dots));
|
||||
QVERIFY(isHalftone(DitherStyle::diamonds) && !isHalftone(DitherStyle::patterns));
|
||||
QVERIFY(drawsMarks(DitherStyle::ascii) && drawsMarks(DitherStyle::lines) && !drawsMarks(DitherStyle::atkinson));
|
||||
QCOMPARE(rawValue(DitherPixelShape::dot), QString("Dot"));
|
||||
QCOMPARE(rawValue(DitherColors::twoColors), QString("Two Colors"));
|
||||
// Chunky square pixels, 1-bit Atkinson, glowing marks by default.
|
||||
const DitherSettings defaults;
|
||||
QVERIFY(defaults.style == DitherStyle::atkinson && defaults.pixelSize == 2 && defaults.pixelShape == DitherPixelShape::square);
|
||||
QVERIFY(defaults.levels == 2 && defaults.lightOnDark && defaults.colors == DitherColors::blackWhite);
|
||||
}
|
||||
|
||||
void DitherTests::normalizingClampsRoundsAndTrimsTheCharacters()
|
||||
{
|
||||
DitherSettings wild;
|
||||
wild.pixelSize = 40.6;
|
||||
wild.cellSize = 2;
|
||||
wild.angle = std::nan("");
|
||||
wild.levels = 4.5;
|
||||
wild.diffusion = -5;
|
||||
wild.density = 300;
|
||||
wild.contrast = std::numeric_limits<double>::infinity();
|
||||
wild.dark = AdjustmentColor(-1, 0.5, 2);
|
||||
wild.light = AdjustmentColor(3, -2, 0.25);
|
||||
wild.characters = QStringLiteral("ab\ncd\r\ne\u2028f\u000Bg\u000Ch\u0085i\u2029\t j") + QString(80, u'x');
|
||||
const DitherSettings normal = wild.normalized();
|
||||
QCOMPARE(normal.pixelSize, 32.0);
|
||||
QCOMPARE(normal.cellSize, 4.0);
|
||||
QCOMPARE(normal.angle, 45.0);
|
||||
QCOMPARE(normal.levels, 5.0);
|
||||
QCOMPARE(normal.diffusion, 0.0);
|
||||
QCOMPARE(normal.density, 100.0);
|
||||
QCOMPARE(normal.contrast, 0.0);
|
||||
QVERIFY(normal.dark == AdjustmentColor(0, 0.5, 1) && normal.light == AdjustmentColor(1, 0, 0.25));
|
||||
// Every line break goes, tabs and spaces stay; 64 kept.
|
||||
QCOMPARE(normal.characters, QStringLiteral("abcdefghi\t j") + QString(52, u'x'));
|
||||
// A grapheme of many units counts once, never split.
|
||||
DitherSettings emoji;
|
||||
emoji.characters = QString(63, u'.') + QStringLiteral("\U0001F44D\U0001F3FD") + QStringLiteral("zz");
|
||||
QCOMPARE(emoji.normalized().characters, QString(63, u'.') + QStringLiteral("\U0001F44D\U0001F3FD"));
|
||||
QCOMPARE(DitherSettings().normalized(), DitherSettings());
|
||||
}
|
||||
|
||||
void DitherTests::bayerThresholdsAGrayIntoItsPattern()
|
||||
{
|
||||
// Half gray over Bayer 2 × 2: high thresholds light.
|
||||
const QImage out = style(DitherStyle::bayer2).apply(filled(4, 4, QColor(128, 128, 128)));
|
||||
const QRgb black = qRgb(0, 0, 0), white = qRgb(255, 255, 255);
|
||||
const std::array<QRgb, 4> pattern{black, white, white, black};
|
||||
for (int y = 0; y < 4; ++y)
|
||||
for (int x = 0; x < 4; ++x)
|
||||
QCOMPARE(out.pixel(x, y), pattern[size_t((y & 1) * 2 + (x & 1))]);
|
||||
// Four tones on a ramp: 0, 85, 170, 255 alone.
|
||||
DitherSettings tones = style(DitherStyle::bayer4);
|
||||
tones.levels = 4;
|
||||
std::set<int> levels;
|
||||
const QImage toned = tones.apply(ramp(64, 8));
|
||||
for (int x = 0; x < 64; ++x)
|
||||
levels.insert(qRed(toned.pixel(x, 3)));
|
||||
QCOMPARE(levels, (std::set<int>{0, 85, 170, 255}));
|
||||
}
|
||||
|
||||
void DitherTests::diffusionMakesTwoTonesOrMore()
|
||||
{
|
||||
for (const DitherStyle each : {DitherStyle::atkinson, DitherStyle::floydSteinberg}) {
|
||||
const QImage out = style(each).apply(ramp(64, 16));
|
||||
QCOMPARE(colours(out), (std::set<QRgb>{qRgb(0, 0, 0), qRgb(255, 255, 255)}));
|
||||
// More white towards the light end.
|
||||
int left = 0, right = 0;
|
||||
for (int y = 0; y < 16; ++y) {
|
||||
for (int x = 0; x < 16; ++x) {
|
||||
left += qRed(out.pixel(x, y)) == 255;
|
||||
right += qRed(out.pixel(63 - x, y)) == 255;
|
||||
}
|
||||
}
|
||||
QVERIFY2(left < 40 && right > 216, qPrintable(QStringLiteral("%1 %2").arg(left).arg(right)));
|
||||
}
|
||||
// No diffusion: a flat mid gray rounds whole.
|
||||
DitherSettings flat = style(DitherStyle::atkinson);
|
||||
flat.diffusion = 0;
|
||||
QCOMPARE(colours(flat.apply(filled(8, 8, QColor(140, 140, 140)))), (std::set<QRgb>{qRgb(255, 255, 255)}));
|
||||
}
|
||||
|
||||
void DitherTests::twoColorsAndTheOriginalOnes()
|
||||
{
|
||||
DitherSettings two = style(DitherStyle::bayer8);
|
||||
two.colors = DitherColors::twoColors;
|
||||
two.dark = AdjustmentColor(0.2, 0, 0.4);
|
||||
two.light = AdjustmentColor(1, 0.8, 0.6);
|
||||
QCOMPARE(colours(two.apply(ramp(32, 8))), (std::set<QRgb>{qRgb(51, 0, 102), qRgb(255, 204, 153)}));
|
||||
// Original: red stays red, dithered on its own channels.
|
||||
DitherSettings original = style(DitherStyle::bayer8);
|
||||
original.colors = DitherColors::original;
|
||||
for (const QRgb each : colours(original.apply(filled(8, 8, QColor(255, 0, 0)))))
|
||||
QCOMPARE(each, qRgb(255, 0, 0));
|
||||
}
|
||||
|
||||
void DitherTests::alphaIsKeptAndClearPixelsLeftAlone()
|
||||
{
|
||||
QImage image = ramp(16, 4);
|
||||
image.setPixelColor(0, 0, Qt::transparent);
|
||||
image.setPixelColor(5, 1, QColor::fromRgbF(1, 1, 1, 0.5f));
|
||||
const QImage out = style(DitherStyle::floydSteinberg).apply(image);
|
||||
QCOMPARE(out.pixelColor(0, 0).alpha(), 0);
|
||||
QCOMPARE(out.pixelColor(5, 1).alpha(), 128);
|
||||
for (int x = 1; x < 16; ++x)
|
||||
QCOMPARE(out.pixelColor(x, 3).alpha(), 255);
|
||||
}
|
||||
|
||||
void DitherTests::chunkyPixelsFillTheirBlocks()
|
||||
{
|
||||
const QImage out = style(DitherStyle::bayer8, 4).apply(ramp(18, 10));
|
||||
QCOMPARE(out.size(), QSize(18, 10));
|
||||
// One colour a 4 × 4 block; edge blocks cut.
|
||||
for (int y = 0; y < 10; ++y)
|
||||
for (int x = 0; x < 18; ++x)
|
||||
QCOMPARE(out.pixel(x, y), out.pixel(x / 4 * 4, y / 4 * 4));
|
||||
QCOMPARE(colours(out.copy(0, 0, 16, 8)), (std::set<QRgb>{qRgb(0, 0, 0), qRgb(255, 255, 255)}));
|
||||
// A cut block averages in the clear beyond the edge.
|
||||
QCOMPARE(out.pixelColor(17, 0).alpha(), 128);
|
||||
QCOMPARE(out.pixelColor(0, 9).alpha(), 128);
|
||||
QCOMPARE(out.pixelColor(17, 9).alpha(), 64);
|
||||
// Diffused small: each block's exact average, then its pixel.
|
||||
const QImage source = ramp(18, 10);
|
||||
QImage averaged = BrushRaster::context(5, 3, false);
|
||||
for (int y = 0; y < 3; ++y) {
|
||||
for (int x = 0; x < 5; ++x) {
|
||||
std::array<int, 4> sum{};
|
||||
for (int sy = y * 4; sy < std::min(10, y * 4 + 4); ++sy)
|
||||
for (int sx = x * 4; sx < std::min(18, x * 4 + 4); ++sx)
|
||||
for (int channel = 0; channel < 4; ++channel)
|
||||
sum[size_t(channel)] += source.constScanLine(sy)[sx * 4 + channel];
|
||||
for (int channel = 0; channel < 4; ++channel)
|
||||
averaged.scanLine(y)[x * 4 + channel] = uchar((sum[size_t(channel)] + 8) / 16);
|
||||
}
|
||||
}
|
||||
const QImage small = style(DitherStyle::floydSteinberg).apply(averaged);
|
||||
const QImage chunky = style(DitherStyle::floydSteinberg, 4).apply(source);
|
||||
for (int y = 0; y < 10; ++y)
|
||||
for (int x = 0; x < 18; ++x)
|
||||
QCOMPARE(chunky.pixel(x, y), small.pixel(x / 4, y / 4));
|
||||
// A block's average: half black, half white reads as gray.
|
||||
QImage halves = BrushRaster::context(8, 8, false);
|
||||
for (int y = 0; y < 8; ++y)
|
||||
for (int x = 0; x < 8; ++x)
|
||||
halves.setPixelColor(x, y, x % 2 ? Qt::white : Qt::black);
|
||||
DitherSettings levels = style(DitherStyle::bayer2, 8);
|
||||
levels.levels = 3;
|
||||
QCOMPARE(colours(levels.apply(halves)), (std::set<QRgb>{qRgb(128, 128, 128)}));
|
||||
}
|
||||
|
||||
void DitherTests::dotsLeaveTheirCornersToTheDarkColour()
|
||||
{
|
||||
DitherSettings dots = style(DitherStyle::bayer2, 8);
|
||||
dots.pixelShape = DitherPixelShape::dot;
|
||||
const QImage out = dots.apply(filled(16, 16, Qt::white));
|
||||
// White dots on black, each centred in its block.
|
||||
QCOMPARE(out.pixel(0, 0), qRgb(0, 0, 0));
|
||||
QCOMPARE(out.pixel(4, 4), qRgb(255, 255, 255));
|
||||
QCOMPARE(out.pixel(8, 15), qRgb(0, 0, 0));
|
||||
QCOMPARE(out.pixel(12, 12), qRgb(255, 255, 255));
|
||||
// Two Colors: the gaps are the dark one.
|
||||
dots.colors = DitherColors::twoColors;
|
||||
dots.dark = AdjustmentColor(0, 0, 1);
|
||||
QCOMPARE(dots.apply(filled(16, 16, Qt::white)).pixel(0, 0), qRgb(0, 0, 255));
|
||||
// Square pixels have no gaps.
|
||||
dots.pixelShape = DitherPixelShape::square;
|
||||
QCOMPARE(dots.apply(filled(16, 16, Qt::white)).pixel(0, 0), qRgb(255, 255, 255));
|
||||
}
|
||||
|
||||
void DitherTests::marksDrawLightOnDarkOrDarkOnLight()
|
||||
{
|
||||
for (const DitherStyle each : {DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds, DitherStyle::patterns, DitherStyle::ascii}) {
|
||||
DitherSettings marks = style(each);
|
||||
const QImage glowing = marks.apply(ramp(64, 64));
|
||||
// Hard marks, but for the characters' smoothed ink.
|
||||
if (each == DitherStyle::ascii) {
|
||||
for (const QRgb colour : colours(glowing))
|
||||
QVERIFY(qRed(colour) == qGreen(colour) && qGreen(colour) == qBlue(colour));
|
||||
} else {
|
||||
QCOMPARE(colours(glowing), (std::set<QRgb>{qRgb(0, 0, 0), qRgb(255, 255, 255)}));
|
||||
}
|
||||
marks.lightOnDark = false;
|
||||
const QImage inked = marks.apply(ramp(64, 64));
|
||||
QVERIFY2(inked != glowing, qPrintable(rawValue(each)));
|
||||
// Black stays black, white white, either way.
|
||||
QCOMPARE(qRed(glowing.pixel(0, 32)), 0);
|
||||
QCOMPARE(qRed(inked.pixel(63, 32)), 255);
|
||||
}
|
||||
}
|
||||
|
||||
void DitherTests::glyphsAreSortedByTheirInk()
|
||||
{
|
||||
const DitherSettings::Glyphs glyphs = DitherSettings::glyphs(QStringLiteral("@. .#"), 8);
|
||||
// Four distinct: space, dot, hash, at; 8 × 8 maps.
|
||||
QCOMPARE(glyphs.coverage.size(), size_t(4));
|
||||
QCOMPARE(glyphs.maps.size(), size_t(4 * 64));
|
||||
QCOMPARE(glyphs.coverage[0], 0.0f);
|
||||
QVERIFY(glyphs.coverage[1] > 0 && glyphs.coverage[1] < glyphs.coverage[2] && glyphs.coverage[2] <= glyphs.coverage[3]);
|
||||
QVERIFY(*std::max_element(glyphs.maps.begin() + 192, glyphs.maps.end()) > 200);
|
||||
QCOMPARE(DitherSettings::glyphs(DitherSettings::defaultCharacters(), 12).coverage.size(), size_t(10));
|
||||
// A 20-pixel cell: M at 23 pixels, 15–19 tall.
|
||||
const std::vector<uint8_t> m = DitherSettings::glyphs(QStringLiteral("M"), 20).maps;
|
||||
int top = 20, bottom = -1;
|
||||
bool smooth = false;
|
||||
for (int index = 0; index < 400; ++index) {
|
||||
if (m[size_t(index)] > 128) {
|
||||
top = std::min(top, index / 20);
|
||||
bottom = std::max(bottom, index / 20);
|
||||
}
|
||||
smooth = smooth || (m[size_t(index)] > 0 && m[size_t(index)] < 255);
|
||||
}
|
||||
QVERIFY2(bottom - top + 1 >= 15 && bottom - top + 1 <= 19, qPrintable(QString::number(bottom - top + 1)));
|
||||
QVERIFY(smooth);
|
||||
// Centred on its ink both ways: a dot mid cell.
|
||||
const std::vector<uint8_t> dot = DitherSettings::glyphs(QStringLiteral("."), 20).maps;
|
||||
double sum = 0, sumX = 0, sumY = 0;
|
||||
for (int index = 0; index < 400; ++index) {
|
||||
sum += dot[size_t(index)];
|
||||
sumX += dot[size_t(index)] * (index % 20 + 0.5);
|
||||
sumY += dot[size_t(index)] * (index / 20 + 0.5);
|
||||
}
|
||||
QVERIFY2(std::abs(sumX / sum - 10) < 0.6 && std::abs(sumY / sum - 10) < 0.6, qPrintable(QStringLiteral("%1 %2").arg(sumX / sum).arg(sumY / sum)));
|
||||
// Canonically equal graphemes are one, as Swift's Characters.
|
||||
QCOMPARE(DitherSettings::glyphs(QStringLiteral("\u00E9e\u0301"), 20).coverage.size(), size_t(1));
|
||||
}
|
||||
|
||||
void DitherTests::densityAndContrastMoveTheThreshold()
|
||||
{
|
||||
// Bayer 8 × 8 on flat gray: whites follow tone.
|
||||
const auto whites = [](int gray, double density, double contrast) {
|
||||
DitherSettings settings = style(DitherStyle::bayer8);
|
||||
settings.density = density;
|
||||
settings.contrast = contrast;
|
||||
const QImage out = settings.apply(filled(8, 8, QColor(gray, gray, gray)));
|
||||
int count = 0;
|
||||
for (int y = 0; y < 8; ++y)
|
||||
for (int x = 0; x < 8; ++x)
|
||||
count += qRed(out.pixel(x, y)) == 255;
|
||||
return count;
|
||||
};
|
||||
// Density: gamma 2^(1.5 d), more ink as it rises.
|
||||
QCOMPARE(whites(128, 0, 0), 32);
|
||||
QCOMPARE(whites(128, 50, 0), 20);
|
||||
QCOMPARE(whites(128, -50, 0), 42);
|
||||
// Contrast pivots on mid gray, steeper as it rises.
|
||||
QCOMPARE(whites(153, 0, 0), 38);
|
||||
QCOMPARE(whites(153, 0, 50), 44);
|
||||
QCOMPARE(whites(153, 0, -50), 35);
|
||||
}
|
||||
|
||||
void DitherTests::theScreenTurnsWithItsAngle()
|
||||
{
|
||||
// Halftone lines at 0°: rows alike; at 90°: columns alike.
|
||||
for (const double angle : {0.0, 90.0}) {
|
||||
DitherSettings lines = style(DitherStyle::lines);
|
||||
lines.angle = angle;
|
||||
lines.cellSize = 8;
|
||||
const QImage out = lines.apply(filled(32, 32, QColor(100, 100, 100)));
|
||||
bool alike = true, varied = false;
|
||||
for (int a = 0; a < 32; ++a) {
|
||||
for (int b = 0; b < 32; ++b) {
|
||||
const QRgb here = angle == 0 ? out.pixel(b, a) : out.pixel(a, b);
|
||||
alike = alike && here == (angle == 0 ? out.pixel(0, a) : out.pixel(a, 0));
|
||||
varied = varied || here != out.pixel(0, 0);
|
||||
}
|
||||
}
|
||||
QVERIFY2(alike && varied, qPrintable(QString::number(angle)));
|
||||
}
|
||||
}
|
||||
|
||||
void DitherTests::asciiDrawsWithItsOwnCharacters()
|
||||
{
|
||||
DitherSettings ascii = style(DitherStyle::ascii);
|
||||
ascii.cellSize = 12;
|
||||
const QImage source = ramp(48, 24);
|
||||
// A blank alphabet marks nothing: all the dark paper.
|
||||
ascii.characters = QStringLiteral(" ");
|
||||
QCOMPARE(colours(ascii.apply(source)), (std::set<QRgb>{qRgb(0, 0, 0)}));
|
||||
// None at all: Swift's default alphabet.
|
||||
ascii.characters = QString();
|
||||
const QImage fallback = ascii.apply(source);
|
||||
ascii.characters = DitherSettings::defaultCharacters();
|
||||
QCOMPARE(fallback, ascii.apply(source));
|
||||
// Another alphabet draws otherwise.
|
||||
ascii.characters = QStringLiteral(" #");
|
||||
QVERIFY(ascii.apply(source) != fallback);
|
||||
}
|
||||
|
||||
QTEST_MAIN(DitherTests)
|
||||
#include "DitherTests.moc"
|
||||
@@ -12,6 +12,7 @@ class FilterFailureTests : public QObject {
|
||||
private slots:
|
||||
void theBlursFailAsAContextWhenTheirRowsFindNoMemory();
|
||||
void aTrimThatFindsNoMemoryIsARenderError();
|
||||
void aDitherThatFindsNoMemoryIsARenderError();
|
||||
};
|
||||
|
||||
void FilterFailureTests::theBlursFailAsAContextWhenTheirRowsFindNoMemory()
|
||||
@@ -44,5 +45,35 @@ void FilterFailureTests::aTrimThatFindsNoMemoryIsARenderError()
|
||||
QVERIFY_THROWS_EXCEPTION(ExportError, PixelFilter::trimmed(framed, LayerTransform{.origin = {0, 0}, .size = {6000, 4000}}));
|
||||
}
|
||||
|
||||
void FilterFailureTests::aDitherThatFindsNoMemoryIsARenderError()
|
||||
{
|
||||
DitherSettings dither;
|
||||
dither.pixelSize = 1;
|
||||
// Another format: its conversion finds no memory.
|
||||
QImage plain(4000, 4000, QImage::Format_RGB32);
|
||||
plain.fill(Qt::gray);
|
||||
malloc_trim(0);
|
||||
{
|
||||
const AddressSpaceLimit limit(16ll * 1024 * 1024);
|
||||
QVERIFY_THROWS_EXCEPTION(ExportError, dither.apply(plain));
|
||||
}
|
||||
// The 64 MB copy fits; the kernel's planes do not.
|
||||
const QImage premultiplied = plain.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
|
||||
plain = QImage();
|
||||
malloc_trim(0);
|
||||
{
|
||||
const AddressSpaceLimit limit(96ll * 1024 * 1024);
|
||||
QVERIFY_THROWS_EXCEPTION(ExportError, dither.apply(premultiplied));
|
||||
}
|
||||
// Chunky pixels dither a small copy: the same limit holds.
|
||||
dither.pixelSize = 8;
|
||||
dither.colors = DitherColors::original;
|
||||
malloc_trim(0);
|
||||
{
|
||||
const AddressSpaceLimit limit(96ll * 1024 * 1024);
|
||||
QCOMPARE(dither.apply(premultiplied).size(), QSize(4000, 4000));
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(FilterFailureTests)
|
||||
#include "FilterFailureTests.moc"
|
||||
@@ -66,7 +66,8 @@ void FilterModelTests::kindsKeepSwiftsNames()
|
||||
{
|
||||
const std::pair<FilterKind, const char *> kinds[] = {
|
||||
{FilterKind::gaussianBlur, "Gaussian Blur"}, {FilterKind::motionBlur, "Motion Blur"}, {FilterKind::addNoise, "Add Noise"},
|
||||
{FilterKind::vignette, "Vignette"}, {FilterKind::bloomGlow, "Bloom / Glow"}, {FilterKind::tonalContrast, "Tonal Contrast"},
|
||||
{FilterKind::vignette, "Vignette"}, {FilterKind::bloomGlow, "Bloom / Glow"}, {FilterKind::dither, "Dither"},
|
||||
{FilterKind::tonalContrast, "Tonal Contrast"},
|
||||
{FilterKind::lensCorrection, "Lens Correction"}, {FilterKind::cameraRaw, "Camera Raw Filter"},
|
||||
{FilterKind::removeBackground, "Remove Background"},
|
||||
{FilterKind::contentAwareFill, "Content-Aware Fill"}, {FilterKind::curves, "Curves"}, {FilterKind::exposure, "Exposure"},
|
||||
@@ -79,7 +80,7 @@ void FilterModelTests::kindsKeepSwiftsNames()
|
||||
QVERIFY(allFilterKinds[index] == kind);
|
||||
QCOMPARE(rawValue(kind), QString(name));
|
||||
QCOMPARE(isAutomatic(kind), kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground);
|
||||
QCOMPARE(isImageAdjustment(kind), index >= 10);
|
||||
QCOMPARE(isImageAdjustment(kind), index >= 11);
|
||||
}
|
||||
// Remove Background's qualities, Swift's words and order.
|
||||
QVERIFY(allBackgroundQualities.size() == 2 && allBackgroundQualities[0] == BackgroundQuality::basic);
|
||||
|
||||
@@ -213,7 +213,8 @@ void FinishingFilterTests::settingsKeepSwiftsDefaultsAndBounds()
|
||||
QCOMPARE(rawValue(FilterKind::vignette), QString("Vignette"));
|
||||
QCOMPARE(rawValue(FilterKind::bloomGlow), QString("Bloom / Glow"));
|
||||
QCOMPARE(rawValue(FilterKind::tonalContrast), QString("Tonal Contrast"));
|
||||
QVERIFY(allFilterKinds[3] == FilterKind::vignette && allFilterKinds[4] == FilterKind::bloomGlow && allFilterKinds[5] == FilterKind::tonalContrast);
|
||||
QVERIFY(allFilterKinds[3] == FilterKind::vignette && allFilterKinds[4] == FilterKind::bloomGlow && allFilterKinds[5] == FilterKind::dither
|
||||
&& allFilterKinds[6] == FilterKind::tonalContrast);
|
||||
QVERIFY(!isImageAdjustment(FilterKind::bloomGlow) && !isAutomatic(FilterKind::tonalContrast) && !isAutomatic(FilterKind::vignette));
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,9 @@ compositor_test(TextColorRunsTests)
|
||||
compositor_test(TextColorModelTests)
|
||||
compositor_test(TextColorEditorTests)
|
||||
compositor_test(TextEditingGatesTests)
|
||||
compositor_test(DitherTests)
|
||||
compositor_test(DitherSheetTests)
|
||||
compositor_test(DitherKernelTests)
|
||||
compositor_test(RecentProjectsCoreTests)
|
||||
compositor_test(ProjectWorkspaceSignalTests)
|
||||
compositor_test(ProjectWorkspaceTests)
|
||||
|
||||
Reference in new issue
Block a user