#include "BrushFixtures.h" #include "Document/EditorSession.h" #include "Document/PixelAdjust.h" #include "IO/ImageExporter.h" #include "SessionFixtures.h" #include // Swift's BlurTool and Core Image's Gaussian, twinned. namespace { // Normalised bell weights, three sigmas each way. std::vector bell(double sigma) { const int reach = int(std::ceil(3 * sigma)); std::vector 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 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 theSampleFollowsTheRadius(); 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 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 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 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{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::theSampleFollowsTheRadius() { EditorSession session; blurring(session, BrushSettings{.diameter = 60}); const CanvasDocument document = session.document().value(); const QImage sharp = edge(60, 40); // The bar's Radius, within 0.5 and 50, whatever the size. for (const auto &[radius, sigma] : {std::pair(6.0, 6.0), std::pair(0.1, 0.5), std::pair(90.0, 50.0)}) { BrushSettings settings = session.brushSettings(); settings.blurRadius = radius; settings.diameter = 3 * radius; 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, .blurRadius = 2}); 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, .blurRadius = 1.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, .blurRadius = 2}); 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{255, 0, 0, 255})); QCOMPARE(exported(session, 30, 36), (std::vector{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, .blurRadius = 2}); 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"