Files
OmaPhoto/tests/ImageAdjustmentModelTests.cpp
雷电芽衣andClaude c864304695
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s
OmaPhoto 1.0.0: the Linux port of Compositor
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>
2026-09-26 00:44:52 -04:00

222 lines
11 KiB
C++

#include "Document/Curves.h"
#include "Document/ImageAdjustments.h"
#include "IO/ProjectStore.h"
#include <QtTest>
#include <cmath>
// The adjustments' models at their edges: ranges, curves, tables.
namespace {
QImage filled(int width, int height, QColor colour)
{
QImage result(width, height, QImage::Format_RGBA8888_Premultiplied);
result.fill(colour);
return result;
}
std::array<int, 4> first(const QImage &image)
{
const uchar *pixel = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied).constBits();
return {pixel[0], pixel[1], pixel[2], pixel[3]};
}
// Whether a call throws ProjectError(invalid).
template <typename Call> bool refused(Call call)
{
try {
call();
} catch (const ProjectError &error) {
return error.kind == ProjectError::Kind::invalid;
}
return false;
}
CurvesSettings withCurve(size_t channel, std::vector<CurvePoint> points)
{
CurvesSettings settings;
settings.channels[channel] = std::move(points);
return settings;
}
}
class ImageAdjustmentModelTests : public QObject {
Q_OBJECT
private slots:
void curvesAreValidAsSwifts();
void curvesInterpolateWithoutOvershoot();
void curvesRunEachChannelThenTheComposite();
void exposureKeepsItsRangesAndTable();
void gradientMapsKeepTheirEndsAndColours();
void grainKeepsItsRangesSeedAndPlace();
void theKernelSeesPremultipliedRgba();
};
void ImageAdjustmentModelTests::curvesAreValidAsSwifts()
{
QVERIFY(CurvesSettings().isValid());
CurvesSettings three;
three.channels.pop_back();
QVERIFY(!three.isValid());
QVERIFY(!withCurve(0, {{0, 0}}).isValid() && !withCurve(0, {}).isValid());
std::vector<CurvePoint> many{{0, 0}};
for (int index = 1; index < 31; ++index)
many.push_back({double(index), 10});
many.push_back({255, 255});
QCOMPARE(many.size(), size_t(32));
QVERIFY(withCurve(1, many).isValid());
many.insert(many.end() - 1, CurvePoint{100, 10});
std::sort(many.begin(), many.end(), [](const CurvePoint &a, const CurvePoint &b) { return a.x < b.x; });
QVERIFY(!withCurve(1, many).isValid());
// Ends at 0 and 255, x rising, values in range.
QVERIFY(!withCurve(2, {{1, 0}, {255, 255}}).isValid());
QVERIFY(!withCurve(2, {{0, 0}, {254, 255}}).isValid());
QVERIFY(!withCurve(3, {{0, 0}, {100, 10}, {100, 20}, {255, 255}}).isValid());
QVERIFY(!withCurve(3, {{0, 0}, {100, 10}, {50, 20}, {255, 255}}).isValid());
QVERIFY(!withCurve(3, {{0, 0}, {100, 256}, {255, 255}}).isValid());
QVERIFY(!withCurve(3, {{0, -1}, {255, 255}}).isValid());
QVERIFY(!withCurve(0, {{0, 0}, {100, std::nan("")}, {255, 255}}).isValid());
QVERIFY(withCurve(0, {{0, 255}, {255, 0}}).isValid());
}
void ImageAdjustmentModelTests::curvesInterpolateWithoutOvershoot()
{
const CurvesSettings identity;
for (const double x : {0.0, 1.0, 64.5, 200.0, 255.0})
QCOMPARE(identity.value(x, 0), x);
// Swift's Hermite: secant ends, harmonic means inside.
const CurvesSettings lifted = withCurve(0, {{0, 0}, {128, 200}, {255, 255}});
const double inner = 2 / (1 / (200.0 / 128) + 1 / (55.0 / 127));
QVERIFY(std::abs(lifted.value(64, 0) - (0.125 * 128 * (200.0 / 128) + 100 - 0.125 * 128 * inner)) < 1e-9);
const double t = 63.5 / 127;
QVERIFY(std::abs(lifted.value(191.5, 0)
- ((2 * t * t * t - 3 * t * t + 1) * 200 + (t * t * t - 2 * t * t + t) * 127 * inner + (-2 * t * t * t + 3 * t * t) * 255
+ (t * t * t - t * t) * 127 * (55.0 / 127)))
< 1e-9);
QCOMPARE(lifted.value(128, 0), 200.0);
double previous = -1;
for (int x = 0; x <= 255; ++x) {
QVERIFY(lifted.value(x, 0) >= previous);
previous = lifted.value(x, 0);
}
// A peak flattens: nothing passes its neighbours.
const CurvesSettings peak = withCurve(0, {{0, 0}, {128, 250}, {255, 0}});
for (int x = 0; x <= 255; ++x)
QVERIFY(peak.value(x, 0) <= 250);
QCOMPARE(peak.value(128, 0), 250.0);
// Off the curve: the first point below, the last above.
QCOMPARE(lifted.value(-40, 0), 0.0);
QCOMPARE(lifted.value(400, 0), 255.0);
QCOMPARE(lifted.value(std::nan(""), 0), 0.0);
QCOMPARE(withCurve(2, {{0, 30}, {255, 90}}).value(std::nan(""), 2), 30.0);
// A straight curve stops at its ends, never runs on.
const CurvesSettings line = withCurve(1, {{0, 100}, {255, 150}});
QCOMPARE(line.value(400, 1), 150.0);
QCOMPARE(line.value(-40, 1), 100.0);
}
void ImageAdjustmentModelTests::curvesRunEachChannelThenTheComposite()
{
CurvesSettings settings = withCurve(1, {{0, 255}, {255, 0}});
QVERIFY((first(settings.apply(filled(2, 2, QColor(200, 100, 50)))) == std::array<int, 4>{55, 100, 50, 255}));
settings.channels[0] = {{0, 255}, {255, 0}};
QVERIFY((first(settings.apply(filled(2, 2, QColor(200, 100, 50)))) == std::array<int, 4>{200, 155, 205, 255}));
settings.channels[3] = {{0, 0}, {255, 0}};
QVERIFY((first(settings.apply(filled(2, 2, QColor(200, 100, 50)))) == std::array<int, 4>{200, 155, 255, 255}));
QVERIFY(refused([] { withCurve(0, {{0, 0}, {256, 255}}).apply(filled(1, 1, Qt::red)); }));
}
void ImageAdjustmentModelTests::exposureKeepsItsRangesAndTable()
{
QVERIFY((ExposureSettings{20, 0.5, 9.99}.isValid() && ExposureSettings{-20, -0.5, 0.01}.isValid()));
for (const ExposureSettings &wild : {ExposureSettings{20.01, 0, 1}, ExposureSettings{-20.01, 0, 1}, ExposureSettings{0, -0.51, 1},
ExposureSettings{0, 0.51, 1}, ExposureSettings{0, 0, 0.009}, ExposureSettings{0, 0, 10},
ExposureSettings{std::nan(""), 0, 1}}) {
QVERIFY(!wild.isValid());
QVERIFY(refused([&] { wild.apply(filled(1, 1, Qt::red)); }));
}
QCOMPARE((ExposureSettings{30, -2, 0}.normalized()), (ExposureSettings{20, -0.5, 0.01}));
QCOMPARE((ExposureSettings{-30, 2, 20}.normalized()), (ExposureSettings{-20, 0.5, 9.99}));
QCOMPARE((ExposureSettings{std::nan(""), INFINITY, std::nan("")}.normalized()), ExposureSettings());
const std::array<float, 256> identity = ExposureSettings().table();
for (size_t index = 0; index < 256; ++index)
QVERIFY(std::abs(identity[index] - float(index) / 255) < 1e-5f);
// A stop doubles linear light; the offset lifts black.
QCOMPARE((ExposureSettings{1, 0, 1}.table()[128]), 0.68845345f);
QCOMPARE((ExposureSettings{0, 0.1, 1}.table()[0]), 0.34919021f);
QCOMPARE((ExposureSettings{0, -0.5, 1}.table()[255]), 0.7353569f);
QCOMPARE((ExposureSettings{-20, 0, 1}.table()[255]), 1.2321472e-5f);
QCOMPARE((ExposureSettings{20, 0, 1}.table()[1]), 1.0f);
}
void ImageAdjustmentModelTests::gradientMapsKeepTheirEndsAndColours()
{
const GradientMapSettings settings{{0, 0, 0}, {1, 0.7, 0}, false};
QCOMPARE(settings.ends().dark, AdjustmentColor(0, 0, 0));
GradientMapSettings reversed = settings;
reversed.reversed = true;
QCOMPARE(reversed.ends().dark, AdjustmentColor(1, 0.7, 0));
QCOMPARE(reversed.ends().light, AdjustmentColor(0, 0, 0));
// Gray 128 lies halfway, each byte rounded.
QVERIFY((first(settings.apply(filled(1, 1, QColor(128, 128, 128)))) == std::array<int, 4>{128, 90, 0, 255}));
QVERIFY((first(settings.apply(filled(1, 1, Qt::transparent))) == std::array<int, 4>{0, 0, 0, 0}));
const GradientMapSettings wild{{1.5, 0, 0}, {0, std::nan(""), -0.5}, true};
QVERIFY(!wild.isValid() && !(GradientMapSettings{{0, 0, 0}, {0, 0, 1.01}, false}.isValid()));
QVERIFY(!(GradientMapSettings{{1.5, 0, 0}, {1, 1, 1}, false}.isValid()));
QVERIFY(refused([&] { wild.apply(filled(1, 1, Qt::red)); }));
QCOMPARE(wild.normalized(), (GradientMapSettings{{1, 0, 0}, {0, 0, 0}, true}));
QCOMPARE(AdjustmentColor(PaletteColor{0.2, 0.4, 0.6}), AdjustmentColor(0.2, 0.4, 0.6));
QVERIFY(AdjustmentColor(0, 1, 0.5).isValid() && !AdjustmentColor(0, 1, INFINITY).isValid() && !AdjustmentColor(-0.1, 0, 0).isValid());
for (const AdjustmentColor &wild : {AdjustmentColor(1.1, 0, 0), AdjustmentColor(0, -0.1, 0), AdjustmentColor(0, 1.1, 0), AdjustmentColor(0, 0, -0.1)})
QVERIFY(!wild.isValid());
}
void ImageAdjustmentModelTests::grainKeepsItsRangesSeedAndPlace()
{
QVERIFY((GrainSettings{100, 20, 100, 0}.isValid() && GrainSettings{0, 0.5, 0, 0}.isValid()));
for (const GrainSettings &wild : {GrainSettings{101, 1.5, 50, 0}, GrainSettings{-1, 1.5, 50, 0}, GrainSettings{25, 0.4, 50, 0},
GrainSettings{25, 21, 50, 0}, GrainSettings{25, 1.5, -1, 0}, GrainSettings{25, 1.5, 101, 0},
GrainSettings{std::nan(""), 1.5, 50, 0}})
QVERIFY(!wild.isValid());
QCOMPARE((GrainSettings{std::nan(""), std::nan(""), std::nan(""), 4}.normalized()), (GrainSettings{25, 1.5, 50, 4}));
QCOMPARE((GrainSettings{-5, 50, 200, 4}.normalized()), (GrainSettings{0, 20, 100, 4}));
QCOMPARE((GrainSettings{150, 0.1, -5, 4}.normalized()), (GrainSettings{100, 0.5, 0, 4}));
const GrainSettings settings{60, 2, 40, 7};
const QImage gray = filled(8, 8, QColor(128, 128, 128));
for (const double scale : {0.0, -1.0, double(std::nan("")), double(INFINITY)})
QVERIFY(refused([&] { settings.apply(gray, QPointF(), scale); }));
QVERIFY(refused([] { GrainSettings{101, 1.5, 50, 0}.apply(filled(1, 1, Qt::red)); }));
// No amount hands the same image back.
QCOMPARE((GrainSettings{0, 1.5, 50, 0}.apply(gray).cacheKey()), gray.cacheKey());
// A given seed stands in for the stored one.
GrainSettings reseeded = settings;
reseeded.seed = 8;
QCOMPARE(settings.apply(gray, QPointF(), 1, 8u), reseeded.apply(gray));
QVERIFY(settings.apply(gray) != reseeded.apply(gray));
// Pixels twice as big sit at other grain.
QVERIFY(settings.apply(gray, QPointF(), 2) != settings.apply(gray));
const QImage whole = settings.apply(filled(4, 4, QColor(128, 128, 128)));
QCOMPARE(settings.apply(filled(2, 2, QColor(128, 128, 128)), QPointF(1, 2)), whole.copy(1, 2, 2, 2));
}
void ImageAdjustmentModelTests::theKernelSeesPremultipliedRgba()
{
QImage source(3, 2, QImage::Format_ARGB32);
source.fill(QColor(200, 100, 50, 128));
const QImage result = ImageAdjustmentPixels::run(source, [](uchar *pixels, int width, int height, qsizetype stride) {
QCOMPARE(width, 3);
QCOMPARE(height, 2);
QCOMPARE(stride, qsizetype(12));
// Premultiplied, red first, alpha last.
QVERIFY(std::abs(pixels[0] - 100) <= 1 && std::abs(pixels[1] - 50) <= 1 && std::abs(pixels[2] - 25) <= 1 && pixels[3] == 128);
pixels[12 + 3] = 255;
});
QCOMPARE(result.format(), QImage::Format_RGBA8888_Premultiplied);
QCOMPARE(qAlpha(result.pixel(0, 1)), 255);
QCOMPARE(ImageAdjustmentPixels::clamp(7, 0, 5, 1), 5.0);
QCOMPARE(ImageAdjustmentPixels::clamp(-7, 0, 5, 1), 0.0);
QCOMPARE(ImageAdjustmentPixels::clamp(std::nan(""), 0, 5, 1), 1.0);
QCOMPARE(ImageAdjustmentPixels::clamp(-INFINITY, 0, 5, 1), 1.0);
}
QTEST_GUILESS_MAIN(ImageAdjustmentModelTests)
#include "ImageAdjustmentModelTests.moc"