#include "Rendering/EditorCanvas.h" #include "IO/ImageExporter.h" #include "IO/ProjectStore+Json.h" #include "ProjectFixtures.h" #include "RenderFixtures.h" #include #include #include #include extern "C" { #include "NoisePixels.h" } // Blur and noise adjustment layers: 1.2.3's three kinds. namespace { constexpr double notANumber = std::numeric_limits::quiet_NaN(); ImportedImage filled(int width, int height, QRgb premultiplied, const QString &name) { const QImage image = solid(width, height, premultiplied); return ImportedImage(image, image, name); } bool refused(const QJsonObject &object) { try { ManifestJson::adjustment(object); } catch (const ProjectError &) { return true; } return false; } // Saving and loading judge alike. std::optional stored(const ProjectSnapshot &snapshot) { QTemporaryDir root; const std::optional saving = projectError([&] { ProjectStore::save(snapshot, root.filePath("Saved.comp")); }); ProjectStore::save(twoLayers(), root.filePath("Read.comp")); for (const auto &[id, pixels] : snapshot.images) { if (!pixels.image().save(root.filePath("Read.comp/images/") + uuidString(id) + ".png")) throw std::runtime_error("cannot write a fixture image"); } overwrite(root.filePath("Read.comp/manifest.json"), snapshot.manifest.encoded()); if (saving != projectError([&] { ProjectStore::load(root.filePath("Read.comp")); })) throw std::runtime_error("saving and loading judged the project apart"); return saving; } // White, black over the left half's top square, the adjustment. QUuid blurred(EditorSession &session, const LayerAdjustment &adjustment) { const QSizeF size = session.document().value().size(); const int side = int(size.width()) / 2; session.insert(filled(int(size.width()), int(size.height()), qRgba(255, 255, 255, 255), "White"), QPointF(size.width() / 2, size.height() / 2)); session.insert(filled(side, side, qRgba(0, 0, 0, 255), "Black"), QPointF(side / 2.0, side / 2.0)); session.addAdjustment(adjustment.kind); session.setAdjustmentEditingID(std::nullopt); const QUuid id = session.activeLayerID().value(); session.updateAdjustment(id, adjustment); return id; } LayerAdjustment gaussian(double radius) { LayerAdjustment adjustment{AdjustmentKind::gaussianBlur}; adjustment.setGaussianRadius(radius); return adjustment; } // A canvas this size at a zoom, no overlay. void frame(EditorSession &session, CanvasView &canvas, QSize view, double zoom) { session.setShowsTransformControls(false); canvas.resize(view); session.viewport.resize(QSizeF(view), 1, session.document().value().size()); session.zoom(zoom); } int level(const QImage &image, int x, int y) { return qGray(image.pixel(x, y)); } } class BlurNoiseAdjustmentTests : public QObject { Q_OBJECT private slots: void settingsFallBackToSwiftsDefaultsAndBound(); void theHaloCoversEachKindsReach(); void theManifestWritesTheKeysOnlyWhenSet(); void theStoreTakesTheKindsFromVersion9(); void eachNoiseLayerRollsItsOwnSeed(); void eachEditorWritesItsOwnSettingsAlone(); void theKindsRunTheirFiltersAtTheScale(); void noiseStaysOneFieldAcrossRegions(); void aScaledTargetBlursInItsOwnPixels(); void theCanvasBlursAtItsZoom(); void aPartialRepaintReadsTheHalo(); }; void BlurNoiseAdjustmentTests::settingsFallBackToSwiftsDefaultsAndBound() { LayerAdjustment adjustment{AdjustmentKind::levels}; QVERIFY(!adjustment.blurRadius && !adjustment.motionAngle && !adjustment.motionDistance && !adjustment.noiseAmount); QVERIFY(!adjustment.noiseGaussian && !adjustment.noiseMonochromatic && !adjustment.noiseSeed); QVERIFY(adjustment.gaussianRadius() == 10 && adjustment.resolvedMotionAngle() == 0 && adjustment.resolvedMotionDistance() == 10); QVERIFY(adjustment.resolvedNoiseAmount() == 10 && !adjustment.resolvedNoiseGaussian() && !adjustment.resolvedNoiseMonochromatic()); QCOMPARE(adjustment.resolvedNoiseSeed(), 0u); // Each setter fills its own optional. adjustment.setGaussianRadius(3); adjustment.setResolvedMotionAngle(-20); adjustment.setResolvedMotionDistance(40); adjustment.setResolvedNoiseAmount(70); adjustment.setResolvedNoiseGaussian(true); adjustment.setResolvedNoiseMonochromatic(true); adjustment.setResolvedNoiseSeed(12); QVERIFY(adjustment.blurRadius == 3.0 && adjustment.motionAngle == -20.0 && adjustment.motionDistance == 40.0 && adjustment.noiseAmount == 70.0); QVERIFY(adjustment.noiseGaussian == true && adjustment.noiseMonochromatic == true && adjustment.noiseSeed == 12u); QVERIFY(adjustment.isValid()); // Swift's ranges bind every kind, NaN included. const std::pair>> bounds[] = { {&LayerAdjustment::setGaussianRadius, {{0.1, true}, {250, true}, {0.09, false}, {250.5, false}, {notANumber, false}}}, {&LayerAdjustment::setResolvedMotionAngle, {{-90, true}, {90, true}, {-90.5, false}, {90.5, false}, {notANumber, false}}}, {&LayerAdjustment::setResolvedMotionDistance, {{1, true}, {2000, true}, {0.5, false}, {2000.5, false}, {notANumber, false}}}, {&LayerAdjustment::setResolvedNoiseAmount, {{0.1, true}, {400, true}, {0.05, false}, {400.5, false}, {notANumber, false}}}}; for (const auto &[set, values] : bounds) { for (const auto &[value, valid] : values) { LayerAdjustment bounded{AdjustmentKind::hsv}; (bounded.*set)(value); QVERIFY2(bounded.isValid() == valid, qPrintable(QString::number(value))); } } } void BlurNoiseAdjustmentTests::theHaloCoversEachKindsReach() { // Swift's samplingMargin: three sigmas plus two, half streak plus two. QCOMPARE(gaussian(4).samplingMargin(), 14.0); LayerAdjustment motion{AdjustmentKind::motionBlur}; motion.setResolvedMotionDistance(20); QCOMPARE(motion.samplingMargin(), 12.0); for (const AdjustmentKind kind : allAdjustmentKinds) { if (kind != AdjustmentKind::gaussianBlur && kind != AdjustmentKind::motionBlur) QCOMPARE(LayerAdjustment{kind}.samplingMargin(), 0.0); } } void BlurNoiseAdjustmentTests::theManifestWritesTheKeysOnlyWhenSet() { QCOMPARE(ManifestJson::encoded(LayerAdjustment{AdjustmentKind::addNoise}).keys(), (QStringList{"colorize", "curves", "hue", "kind", "levels", "lightness", "saturation"})); LayerAdjustment set{AdjustmentKind::motionBlur}; set.setGaussianRadius(2.5); set.setResolvedMotionAngle(-45); set.setResolvedMotionDistance(30); set.setResolvedNoiseAmount(25); set.setResolvedNoiseGaussian(true); set.setResolvedNoiseMonochromatic(false); set.setResolvedNoiseSeed(4'294'967'295u); const QJsonObject object = ManifestJson::encoded(set); QCOMPARE(object.value("kind").toString(), QString("Motion Blur")); QVERIFY(object.value("blurRadius") == 2.5 && object.value("motionAngle") == -45 && object.value("motionDistance") == 30); QVERIFY(object.value("noiseAmount") == 25 && object.value("noiseGaussian") == true && object.value("noiseMonochromatic") == false); QCOMPARE(object.value("noiseSeed").toInteger(), qint64(4'294'967'295)); QVERIFY(ManifestJson::adjustment(object) == set); // Absent or null is nil; wrong types and seeds refuse. for (const char *key : {"blurRadius", "motionAngle", "motionDistance", "noiseAmount", "noiseGaussian", "noiseMonochromatic", "noiseSeed"}) { QJsonObject changed = object; changed.insert(QLatin1String(key), QJsonValue::Null); QVERIFY2(!refused(changed) && ManifestJson::adjustment(changed) != set, key); changed.remove(QLatin1String(key)); QVERIFY2(!refused(changed), key); changed.insert(QLatin1String(key), QJsonArray()); QVERIFY2(refused(changed), key); } for (const QJsonValue &seed : {QJsonValue(-1), QJsonValue(qint64(4'294'967'296)), QJsonValue(1.5)}) { QJsonObject changed = object; changed.insert("noiseSeed", seed); QVERIFY(refused(changed)); } } void BlurNoiseAdjustmentTests::theStoreTakesTheKindsFromVersion9() { for (const AdjustmentKind kind : {AdjustmentKind::gaussianBlur, AdjustmentKind::motionBlur, AdjustmentKind::addNoise}) { ProjectSnapshot snapshot = twoLayers(); snapshot.manifest.layers[1].adjustment = LayerAdjustment{kind}; QCOMPARE(stored(snapshot), std::nullopt); snapshot.manifest.version = 8; QCOMPARE(stored(snapshot), std::optional(ProjectError::Kind::invalid)); // Invalid settings refuse it at any version. snapshot.manifest.version = 9; snapshot.manifest.layers[1].adjustment->setGaussianRadius(300); QCOMPARE(stored(snapshot), std::optional(ProjectError::Kind::invalid)); } // The rule reads the kind: older kinds may carry keys. ProjectSnapshot older = twoLayers(); older.manifest.version = 7; older.manifest.layers[1].adjustment = LayerAdjustment{AdjustmentKind::levels}; older.manifest.layers[1].adjustment->setGaussianRadius(5); QCOMPARE(stored(older), std::nullopt); } void BlurNoiseAdjustmentTests::eachNoiseLayerRollsItsOwnSeed() { EditorSession session; session.createDocument(4, 4); session.addAdjustment(AdjustmentKind::addNoise); const LayerAdjustment first = session.activeLayer().value().adjustment.value(); session.setAdjustmentEditingID(std::nullopt); session.addAdjustment(AdjustmentKind::addNoise); const LayerAdjustment second = session.activeLayer().value().adjustment.value(); QVERIFY(first.noiseSeed && second.noiseSeed && *first.noiseSeed != *second.noiseSeed); // Only noise rolls one; the rest keep their defaults. session.setAdjustmentEditingID(std::nullopt); session.addAdjustment(AdjustmentKind::gaussianBlur); QVERIFY(session.activeLayer().value().adjustment.value() == LayerAdjustment{AdjustmentKind::gaussianBlur}); } void BlurNoiseAdjustmentTests::eachEditorWritesItsOwnSettingsAlone() { for (const AdjustmentKind kind : {AdjustmentKind::gaussianBlur, AdjustmentKind::motionBlur, AdjustmentKind::addNoise}) { EditorSession session; session.createDocument(4, 4); session.insert(filled(4, 4, qRgba(255, 255, 255, 255), "White")); session.addAdjustment(kind); const QUuid id = session.activeLayerID().value(); // Every field set: the panel reads them all. LayerAdjustment preset = session.activeLayer().value().adjustment.value(); preset.setGaussianRadius(20); preset.setResolvedMotionAngle(45); preset.setResolvedMotionDistance(30); preset.setResolvedNoiseAmount(50); preset.setResolvedNoiseGaussian(true); preset.setResolvedNoiseMonochromatic(true); session.updateAdjustment(id, preset); bool open = false; session.beginAdjustmentEditing(id, [&open] { open = true; }); QTRY_VERIFY(open); const FilterEdit &edit = session.filterEdit().value(); QVERIFY(edit.kind == filterKind(kind)); QVERIFY(edit.settings.radius == 20 && edit.settings.angle == 45 && edit.settings.distance == 30 && edit.settings.amount == 50); QVERIFY(edit.settings.gaussian && edit.settings.monochromatic); session.updateFilter(FilterSettings{.radius = 5, .angle = -30, .distance = 12, .amount = 70}, true); // The kind writes its own; the others keep the preset. const LayerAdjustment live = session.activeLayer().value().adjustment.value(); const bool blur = kind == AdjustmentKind::gaussianBlur, motion = kind == AdjustmentKind::motionBlur, noise = !blur && !motion; QCOMPARE(live.blurRadius, std::optional(blur ? 5.0 : 20.0)); QCOMPARE(live.motionAngle, std::optional(motion ? -30.0 : 45.0)); QCOMPARE(live.motionDistance, std::optional(motion ? 12.0 : 30.0)); QCOMPARE(live.noiseAmount, std::optional(noise ? 70.0 : 50.0)); QCOMPARE(live.noiseGaussian, std::optional(!noise)); QCOMPARE(live.noiseMonochromatic, std::optional(!noise)); // The seed stays the layer's own. QCOMPARE(live.noiseSeed.has_value(), noise); session.cancelFilter(); } } void BlurNoiseAdjustmentTests::theKindsRunTheirFiltersAtTheScale() { QImage image = BrushRaster::context(12, 12, false); image.fill(0); QPainter(&image).fillRect(QRect(3, 3, 6, 6), QColor(200, 100, 50)); const auto filtered = [&image](FilterKind kind, const FilterSettings &settings, quint32 seed = 0) { return PixelFilter::run(FilterJob{kind, image, settings, 1, std::nullopt, QTransform(), seed}); }; // At scale two a blur reads twice the radius. QCOMPARE(gaussian(1.5).apply(image, std::nullopt, 2), filtered(FilterKind::gaussianBlur, FilterSettings{.radius = 3})); LayerAdjustment motion{AdjustmentKind::motionBlur}; motion.setResolvedMotionAngle(30); motion.setResolvedMotionDistance(4); QCOMPARE(motion.apply(image, std::nullopt, 2), filtered(FilterKind::motionBlur, FilterSettings{.angle = 30, .distance = 8})); LayerAdjustment noise{AdjustmentKind::addNoise}; noise.setResolvedNoiseAmount(60); noise.setResolvedNoiseGaussian(true); noise.setResolvedNoiseMonochromatic(true); noise.setResolvedNoiseSeed(77); QCOMPARE(noise.apply(image), filtered(FilterKind::addNoise, FilterSettings{.amount = 60, .gaussian = true, .monochromatic = true}, 77)); QVERIFY(noise.apply(image) != image); } void BlurNoiseAdjustmentTests::noiseStaysOneFieldAcrossRegions() { QImage gray = BrushRaster::context(8, 8, false); gray.fill(QColor(128, 128, 128)); LayerAdjustment noise{AdjustmentKind::addNoise}; noise.setResolvedNoiseAmount(50); noise.setResolvedNoiseSeed(3); // A region's pixels are the whole field's there. const QImage whole = noise.apply(gray, QRectF(10, 20, 8, 8)); QCOMPARE(noise.apply(gray.copy(2, 3, 4, 4), QRectF(12, 23, 4, 4)), whole.copy(2, 3, 4, 4)); // The kernel's origin, in the image's own pixels. QImage expected = gray.copy(); noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 20, 40); QCOMPARE(noise.apply(gray, QRectF(10, 20, 4, 4)), expected); // A negative origin floors, as Swift rounds down. expected = gray.copy(); noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, -11, -21); QCOMPARE(noise.apply(gray, QRectF(-10.5, -20.5, 8, 8)), expected); // Without a region, the origin is zero. expected = gray.copy(); noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 0, 0); QCOMPARE(noise.apply(gray), expected); } void BlurNoiseAdjustmentTests::aScaledTargetBlursInItsOwnPixels() { // Drawn twice as large, the blur doubles in pixels. EditorSession session; session.createDocument(16, 16); blurred(session, gaussian(1.5)); QImage twice = BrushRaster::context(32, 32, false); twice.fill(0); { QPainter painter(&twice); painter.scale(2, 2); session.drawLiveComposite(session.document().value(), painter); } session.toggleLayerVisibility(session.activeLayerID().value()); QImage plain = BrushRaster::context(32, 32, false); plain.fill(0); { QPainter painter(&plain); painter.scale(2, 2); session.drawLiveComposite(session.document().value(), painter); } QCOMPARE(twice, gaussian(1.5).apply(plain, QRectF(0, 0, 16, 16), 2)); } void BlurNoiseAdjustmentTests::theCanvasBlursAtItsZoom() { // At half size radius 8 spreads 4 view points. EditorSession session; session.createDocument(200, 100); CanvasView canvas(session); blurred(session, gaussian(8)); frame(session, canvas, QSize(200, 100), 0.5); QCOMPARE(session.viewport.viewPoint(QPointF(100, 50), QSizeF(200, 100)), QPointF(100, 50)); const QImage shot = canvas.grab().toImage(); // 0.875 sigma out 19% of the black spreads; 1.875, 3%. const int near = level(shot, 103, 50), far = level(shot, 107, 50); QVERIFY2(near > 195 && near < 220, qPrintable(QString::number(near))); QVERIFY2(far > 240, qPrintable(QString::number(far))); } void BlurNoiseAdjustmentTests::aPartialRepaintReadsTheHalo() { // A dirty rect by the edge sees pixels past it. for (const double zoom : {1.0, 1.5, 4.0}) { EditorSession session; session.createDocument(60, 60); CanvasView canvas(session); blurred(session, gaussian(3)); // A hair-thin blur above: the widest margin must win. session.addAdjustment(AdjustmentKind::gaussianBlur); session.setAdjustmentEditingID(std::nullopt); session.updateAdjustment(session.activeLayerID().value(), gaussian(0.2)); frame(session, canvas, QSize(240, 240), zoom); const QImage full = canvas.grab().toImage(); const QPointF edge = session.viewport.viewPoint(QPointF(30, 15), QSizeF(60, 60)); const QRect dirty(int(edge.x()) - 3, int(edge.y()) - 3, 6, 6); QImage part(canvas.size(), QImage::Format_ARGB32_Premultiplied); part.fill(0); canvas.render(&part, dirty.topLeft(), QRegion(dirty)); for (int y = dirty.top(); y <= dirty.bottom(); ++y) { for (int x = dirty.left(); x <= dirty.right(); ++x) QVERIFY2(part.pixel(x, y) == full.pixel(x, y), qPrintable(QStringLiteral("%1 at %2, %3").arg(zoom).arg(x).arg(y))); } } } QTEST_MAIN(BlurNoiseAdjustmentTests) #include "BlurNoiseAdjustmentTests.moc"