Grain and Add Noise stay with the document on the canvas; the GPU canvas's contracts judged and pinned on the CPU canvas (13.9b)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 17:47:52 -04:00
1 parent 06843364fe
commit d2c8507fc7
8 files changed
+402 -31

No files matched your search

+4
View File
@@ -430,6 +430,10 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
live.adjustment = [&](QUuid id) { return byID.contains(id) ? byID.at(id).adjustment : std::nullopt; };
live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).effectiveOpacity(byID); };
live.adjustmentScale = scale;
const QPointF corner = center(QPointF(0, 0));
live.adjustmentRegion = [corner, scale](const QRectF &rect) {
return QRectF((rect.left() - corner.x()) / scale, (rect.top() - corner.y()) / scale, rect.width() / scale, rect.height() / scale);
};
live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) {
const ImageLayer &layer = byID.at(id);
if (!layer.mask || !layer.mask->isEnabled)
+4 -3
View File
@@ -208,8 +208,9 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip)
qCWarning(lcRendering) << "an adjustment passes its pixel budget:" << device->size();
return;
}
// The document the device shows, where Grain's pattern sits.
const QRectF region = context.deviceTransform().inverted().mapRect(QRectF(device->rect()));
// What the device shows; Grain and Noise keep the document.
const QRectF shown = context.deviceTransform().inverted().mapRect(QRectF(device->rect()));
const QRectF region = adjustmentRegion ? adjustmentRegion(shown) : shown;
try {
// The kernels' byte order, one image pixel a device pixel.
QImage original = device->convertToFormat(QImage::Format_RGBA8888_Premultiplied).copy();
@@ -217,7 +218,7 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip)
throw ExportError(ExportError::Kind::render);
original.setDevicePixelRatio(1);
// Device pixels a document pixel, where Swift's surface counts points.
QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / region.width());
QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / shown.width());
const auto found = m_modes.find(id);
const LayerBlendMode mode = found != m_modes.end() ? found->second : LayerBlendMode::normal;
if (mode != LayerBlendMode::normal)
+2
View File
@@ -31,6 +31,8 @@ public:
std::function<void(QUuid, const QPainter &, QImage &coverage)> adjustmentClip = [](QUuid, const QPainter &, QImage &) {};
// Painter units a document pixel: the canvas's zoom.
double adjustmentScale = 1;
// The document a painter region shows: the canvas maps it.
std::function<QRectF(const QRectF &)> adjustmentRegion;
private:
// The pixels beneath, adjusted and mixed back through coverage.
+123
View File
@@ -0,0 +1,123 @@
#include "CanvasContractFixtures.h"
// Compositor 1.4's GPU canvas contracts on the CPU canvas: adjustments.
namespace {
// A canvas over a photo and a smaller, translucent layer.
struct Scene : ContractScene {
Scene(int width, int height, int canvasWidth, int canvasHeight) : ContractScene(width, height, canvasWidth, canvasHeight)
{
const QImage photo = noise(width, height, 3);
session.insert(ImportedImage(photo, photo, QStringLiteral("Photo")));
const QImage top = noise(width / 2, height / 2, 5, 160);
session.insert(ImportedImage(top, top, QStringLiteral("Top")));
}
// An adjustment layer of this kind on top, at 80%.
QUuid adjust(const LayerAdjustment &adjustment)
{
session.addAdjustment(adjustment.kind);
session.setAdjustmentEditingID(std::nullopt);
const QUuid id = session.activeLayerID().value();
session.updateAdjustment(id, adjustment);
session.setLayerOpacity(0.8);
return id;
}
};
LayerAdjustment visible(AdjustmentKind kind)
{
LayerAdjustment adjustment{kind};
switch (kind) {
case AdjustmentKind::hsv: adjustment.hsvSettings = HueSaturationSettings(120, 30); break;
case AdjustmentKind::levels: adjustment.levels.ranges[0].outputWhite = 160; break;
case AdjustmentKind::curves: adjustment.curves.channels[0] = {{0, 20}, {120, 170}, {255, 235}}; break;
case AdjustmentKind::exposure: adjustment.setExposure(ExposureSettings{.exposure = 0.8, .gamma = 1.2}); break;
case AdjustmentKind::colorBalance: adjustment.setColorBalance(ColorBalanceSettings{.shadowYellowBlue = -30, .midCyanRed = 40}); break;
case AdjustmentKind::gaussianBlur: adjustment.setGaussianRadius(6); break;
case AdjustmentKind::motionBlur:
adjustment.setResolvedMotionDistance(30);
adjustment.setResolvedMotionAngle(30);
break;
case AdjustmentKind::addNoise:
adjustment.setResolvedNoiseAmount(25);
adjustment.setResolvedNoiseSeed(7);
break;
case AdjustmentKind::grain: adjustment.setGrain(GrainSettings{.amount = 60, .size = 3, .seed = 11}); break;
default: break;
}
return adjustment;
}
}
class AdjustmentCanvasTests : public QObject {
Q_OBJECT
private slots:
void everyAdjustmentShowsAsTheExport_data();
void everyAdjustmentShowsAsTheExport();
void patternsStayWithTheDocument_data();
void patternsStayWithTheDocument();
};
void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport_data()
{
QTest::addColumn<AdjustmentKind>("kind");
for (const AdjustmentKind kind : allAdjustmentKinds)
QTest::newRow(qPrintable(rawValue(kind))) << kind;
}
// Swift's matchesEveryAdjustment: a mask, 80%, one to one.
void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport()
{
QFETCH(AdjustmentKind, kind);
Scene scene(60, 50, 60, 50);
const QImage before = scene.exported();
const QUuid id = scene.adjust(visible(kind));
rewrite(scene.session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, rampMask(60, 50)); });
scene.session.zoom(1);
QVERIFY(scene.exported() != before);
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport())));
}
void AdjustmentCanvasTests::patternsStayWithTheDocument_data()
{
QTest::addColumn<AdjustmentKind>("kind");
QTest::newRow("addNoise") << AdjustmentKind::addNoise;
QTest::newRow("grain") << AdjustmentKind::grain;
}
// Swift's patternsStayWithTheDocument: wherever it sits, however zoomed.
void AdjustmentCanvasTests::patternsStayWithTheDocument()
{
QFETCH(AdjustmentKind, kind);
Scene scene(60, 50, 140, 120);
scene.adjust(visible(kind));
// One to one, and the crisp path's document pixels.
for (const double zoom : {1.0, 2.0}) {
scene.session.zoom(zoom);
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1: %2").arg(zoom).arg(scene.gapToExport())));
scene.session.viewport.translate(QSizeF(3, -2));
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1 panned: %2").arg(zoom).arg(scene.gapToExport())));
}
// Reduced: a whole-pixel pan moves the pattern with the image.
scene.session.zoom(0.8);
const QImage before = scene.shot();
const QPoint corner = scene.origin().toPoint();
scene.session.viewport.translate(QSizeF(5, 3));
const QImage after = scene.shot();
QCOMPARE(scene.origin().toPoint(), corner + QPoint(5, 3));
int worst = 0;
for (int y = corner.y() + 2; y < corner.y() + 36; ++y) {
for (int x = corner.x() + 2; x < corner.x() + 44; ++x) {
const QRgb was = before.pixel(x, y), now = after.pixel(x + 5, y + 3);
worst = std::max({worst, std::abs(qRed(was) - qRed(now)), std::abs(qGreen(was) - qGreen(now)), std::abs(qBlue(was) - qBlue(now))});
}
}
QVERIFY2(worst <= 1, qPrintable(QString::number(worst)));
// A partial repaint shows the whole's pattern, resampled alike.
const QRect part(corner + QPoint(18, 12), QSize(20, 15));
const QImage piece = scene.canvas.grab(part).toImage().convertToFormat(QImage::Format_ARGB32);
QVERIFY(ContractScene::gap(piece, after.copy(part)) <= 2);
}
QTEST_MAIN(AdjustmentCanvasTests)
#include "AdjustmentCanvasTests.moc"
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#include "Rendering/EditorCanvas.h"
#include "IO/ImageExporter.h"
#include "RenderFixtures.h"
#include "SessionFixtures.h"
#include <QPainter>
#include <QPainterPath>
#include <cmath>
#include <QtTest>
// Compositor 1.4's GPU canvas tests, held against the CPU canvas.
struct ContractScene {
EditorSession session;
CanvasView canvas{session};
ContractScene(int width, int height, int canvasWidth, int canvasHeight)
{
session.createDocument(width, height);
session.setShowsTransformControls(false);
canvas.resize(canvasWidth, canvasHeight);
session.viewport.resize(QSizeF(canvasWidth, canvasHeight), 1, QSizeF(width, height));
}
QImage shot() { return canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); }
QPointF origin() { return session.viewport.viewPoint(QPointF(0, 0), session.document().value().size()); }
QImage exported() { return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); }
// What the canvas shows at each document pixel's centre.
QImage documentShot()
{
canvas.synchronizeDisplay();
const QImage view = shot();
const QSizeF size = session.document().value().size();
const double zoom = session.viewport.pointsPerPixel();
QImage result(size.toSize(), QImage::Format_ARGB32);
for (int y = 0; y < result.height(); ++y) {
for (int x = 0; x < result.width(); ++x)
result.setPixel(x, y, view.pixel(int(origin().x() + (x + 0.5) * zoom), int(origin().y() + (y + 0.5) * zoom)));
}
return result;
}
// The export over the checkerboard, as the canvas shows it.
QImage exportedOnCanvas()
{
const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image;
const double zoom = session.viewport.pointsPerPixel();
QImage result(exported.size(), QImage::Format_ARGB32_Premultiplied);
for (int y = 0; y < result.height(); ++y) {
for (int x = 0; x < result.width(); ++x) {
const bool light = (int(std::floor((x + 0.5) * zoom / 10)) + int(std::floor((y + 0.5) * zoom / 10))) % 2 == 0;
result.setPixel(x, y, QColor::fromRgbF(light ? 0.35 : 0.30, light ? 0.35 : 0.30, light ? 0.35 : 0.30).rgb());
}
}
QPainter painter(&result);
painter.drawImage(QRectF(result.rect()), exported);
painter.end();
return result.convertToFormat(QImage::Format_ARGB32);
}
int gapToExport(const QPainterPath &skip = QPainterPath()) { return gap(documentShot(), exportedOnCanvas(), skip); }
// The worst channel gap inside the border, `skip` aside.
static int gap(const QImage &one, const QImage &other, const QPainterPath &skip = QPainterPath())
{
int worst = 0;
for (int y = 1; y < one.height() - 1; ++y) {
for (int x = 1; x < one.width() - 1; ++x) {
if (skip.contains(QPointF(x + 0.5, y + 0.5)))
continue;
const QRgb a = one.pixel(x, y), b = other.pixel(x, y);
worst = std::max({worst, std::abs(qRed(a) - qRed(b)), std::abs(qGreen(a) - qGreen(b)), std::abs(qBlue(a) - qBlue(b))});
}
}
return worst;
}
// A band round a path, where overlays draw.
static QPainterPath band(const QPainterPath &path, double width = 6)
{
QPainterPathStroker stroker;
stroker.setWidth(width);
return stroker.createStroke(path);
}
};
// A mask dark on the left, light on the right.
inline ImportedImage rampMask(int width, int height)
{
QImage mask(width, height, QImage::Format_Grayscale8);
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x)
mask.scanLine(y)[x] = uchar(x * 255 / (width - 1));
}
return LayerMask::assetFrom(mask);
}
-28
View File
@@ -83,7 +83,6 @@ private slots:
void theLiveCompositeDrawsWhatTheExportDraws();
void adjustmentsShowAsTheExportDoes();
void anAdjustmentsMaskClipsItOnTheCanvas();
void grainFollowsTheViewOnTheCanvas();
};
void CanvasDisplayTests::theBackgroundCheckerboardShadowAndBorderSurroundTheDocument()
@@ -411,32 +410,5 @@ void CanvasDisplayTests::anAdjustmentsMaskClipsItOnTheCanvas()
session.cancelBrush();
}
void CanvasDisplayTests::grainFollowsTheViewOnTheCanvas()
{
// Swift's canvas hands Grain the view's region, not the document's.
Scene scene(40, 40, 120, 120);
EditorSession &session = scene.session;
session.insert(filled(40, 40, qRgba(128, 128, 128, 255), "Gray"));
session.addAdjustment(AdjustmentKind::grain);
session.setAdjustmentEditingID(std::nullopt);
LayerAdjustment grain = session.activeLayer().value().adjustment.value();
GrainSettings strong = grain.grain();
strong.amount = 100;
// Pinned: under some seeds the pan keeps one pixel's tone.
strong.seed = 7;
grain.setGrain(strong);
session.updateAdjustment(session.activeLayerID().value(), grain);
session.zoom(1);
QVERIFY(gapToExport(scene) > 20);
// A pan moves the pattern under the same document pixel.
const auto pixel = [&scene] {
const QPointF origin = scene.origin();
return scene.shot().pixel(int(origin.x() + 20.5), int(origin.y() + 20.5));
};
const QRgb before = pixel();
session.viewport.translate(QSizeF(3, 0));
QVERIFY(pixel() != before);
}
QTEST_MAIN(CanvasDisplayTests)
#include "CanvasDisplayTests.moc"
+177
View File
@@ -0,0 +1,177 @@
#include "CanvasContractFixtures.h"
// Compositor 1.4's GPU canvas contracts on the CPU canvas: edits.
namespace {
QUuid insertNoise(ContractScene &scene, int width, int height, quint32 seed, QPointF centre, int alpha = 255)
{
const QImage image = noise(width, height, seed, alpha);
scene.session.insert(ImportedImage(image, image, QStringLiteral("Noise")), centre);
return scene.session.activeLayerID().value();
}
QPainterPath ellipse(const QRectF &rect)
{
QPainterPath path;
path.addEllipse(rect);
return path;
}
bool finished(const std::function<void(std::function<void()>)> &start)
{
bool done = false;
start([&done] { done = true; });
return QTest::qWaitFor([&done] { return done; }, 20000);
}
}
class CanvasEditContractTests : public QObject {
Q_OBJECT
private slots:
void aNewMaskRevealsTheWholeLayer_data();
void aNewMaskRevealsTheWholeLayer();
void movingPixelsShowAsTheirCommit_data();
void movingPixelsShowAsTheirCommit();
void aGradientInASelectionShowsAsItsCommit();
void aGradientThatCannotFillCancels();
void aLiveMaskFromFurtherDownShowsAsTheExport();
};
void CanvasEditContractTests::aNewMaskRevealsTheWholeLayer_data()
{
QTest::addColumn<double>("rotation");
QTest::newRow("upright") << 0.0;
QTest::newRow("turned") << 25.0;
}
// Swift's aNewMaskRevealsTheWholeLayer: one white pixel, stretched.
void CanvasEditContractTests::aNewMaskRevealsTheWholeLayer()
{
QFETCH(double, rotation);
ContractScene scene(80, 70, 80, 70);
const QUuid id = insertNoise(scene, 30, 20, 2, QPointF(40, 35));
rewrite(scene.session, [&](ProjectSnapshot &snapshot) {
record(snapshot, id).transform = LayerTransform{.origin = {10, 12}, .size = {38, 30}, .rotation = rotation};
});
scene.session.zoom(1);
scene.session.selectLayer(id);
const QImage before = scene.exported();
scene.session.addLayerMask();
QCOMPARE(scene.session.activeLayer().value().mask.value().asset.size(), QSize(1, 1));
const QImage after = scene.exported();
// Wherever the layer covered its pixel, nothing changes.
int covered = 0;
for (int y = 0; y < after.height(); ++y) {
for (int x = 0; x < after.width(); ++x) {
if (qAlpha(before.pixel(x, y)) < 255)
continue;
++covered;
QVERIFY2(ContractScene::gap(before.copy(x - 1, y - 1, 3, 3), after.copy(x - 1, y - 1, 3, 3)) <= 1,
qPrintable(QStringLiteral("%1, %2").arg(x).arg(y)));
}
}
QVERIFY(covered > 900);
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport())));
}
void CanvasEditContractTests::movingPixelsShowAsTheirCommit_data()
{
QTest::addColumn<bool>("duplicate");
QTest::newRow("moving") << false;
QTest::newRow("duplicating") << true;
}
// Swift's matchesWhileMovingPixels, on a masked layer at 60%.
void CanvasEditContractTests::movingPixelsShowAsTheirCommit()
{
QFETCH(bool, duplicate);
ContractScene scene(80, 60, 80, 60);
const QUuid id = insertNoise(scene, 80, 60, 2, QPointF(40, 30));
rewrite(scene.session, [&](ProjectSnapshot &snapshot) {
setMask(snapshot, id, rampMask(80, 60));
record(snapshot, id).opacity = 0.6;
});
scene.session.zoom(1);
scene.session.selectLayer(id);
const QImage still = scene.documentShot();
scene.session.applySelection(ellipse(QRectF(20, 15, 30, 24)), SelectionMode::replace, QStringLiteral("Select"));
QVERIFY(scene.session.beginPixelMove(duplicate));
scene.session.movePixels(QSizeF(9.4, 5.6));
const QImage moving = scene.documentShot();
QVERIFY(finished([&](std::function<void()> done) { scene.session.finishPixelMove(std::move(done)); }));
const QImage moved = scene.documentShot();
// The ants run round the moved outline: left out.
const QPainterPath ants = ContractScene::band(ellipse(QRectF(29, 21, 30, 24)));
QVERIFY(ContractScene::gap(still, moving, ants) > 20);
QVERIFY2(ContractScene::gap(moving, moved, ants) <= 1, qPrintable(QString::number(ContractScene::gap(moving, moved, ants))));
QVERIFY(scene.gapToExport(ants) <= 1);
// A move leaves a hole; a duplicate leaves its pixels.
const QRgb left = scene.exported().pixel(24, 27);
QCOMPARE(qAlpha(left) == 0, !duplicate);
}
// Swift's matchesWhileDraggingAGradient: inside a selection, at 70%.
void CanvasEditContractTests::aGradientInASelectionShowsAsItsCommit()
{
ContractScene scene(80, 60, 80, 60);
insertNoise(scene, 80, 60, 2, QPointF(40, 30));
scene.session.zoom(1);
const QImage still = scene.documentShot();
const QPainterPath outline = ellipse(QRectF(15, 10, 40, 30));
scene.session.applySelection(outline, SelectionMode::replace, QStringLiteral("Select"));
GradientSettings settings;
settings.opacity = 0.7;
settings.style = GradientStyle::foregroundToBackground;
scene.session.setGradientSettings(settings);
scene.session.selectTool(NavigationTool::gradient);
scene.session.beginGradient(QPointF(10, 30));
scene.session.moveGradient(std::nullopt, QPointF(70, 30));
scene.session.endGradientDrag();
const QImage pending = scene.documentShot();
QVERIFY(finished([&](std::function<void()> done) { scene.session.commitGradient(std::move(done)); }));
const QImage committed = scene.documentShot();
// The ants, the gradient's line and its ends: overlays.
QPainterPath line;
line.moveTo(10, 30);
line.lineTo(70, 30);
const QPainterPath overlays = ContractScene::band(outline).united(ContractScene::band(line, 30));
QVERIFY(ContractScene::gap(still, pending, overlays) > 20);
QVERIFY2(ContractScene::gap(pending, committed, overlays) <= 1, qPrintable(QString::number(ContractScene::gap(pending, committed, overlays))));
// Outside the selection the photo stays.
QCOMPARE(scene.exported().pixel(3, 3), still.pixel(3, 3));
}
// Swift's 5708ed3: a fill that fails cancels with the error.
void CanvasEditContractTests::aGradientThatCannotFillCancels()
{
ContractScene scene(80, 60, 80, 60);
insertNoise(scene, 80, 60, 2, QPointF(40, 30));
const QImage before = scene.exported();
const int count = scene.session.history.undoCount();
scene.session.selectTool(NavigationTool::gradient);
scene.session.beginGradient(QPointF(10, 30));
QVERIFY(scene.session.gradientEdit());
scene.session.gradientEdit()->raster->pixelLimit = 100;
scene.session.moveGradient(std::nullopt, QPointF(70, 30));
QVERIFY(!scene.session.gradientEdit() && scene.session.brushError().has_value());
QCOMPARE(scene.session.history.undoCount(), count);
QCOMPARE(scene.exported(), before);
}
// Swift's matchesALiveMaskOutsideAClippingStack: a turned, distant source.
void CanvasEditContractTests::aLiveMaskFromFurtherDownShowsAsTheExport()
{
ContractScene scene(80, 60, 80, 60);
insertNoise(scene, 80, 60, 2, QPointF(40, 30));
const QUuid source = insertNoise(scene, 40, 30, 4, QPointF(35, 30), 140);
rewrite(scene.session, [&](ProjectSnapshot &snapshot) { record(snapshot, source).transform.rotation = 25; });
insertNoise(scene, 16, 16, 5, QPointF(60, 20));
const QUuid target = insertNoise(scene, 50, 40, 9, QPointF(40, 30));
const QImage unlinked = scene.exported();
QVERIFY(scene.session.linkMask(source, target));
scene.session.zoom(1);
QVERIFY(ContractScene::gap(unlinked, scene.exported()) > 20);
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport())));
}
QTEST_MAIN(CanvasEditContractTests)
#include "CanvasEditContractTests.moc"
+3
View File
@@ -266,6 +266,9 @@ compositor_test(PSDAdjustmentTests)
compositor_test(SharpCanvasTests)
compositor_test(AdjustmentBandsTests)
compositor_test(LayerOrderTests)
compositor_test(BlendingTests)
compositor_test(AdjustmentCanvasTests)
compositor_test(CanvasEditContractTests)
# It checks the bands against one pass of the kernels.
target_include_directories(AdjustmentBandsTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(RawImporterTests)