A layered image editor for Linux and Omarchy, ported from Compositor (github.com/robbietilton/Compositor), whose Swift source is its specification. It reads and writes Compositor's .comp projects. Packages for Ubuntu, Fedora and Arch come from the release workflow; scripts/install/ holds one install script per distribution family, and flake.nix serves NixOS. Co-Authored-By: Claude <noreply@anthropic.com>
303 lines
13 KiB
C++
303 lines
13 KiB
C++
#include "BrushFixtures.h"
|
|
#include "Document/EditorSession.h"
|
|
#include "Document/PixelAdjust.h"
|
|
#include "IO/ImageExporter.h"
|
|
#include "SessionFixtures.h"
|
|
#include <cmath>
|
|
|
|
// Swift's BlurTool and Core Image's Gaussian, twinned.
|
|
namespace {
|
|
// Normalised bell weights, three sigmas each way.
|
|
std::vector<double> bell(double sigma)
|
|
{
|
|
const int reach = int(std::ceil(3 * sigma));
|
|
std::vector<double> weights;
|
|
double total = 0;
|
|
for (int i = 0; i <= reach; ++i) {
|
|
weights.push_back(std::exp(-double(i * i) / (2 * sigma * sigma)));
|
|
total += i == 0 ? weights.back() : 2 * weights.back();
|
|
}
|
|
for (double &weight : weights)
|
|
weight /= total;
|
|
return weights;
|
|
}
|
|
|
|
std::vector<int> exported(const EditorSession &session, int x, int y)
|
|
{
|
|
return pixel(ImageExporter::render(session.projectSnapshot().value()).image, x, y);
|
|
}
|
|
|
|
// Red left of x 30, blue right: a hard edge.
|
|
QImage edge(int width, int height)
|
|
{
|
|
QImage image = BrushRaster::context(width, height, false);
|
|
QPainter painter(&image);
|
|
painter.fillRect(QRect(0, 0, 30, height), QColor(255, 0, 0));
|
|
painter.fillRect(QRect(30, 0, width - 30, height), QColor(0, 0, 255));
|
|
return image;
|
|
}
|
|
|
|
void blurring(EditorSession &session, const BrushSettings &settings)
|
|
{
|
|
session.createDocument(60, 40);
|
|
const QImage image = edge(60, 40);
|
|
session.insert(ImportedImage(image, image, QStringLiteral("Edge")));
|
|
session.selectTool(NavigationTool::blur);
|
|
session.setBlurMode(BlurToolMode::blur);
|
|
session.setBrushSettings(settings);
|
|
}
|
|
}
|
|
|
|
class BlurToolTests : public QObject {
|
|
Q_OBJECT
|
|
private slots:
|
|
void aPointSpreadsAlongTheBellCurve();
|
|
void clearEdgesFadeAndClampedEdgesHold();
|
|
void grayBlursAndBadInputIsRefused();
|
|
void theSampleFollowsTheBrushSize();
|
|
void aMaskSampleKeepsItsEdgeToneAndPlacement();
|
|
void aBlurStrokeSoftensUnderTheBrushAsOneStep();
|
|
void aBlurStrokeSoftensAMask();
|
|
void aMaskBetweenPixelsTakesTheFraction();
|
|
void aMaskPastTheCanvasEdgeKeepsItsTone();
|
|
};
|
|
|
|
void BlurToolTests::aPointSpreadsAlongTheBellCurve()
|
|
{
|
|
QImage point = BrushRaster::context(41, 41, false);
|
|
point.setPixelColor(20, 20, QColor(255, 255, 255));
|
|
const QImage soft = PixelAdjust::gaussianBlur(point, 2, false);
|
|
const std::vector<double> weights = bell(2);
|
|
for (const auto &[dx, dy] : {std::pair(0, 0), std::pair(1, 0), std::pair(0, 3), std::pair(2, 2), std::pair(-4, 1), std::pair(6, 0), std::pair(0, -6)}) {
|
|
const int expected = int(std::lround(255 * weights[size_t(std::abs(dx))] * weights[size_t(std::abs(dy))]));
|
|
const std::vector<int> found = pixel(soft, 20 + dx, 20 + dy);
|
|
QVERIFY2(std::abs(found[3] - expected) <= 1 && found[0] == found[3], qPrintable(QString("%1,%2: %3 for %4").arg(dx).arg(dy).arg(found[3]).arg(expected)));
|
|
}
|
|
// Past three sigmas nothing arrives.
|
|
QCOMPARE(pixel(soft, 27, 20)[3], 0);
|
|
QCOMPARE(pixel(soft, 20, 13)[3], 0);
|
|
// A line keeps the far taps a point loses.
|
|
QImage line = BrushRaster::context(41, 41, false);
|
|
QPainter(&line).fillRect(QRect(20, 0, 1, 41), Qt::white);
|
|
const QImage spread = PixelAdjust::gaussianBlur(line, 2, true);
|
|
for (int dx = 0; dx <= 7; ++dx)
|
|
QCOMPARE(pixel(spread, 20 + dx, 20)[3], int(std::lround(255 * (dx < int(weights.size()) ? weights[size_t(dx)] : 0.0))));
|
|
QCOMPARE(pixel(spread, 25, 20)[3], 2);
|
|
QCOMPARE(pixel(spread, 26, 20)[3], 1);
|
|
// What spreads is what was there.
|
|
long total = 0;
|
|
for (int y = 0; y < 41; ++y)
|
|
for (int x = 0; x < 41; ++x)
|
|
total += pixel(soft, x, y)[3];
|
|
QVERIFY2(std::abs(total - 255) < 40, qPrintable(QString::number(total)));
|
|
}
|
|
|
|
void BlurToolTests::clearEdgesFadeAndClampedEdgesHold()
|
|
{
|
|
QImage red = BrushRaster::context(30, 30, false);
|
|
red.fill(QColor(255, 0, 0));
|
|
// Past the edge: clear blurs in, unless clamped to it.
|
|
const QImage faded = PixelAdjust::gaussianBlur(red, 3, false);
|
|
const std::vector<double> weights = bell(3);
|
|
double half = weights[0];
|
|
for (size_t i = 1; i < weights.size(); ++i)
|
|
half += weights[i];
|
|
QVERIFY(std::abs(pixel(faded, 0, 0)[3] - int(std::lround(255 * half * half))) <= 1);
|
|
QVERIFY(std::abs(pixel(faded, 0, 15)[3] - int(std::lround(255 * half))) <= 1);
|
|
QCOMPARE(pixel(faded, 15, 15), (std::vector<int>{255, 0, 0, 255}));
|
|
const QImage held = PixelAdjust::gaussianBlur(red, 3, true);
|
|
QCOMPARE(held, red);
|
|
}
|
|
|
|
void BlurToolTests::grayBlursAndBadInputIsRefused()
|
|
{
|
|
QImage step(20, 10, QImage::Format_Grayscale8);
|
|
for (int y = 0; y < 10; ++y)
|
|
for (int x = 0; x < 20; ++x)
|
|
step.scanLine(y)[x] = x < 10 ? 0 : 255;
|
|
const QImage soft = PixelAdjust::gaussianBlur(step, 1.5, true);
|
|
QCOMPARE(soft.format(), QImage::Format_Grayscale8);
|
|
// Every row softens alike; clamped, the edges hold.
|
|
QCOMPARE(int(soft.constScanLine(0)[0]), 0);
|
|
QCOMPARE(int(soft.constScanLine(9)[19]), 255);
|
|
QCOMPARE(int(soft.constScanLine(0)[9]), int(soft.constScanLine(9)[9]));
|
|
QVERIFY(soft.constScanLine(5)[9] > 40 && soft.constScanLine(5)[9] < 128 && soft.constScanLine(5)[10] > 128);
|
|
QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::gaussianBlur(step, 0, true));
|
|
QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::gaussianBlur(step, std::nan(""), true));
|
|
QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::gaussianBlur(step.convertToFormat(QImage::Format_RGB32), 2, true));
|
|
}
|
|
|
|
void BlurToolTests::theSampleFollowsTheBrushSize()
|
|
{
|
|
EditorSession session;
|
|
blurring(session, BrushSettings{.diameter = 60});
|
|
const CanvasDocument document = session.document().value();
|
|
const QImage sharp = edge(60, 40);
|
|
// A tenth of the size, within 1.5 and 30.
|
|
for (const auto &[diameter, sigma] : {std::pair(60.0, 6.0), std::pair(5.0, 1.5), std::pair(900.0, 30.0)}) {
|
|
BrushSettings settings = session.brushSettings();
|
|
settings.diameter = diameter;
|
|
session.setBrushSettings(settings);
|
|
QCOMPARE(session.blurSample(document).value(), PixelAdjust::gaussianBlur(sharp, sigma, false));
|
|
}
|
|
// No layer, nothing to soften.
|
|
session.selectLayer(std::nullopt);
|
|
QVERIFY(!session.blurSample(document));
|
|
QVERIFY(!session.blurSample(document, true));
|
|
}
|
|
|
|
void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement()
|
|
{
|
|
EditorSession session;
|
|
blurring(session, BrushSettings{.diameter = 20});
|
|
const QUuid id = session.activeLayerID().value();
|
|
// A white mask with a black bar, placed mid-layer.
|
|
QImage mask(20, 20, QImage::Format_Grayscale8);
|
|
mask.fill(255);
|
|
for (int y = 0; y < 20; ++y)
|
|
for (int x = 8; x < 12; ++x)
|
|
mask.scanLine(y)[x] = 0;
|
|
rewrite(session, [&](ProjectSnapshot &snapshot) {
|
|
setMask(snapshot, id, LayerMask::assetFrom(mask));
|
|
record(snapshot, id).maskPlacement = LayerTransform{.origin = {20, 10}, .size = {20, 20}};
|
|
record(snapshot, id).maskLinked = false;
|
|
});
|
|
const QImage sample = session.blurSample(session.document().value(), true).value();
|
|
QCOMPARE(sample.format(), QImage::Format_Grayscale8);
|
|
QImage expected(60, 40, QImage::Format_Grayscale8);
|
|
// Past its pixels the mask keeps its edge's tone: white.
|
|
expected.fill(255);
|
|
QPainter painter(&expected);
|
|
painter.drawImage(QRectF(20, 10, 20, 20), mask);
|
|
painter.end();
|
|
QCOMPARE(sample, PixelAdjust::gaussianBlur(expected, 2, true));
|
|
QVERIFY(sample.constScanLine(20)[30] < 128 && sample.constScanLine(2)[2] == 255);
|
|
// Turned, the black box turns with the mask.
|
|
EditorSession turned;
|
|
turned.createDocument(100, 60);
|
|
const QImage wide = edge(100, 60);
|
|
turned.insert(ImportedImage(wide, wide, QStringLiteral("Edge")));
|
|
const QUuid wideID = turned.activeLayerID().value();
|
|
QImage white(40, 40, QImage::Format_Grayscale8);
|
|
white.fill(255);
|
|
rewrite(turned, [&](ProjectSnapshot &snapshot) {
|
|
setMask(snapshot, wideID, LayerMask::assetFrom(white));
|
|
record(snapshot, wideID).maskPlacement = LayerTransform{.origin = {30, 10}, .size = {40, 40}, .rotation = 30};
|
|
record(snapshot, wideID).maskLinked = false;
|
|
});
|
|
turned.setBrushSettings(BrushSettings{.diameter = 5});
|
|
const QImage soft = turned.blurSample(turned.document().value(), true).value();
|
|
// Past the true box, inside one turned the other way.
|
|
QVERIFY2(soft.constScanLine(36)[73] > 240, qPrintable(QString::number(soft.constScanLine(36)[73])));
|
|
QVERIFY2(soft.constScanLine(24)[27] > 240, qPrintable(QString::number(soft.constScanLine(24)[27])));
|
|
}
|
|
|
|
void BlurToolTests::aBlurStrokeSoftensUnderTheBrushAsOneStep()
|
|
{
|
|
EditorSession session;
|
|
blurring(session, BrushSettings{.diameter = 20, .hardness = 1});
|
|
const int steps = session.history.undoCount();
|
|
session.beginBrush(QPointF(30, 12));
|
|
QVERIFY(session.brushStroke() && session.brushStroke()->isBlur && !session.warpStroke());
|
|
session.continueBrush(QPointF(30, 20));
|
|
session.finishBrush();
|
|
QCOMPARE(session.history.undoCount(), steps + 1);
|
|
QCOMPARE(session.history.undoName(), QString("Blur"));
|
|
// Under the brush, the sample's edge; away, still hard.
|
|
const QImage soft = PixelAdjust::gaussianBlur(edge(60, 40), 2, false);
|
|
QCOMPARE(exported(session, 29, 16), pixel(soft, 29, 16));
|
|
QCOMPARE(exported(session, 30, 16), pixel(soft, 30, 16));
|
|
QVERIFY(exported(session, 29, 16)[2] > 20);
|
|
QCOMPARE(exported(session, 29, 36), (std::vector<int>{255, 0, 0, 255}));
|
|
QCOMPARE(exported(session, 30, 36), (std::vector<int>{0, 0, 255, 255}));
|
|
// At half strength, halfway between sharp and soft.
|
|
session.undo();
|
|
BrushSettings half = session.brushSettings();
|
|
half.opacity = 0.5;
|
|
session.setBrushSettings(half);
|
|
session.beginBrush(QPointF(30, 12));
|
|
session.continueBrush(QPointF(30, 20));
|
|
session.finishBrush();
|
|
const int blended = exported(session, 29, 16)[2];
|
|
QVERIFY2(std::abs(blended - pixel(soft, 29, 16)[2] / 2) <= 1, qPrintable(QString::number(blended)));
|
|
}
|
|
|
|
void BlurToolTests::aBlurStrokeSoftensAMask()
|
|
{
|
|
EditorSession session;
|
|
blurring(session, BrushSettings{.diameter = 20, .hardness = 1});
|
|
const QUuid id = session.activeLayerID().value();
|
|
QImage mask(60, 40, QImage::Format_Grayscale8);
|
|
for (int y = 0; y < 40; ++y)
|
|
for (int x = 0; x < 60; ++x)
|
|
mask.scanLine(y)[x] = x < 30 ? 0 : 255;
|
|
rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(mask)); });
|
|
session.selectLayerTarget(id, true);
|
|
session.selectTool(NavigationTool::blur);
|
|
session.beginBrush(QPointF(30, 12));
|
|
session.continueBrush(QPointF(30, 20));
|
|
session.finishBrush();
|
|
QCOMPARE(session.history.undoName(), QString("Paint Mask"));
|
|
const QImage painted = layerWith(session, id).mask.value().asset.image();
|
|
const QImage soft = PixelAdjust::gaussianBlur(mask, 2, true);
|
|
QCOMPARE(int(painted.constScanLine(16)[29]), int(soft.constScanLine(16)[29]));
|
|
QCOMPARE(int(painted.constScanLine(16)[30]), int(soft.constScanLine(16)[30]));
|
|
QVERIFY(painted.constScanLine(16)[29] > 20 && painted.constScanLine(16)[30] < 235);
|
|
QCOMPARE(int(painted.constScanLine(36)[29]), 0);
|
|
}
|
|
|
|
void BlurToolTests::aMaskBetweenPixelsTakesTheFraction()
|
|
{
|
|
// Half a pixel right: the mask's grid falls between.
|
|
ImageLayer layer(QStringLiteral("Blank"), QSizeF(40, 20));
|
|
layer.transform.origin = QPointF(0.5, 0);
|
|
QImage clear(40, 20, QImage::Format_Grayscale8);
|
|
clear.fill(0);
|
|
layer.mask = LayerMask(LayerMask::assetFrom(clear));
|
|
BrushSettings settings = brush(20, 1, 1, 1, 1);
|
|
BrushStroke paint(layer, true, settings, QSizeF(80, 20));
|
|
QImage steps(80, 20, QImage::Format_Grayscale8);
|
|
for (int y = 0; y < 20; ++y)
|
|
for (int x = 0; x < 80; ++x)
|
|
steps.scanLine(y)[x] = x < 21 ? 0 : 200;
|
|
paint.clone = BrushStroke::Clone{steps, QSizeF(0, 0)};
|
|
paint.isBlur = true;
|
|
paint.append(QPointF(21, 10));
|
|
const QImage tile = paint.patches().at(0).image;
|
|
QCOMPARE(tile.format(), QImage::Format_Grayscale8);
|
|
// Mask pixel 20 spans document 20.5 to 21.5.
|
|
QCOMPARE(int(tile.constScanLine(10)[19]), 0);
|
|
QCOMPARE(int(tile.constScanLine(10)[20]), 100);
|
|
QCOMPARE(int(tile.constScanLine(10)[21]), 200);
|
|
}
|
|
|
|
void BlurToolTests::aMaskPastTheCanvasEdgeKeepsItsTone()
|
|
{
|
|
// Half a pixel past the left edge: half covered.
|
|
ImageLayer layer(QStringLiteral("Blank"), QSizeF(20, 20));
|
|
layer.transform.origin = QPointF(-0.5, 0);
|
|
QImage white(20, 20, QImage::Format_Grayscale8);
|
|
white.fill(255);
|
|
layer.mask = LayerMask(LayerMask::assetFrom(white));
|
|
BrushStroke paint(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20));
|
|
paint.clone = BrushStroke::Clone{white, QSizeF(0, 0)};
|
|
paint.isBlur = true;
|
|
paint.append(QPointF(0, 10));
|
|
const QImage tile = paint.patches().at(0).image;
|
|
QCOMPARE(int(tile.constScanLine(10)[0]), 255);
|
|
QCOMPARE(int(tile.constScanLine(10)[1]), 255);
|
|
// Over a black mask, the covered half alone turns white.
|
|
QImage black(20, 20, QImage::Format_Grayscale8);
|
|
black.fill(0);
|
|
layer.mask = LayerMask(LayerMask::assetFrom(black));
|
|
BrushStroke over(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20));
|
|
over.clone = BrushStroke::Clone{white, QSizeF(0, 0)};
|
|
over.isBlur = true;
|
|
over.append(QPointF(0, 10));
|
|
QCOMPARE(int(over.patches().at(0).image.constScanLine(10)[0]), 128);
|
|
QCOMPARE(int(over.patches().at(0).image.constScanLine(10)[1]), 255);
|
|
}
|
|
|
|
QTEST_GUILESS_MAIN(BlurToolTests)
|
|
#include "BlurToolTests.moc"
|