Files
OmaPhoto/tests/AdjustmentLayerTests.cpp
T

409 lines
17 KiB
C++

#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore+Json.h"
#include "SessionFixtures.h"
#include <QTemporaryDir>
#include <QtTest>
// Swift's AdjustmentLayerTests and AdjustmentEditorTests, as far as they reach.
namespace {
// Swift's fixture: two by two, one colour, these alphas.
ImportedImage image(const PaletteColor &colour, const std::array<int, 4> &alpha = {255, 255, 255, 255})
{
QImage pixels(2, 2, QImage::Format_RGBA8888_Premultiplied);
for (int index = 0; index < 4; ++index) {
uchar *pixel = pixels.scanLine(index / 2) + (index % 2) * 4;
const int a = alpha[size_t(index)];
pixel[0] = uchar(std::lround(colour.red * a));
pixel[1] = uchar(std::lround(colour.green * a));
pixel[2] = uchar(std::lround(colour.blue * a));
pixel[3] = uchar(a);
}
return ImportedImage(pixels, pixels, QStringLiteral("Fixture"));
}
// Premultiplied bytes, row after row.
std::vector<int> pixels(const QImage &image)
{
const QImage drawn = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
std::vector<int> result;
for (int y = 0; y < drawn.height(); ++y)
result.insert(result.end(), drawn.constScanLine(y), drawn.constScanLine(y) + drawn.width() * 4);
return result;
}
std::vector<int> rendered(const EditorSession &session)
{
return pixels(ImageExporter::render(session.projectSnapshot().value()).image);
}
std::vector<int> part(const std::vector<int> &bytes, size_t from, size_t to)
{
return std::vector<int>(bytes.begin() + qsizetype(from), bytes.begin() + qsizetype(to));
}
// Swift's `add`: no editor opens here.
QUuid add(AdjustmentKind kind, EditorSession &session)
{
session.addAdjustment(kind);
session.setAdjustmentEditingID(std::nullopt);
return session.activeLayerID().value();
}
}
class AdjustmentLayerTests : public QObject {
Q_OBJECT
private slots:
void globalAdjustmentAffectsBelowButNotAboveAndRemainsLive();
void clippedCurveChangesOnlyItsBaseAndCopyMergedMatchesExport();
void hueOpacityAndMaskPreserveOriginalPixels();
void adjustmentPersistsDuplicatesAndUndoRestoresSettings();
void curvesIdentityAndImageCommandPreserveAlpha();
void adjustmentBlendAndSoftMaskPreserveCoverage();
void legacyHSVStillDecodesAndRenders();
void theLiveCompositeDrawsAsTheExport();
void opacityMixesAndGrainSitsInTheDocument();
void everyKindAdjustsThroughItsSettings();
void invertAppliesWithoutAnEditor();
void aFoldersMaskClipsTheAdjustmentsInside();
void anyImageTargetTakesTheAdjustment();
};
void AdjustmentLayerTests::globalAdjustmentAffectsBelowButNotAboveAndRemainsLive()
{
EditorSession s;
s.createDocument(2, 2);
s.insert(image(PaletteColor::white()));
const QUuid base = s.activeLayerID().value();
const QUuid adjustment = add(AdjustmentKind::levels, s);
LayerAdjustment settings{AdjustmentKind::levels};
settings.levels.ranges[0].outputWhite = 0;
s.updateAdjustment(adjustment, settings);
QVERIFY((rendered(s) == std::vector<int>{0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255}));
s.insert(image(PaletteColor{1, 0, 0}, {255, 0, 0, 0}));
QVERIFY((part(rendered(s), 0, 4) == std::vector<int>{255, 0, 0, 255}));
rewrite(s, [&](ProjectSnapshot &snapshot) { snapshot.images.insert_or_assign(base, image(PaletteColor{0, 0, 1})); });
QCOMPARE(s.document().value().layers[0].id, base);
QVERIFY((part(rendered(s), 4, 8) == std::vector<int>{0, 0, 0, 255}));
s.selectLayer(adjustment);
s.toggleLayerVisibility(adjustment);
QVERIFY((part(rendered(s), 4, 8) == std::vector<int>{0, 0, 255, 255}));
}
void AdjustmentLayerTests::clippedCurveChangesOnlyItsBaseAndCopyMergedMatchesExport()
{
EditorSession s;
s.createDocument(2, 2);
s.insert(image(PaletteColor{0, 0, 1}));
s.insert(image(PaletteColor{0, 1, 0}, {255, 0, 128, 0}));
const QUuid adjustment = add(AdjustmentKind::curves, s);
LayerAdjustment settings{AdjustmentKind::curves};
settings.curves.channels[0] = {{0, 255}, {255, 0}};
s.updateAdjustment(adjustment, settings);
s.toggleClippingMask(adjustment);
const std::vector<int> result = rendered(s);
QVERIFY((part(result, 0, 4) == std::vector<int>{255, 0, 255, 255}));
QVERIFY((part(result, 4, 8) == std::vector<int>{0, 0, 255, 255}));
QVERIFY(pixels(s.renderMergedPixels().value().image) == result);
s.toggleClippingMask(adjustment);
QVERIFY((part(rendered(s), 4, 8) == std::vector<int>{255, 255, 0, 255}));
}
void AdjustmentLayerTests::hueOpacityAndMaskPreserveOriginalPixels()
{
EditorSession s;
s.createDocument(2, 2);
s.insert(image(PaletteColor{1, 0, 0}));
const ImageIdentity original = s.document().value().layers[0].asset.value().identity();
const QUuid id = add(AdjustmentKind::hsv, s);
LayerAdjustment value{AdjustmentKind::hsv};
value.hue = 120;
s.updateAdjustment(id, value);
const std::vector<int> result = rendered(s);
QVERIFY2(result[1] > 250 && result[0] < 5 && result[2] < 5, qPrintable(QString::number(result[0])));
s.setLayerOpacity(0);
QVERIFY((part(rendered(s), 0, 4) == std::vector<int>{255, 0, 0, 255}));
s.setLayerOpacity(1);
// Swift's LayerMask.solid(revealing: false).
s.addMask(false);
QVERIFY((part(rendered(s), 0, 4) == std::vector<int>{255, 0, 0, 255}));
QVERIFY(s.document().value().layers[0].asset.value().identity() == original);
}
void AdjustmentLayerTests::adjustmentPersistsDuplicatesAndUndoRestoresSettings()
{
EditorSession s;
s.createDocument(2, 2);
s.insert(image(PaletteColor::white()));
const QUuid id = add(AdjustmentKind::curves, s);
LayerAdjustment value{AdjustmentKind::curves};
value.curves.channels[0].insert(value.curves.channels[0].begin() + 1, CurvePoint{128, 190});
s.beginEdit(QStringLiteral("Edit Curves"));
s.updateAdjustment(id, value);
s.endEdit();
s.undo();
QVERIFY(s.activeLayer().value().adjustment.value().curves == CurvesSettings());
s.redo();
QVERIFY(s.activeLayer().value().adjustment == value);
s.duplicateActiveLayer();
QVERIFY(s.activeLayer().value().adjustment == value);
QTemporaryDir root;
const QString path = root.filePath(QStringLiteral("Adjustment-%1.comp").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)));
ProjectStore::save(s.projectSnapshot().value(), path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QVERIFY(loaded.manifest.layers.back().adjustment == value);
EditorSession restored;
restored.installProject(loaded, path);
QVERIFY(rendered(restored) == rendered(s));
}
void AdjustmentLayerTests::curvesIdentityAndImageCommandPreserveAlpha()
{
const ImportedImage asset = image(PaletteColor{0.4, 0.7, 0.1}, {255, 128, 32, 0});
QVERIFY(pixels(CurvesSettings().apply(asset.image())) == pixels(asset.image()));
EditorSession s;
s.createDocument(2, 2);
s.insert(asset);
s.beginFilter(FilterKind::curves);
FilterSettings settings;
settings.curves.channels[0] = {{0, 255}, {255, 255}};
s.updateFilter(settings, true);
bool done = false;
s.commitFilter([&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY((pixels(s.activeLayer().value().asset.value().image()) == std::vector<int>{255, 255, 255, 255, 128, 128, 128, 128, 32, 32, 32, 32, 0, 0, 0, 0}));
s.undo();
QVERIFY(s.activeLayer().value().asset.value().identity() == asset.identity());
}
void AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage()
{
EditorSession s;
s.createDocument(2, 2);
s.insert(image(PaletteColor{1, 0, 0}, {255, 128, 32, 0}));
const QUuid id = add(AdjustmentKind::hsv, s);
LayerAdjustment value{AdjustmentKind::hsv};
value.hue = 120;
s.updateAdjustment(id, value);
s.setLayerBlendMode(LayerBlendMode::multiply);
QVERIFY((rendered(s) == std::vector<int>{0, 0, 0, 255, 0, 0, 0, 128, 0, 0, 0, 32, 0, 0, 0, 0}));
// Green over red adds to yellow, as Swift 1.4.
s.setLayerBlendMode(LayerBlendMode::linearDodge);
QVERIFY((part(rendered(s), 0, 4) == std::vector<int>{255, 255, 0, 255}));
QCOMPARE(rendered(s)[7], 128);
s.setLayerBlendMode(LayerBlendMode::normal);
QImage gray(1, 1, QImage::Format_Grayscale8);
gray.fill(128);
rewrite(s, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(gray)); });
const std::vector<int> p = rendered(s);
QVERIFY(p[3] == 255 && p[7] == 128 && p[11] == 32 && p[15] == 0);
QVERIFY2(std::abs(p[0] - 127) <= 2 && std::abs(p[1] - 128) <= 2, qPrintable(QStringLiteral("%1 %2").arg(p[0]).arg(p[1])));
}
void AdjustmentLayerTests::legacyHSVStillDecodesAndRenders()
{
const QJsonObject data = ManifestJson::encoded(LayerAdjustment{.kind = AdjustmentKind::hsv, .hue = 120});
const LayerAdjustment decoded = ManifestJson::adjustment(data);
QVERIFY(!decoded.hsvSettings);
QCOMPARE(decoded.resolvedHSV().hue(), 120.0);
}
void AdjustmentLayerTests::theLiveCompositeDrawsAsTheExport()
{
// A global Levels, a clipped Curve and a masked Hue.
EditorSession s;
s.createDocument(2, 2);
s.insert(image(PaletteColor{0.2, 0.6, 1}, {255, 200, 128, 64}));
const QUuid levels = add(AdjustmentKind::levels, s);
LayerAdjustment darker{AdjustmentKind::levels};
darker.levels.ranges[0].outputWhite = 180;
s.updateAdjustment(levels, darker);
s.insert(image(PaletteColor{1, 0.5, 0}, {255, 0, 255, 0}));
const QUuid curve = add(AdjustmentKind::curves, s);
LayerAdjustment inverted{AdjustmentKind::curves};
inverted.curves.channels[0] = {{0, 255}, {255, 0}};
s.updateAdjustment(curve, inverted);
s.toggleClippingMask(curve);
const QUuid hue = add(AdjustmentKind::hsv, s);
s.updateAdjustment(hue, LayerAdjustment{.kind = AdjustmentKind::hsv, .hue = 90});
s.addMask(true);
s.setLayerOpacity(0.5);
QImage live(2, 2, QImage::Format_RGBA8888_Premultiplied);
live.fill(0);
{
QPainter painter(&live);
s.drawLiveComposite(s.document().value(), painter);
}
QVERIFY(pixels(live) == rendered(s));
// Under a translucent painter the composite fades once, whole.
const QImage exported = ImageExporter::render(s.projectSnapshot().value()).image;
QImage faded(2, 2, QImage::Format_RGBA8888_Premultiplied), expected(2, 2, QImage::Format_RGBA8888_Premultiplied);
faded.fill(Qt::white);
expected.fill(Qt::white);
{
QPainter painter(&faded);
painter.setOpacity(0.5);
s.drawLiveComposite(s.document().value(), painter);
QPainter over(&expected);
over.setOpacity(0.5);
over.drawImage(QRectF(0, 0, 2, 2), exported);
}
const std::vector<int> got = pixels(faded), wanted = pixels(expected);
for (size_t index = 0; index < got.size(); ++index)
QVERIFY2(std::abs(got[index] - wanted[index]) <= 1, qPrintable(QString::number(index)));
}
void AdjustmentLayerTests::opacityMixesAndGrainSitsInTheDocument()
{
// Half opacity lies halfway to the change.
EditorSession s;
s.createDocument(2, 2);
s.insert(image(PaletteColor{1, 0, 0}));
const QUuid hue = add(AdjustmentKind::hsv, s);
s.updateAdjustment(hue, LayerAdjustment{.kind = AdjustmentKind::hsv, .hue = 120});
s.setLayerOpacity(0.5);
QVERIFY((part(rendered(s), 0, 4) == std::vector<int>{128, 128, 0, 255}));
// Grain drawn for part of a document matches the whole.
EditorSession grained;
grained.createDocument(12, 12);
QImage gray(12, 12, QImage::Format_RGBA8888_Premultiplied);
gray.fill(QColor(128, 128, 128));
grained.insert(ImportedImage(gray, gray, QStringLiteral("Gray")));
const QUuid grain = add(AdjustmentKind::grain, grained);
LayerAdjustment strong = grained.activeLayer().value().adjustment.value();
GrainSettings settings = strong.grain();
settings.amount = 100;
strong.setGrain(settings);
grained.updateAdjustment(grain, strong);
const QImage whole = ImageExporter::render(grained.projectSnapshot().value()).image;
QImage corner(5, 4, QImage::Format_RGBA8888_Premultiplied);
corner.fill(0);
{
QPainter painter(&corner);
painter.translate(-7, -8);
grained.drawLiveComposite(grained.document().value(), painter);
}
QVERIFY(corner == whole.copy(7, 8, 5, 4));
QVERIFY(corner != gray.copy(7, 8, 5, 4));
// Twice as fine, each pixel is half a grain unit.
QImage fine(24, 24, QImage::Format_RGBA8888_Premultiplied);
fine.fill(0);
{
QPainter painter(&fine);
painter.scale(2, 2);
grained.drawLiveComposite(grained.document().value(), painter);
}
QImage flat(24, 24, QImage::Format_RGBA8888_Premultiplied);
flat.fill(QColor(128, 128, 128));
QVERIFY(fine == settings.apply(flat, QPointF(), 0.5));
}
void AdjustmentLayerTests::everyKindAdjustsThroughItsSettings()
{
QImage gray(2, 2, QImage::Format_RGBA8888_Premultiplied);
gray.fill(QColor(100, 150, 200));
std::vector<LayerAdjustment> kinds;
for (const AdjustmentKind kind : allAdjustmentKinds)
kinds.push_back(LayerAdjustment{kind});
kinds[0].hue = 60;
kinds[1].levels.ranges[0].outputWhite = 100;
kinds[2].curves.channels[0] = {{0, 50}, {255, 255}};
kinds[3].setExposure(ExposureSettings{.exposure = 1});
kinds[4].setGradientMap(GradientMapSettings{{1, 0, 0}, {0, 0, 1}, false});
kinds[5].setGrain(GrainSettings{.amount = 80, .seed = 4});
kinds[10].setBlackWhite(BlackWhiteSettings{.reds = 100});
kinds[11].setColorBalance(ColorBalanceSettings{.midCyanRed = 60});
for (const LayerAdjustment &adjustment : kinds) {
EditorSession s;
s.createDocument(2, 2);
s.insert(ImportedImage(gray, gray, QStringLiteral("Colour")));
s.updateAdjustment(add(adjustment.kind, s), adjustment);
const std::vector<int> made = pixels(adjustment.apply(gray));
QVERIFY2(made != pixels(gray) && rendered(s) == made, qPrintable(rawValue(adjustment.kind)));
}
}
// Swift's: nothing to set, no editor; the pixels invert.
void AdjustmentLayerTests::invertAppliesWithoutAnEditor()
{
EditorSession session;
session.createDocument(2, 2);
session.insert(image(PaletteColor::white()));
session.addAdjustment(AdjustmentKind::invert);
QVERIFY(!session.adjustmentEditingID());
QCOMPARE(session.history.undoName(), QString("New Invert Adjustment"));
const LayerAdjustment adjustment = session.activeLayer().value().adjustment.value();
QVERIFY(adjustment.kind == AdjustmentKind::invert && !isEditable(adjustment.kind));
QImage source(2, 2, QImage::Format_RGBA8888_Premultiplied);
source.fill(QColor::fromRgbF(0.2f, 0.4f, 0.6f));
source.setPixelColor(1, 1, QColor(102, 51, 153, 128));
const QImage inverted = adjustment.apply(source);
for (const QPoint at : {QPoint(0, 0), QPoint(1, 1)}) {
const QColor before = source.pixelColor(at), after = inverted.pixelColor(at);
QVERIFY(std::abs(before.red() + after.red() - 255) <= 1 && std::abs(before.green() + after.green() - 255) <= 1);
QVERIFY(std::abs(before.blue() + after.blue() - 255) <= 1);
// Transparency is left alone.
QCOMPARE(after.alpha(), before.alpha());
}
// The export inverts the white beneath to black.
QCOMPARE(ImageExporter::render(session.projectSnapshot().value()).image.pixelColor(0, 0), QColor(0, 0, 0));
}
void AdjustmentLayerTests::aFoldersMaskClipsTheAdjustmentsInside()
{
// White beneath; a folder whose mask hides its left column.
EditorSession s;
s.createDocument(2, 2);
s.insert(image(PaletteColor::white()));
const QUuid levels = add(AdjustmentKind::levels, s);
LayerAdjustment black{AdjustmentKind::levels};
black.levels.ranges[0].outputWhite = 0;
s.updateAdjustment(levels, black);
s.groupSelectedLayers();
const QUuid folder = s.activeLayerID().value();
QImage right(2, 2, QImage::Format_Grayscale8);
right.fill(255);
right.setPixel(0, 0, 0);
right.setPixel(0, 1, 0);
rewrite(s, [&](ProjectSnapshot &snapshot) { setMask(snapshot, folder, LayerMask::assetFrom(right)); });
QVERIFY((rendered(s) == std::vector<int>{255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 255}));
}
void AdjustmentLayerTests::anyImageTargetTakesTheAdjustment()
{
EditorSession s;
s.createDocument(2, 2);
s.insert(image(PaletteColor{0.2, 0.6, 1}, {255, 200, 128, 64}));
const QUuid curve = add(AdjustmentKind::curves, s);
LayerAdjustment inverted{AdjustmentKind::curves};
inverted.curves.channels[0] = {{0, 255}, {255, 0}};
s.updateAdjustment(curve, inverted);
// ARGB32's byte order: the adjustment reads colours, not bytes.
QImage argb(2, 2, QImage::Format_ARGB32_Premultiplied);
argb.fill(0);
{
QPainter painter(&argb);
s.drawLiveComposite(s.document().value(), painter);
}
QVERIFY(pixels(argb) == rendered(s));
// ARGB32 byte order: the adjustment reads colours, not bytes.
for (const LayerBlendMode mode : {LayerBlendMode::normal, LayerBlendMode::multiply}) {
s.setLayerBlendMode(mode);
QImage sharp(4, 4, QImage::Format_RGBA8888_Premultiplied), scaled(4, 4, QImage::Format_RGBA8888_Premultiplied);
sharp.setDevicePixelRatio(2);
sharp.fill(0);
scaled.fill(0);
{
QPainter ratio(&sharp);
s.drawLiveComposite(s.document().value(), ratio);
QPainter scale(&scaled);
scale.scale(2, 2);
s.drawLiveComposite(s.document().value(), scale);
}
QVERIFY2(pixels(sharp) == pixels(scaled), qPrintable(rawValue(mode)));
}
}
QTEST_MAIN(AdjustmentLayerTests)
#include "AdjustmentLayerTests.moc"