OmaPhoto 1.0.0: the Linux port of Compositor
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s

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>
This commit is contained in:
雷电芽衣andClaude committed 2026-09-26 00:44:52 -04:00
commit c864304695
564 files changed
+100804

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
#include "LevelsFixtures.h"
#include <QtTest>
#include <cmath>
// Levels' rules to the value: ranges, the scale, auto, sampling.
class LevelsModelTests : public QObject {
Q_OBJECT
private slots:
void rangesClampIntoRangeAndResetWhatIsNoNumber();
void applyClampsTheInputBetweenBlackAndWhite();
void settingsStoreAndCompareNormalized();
void theScaleReadsTheSortedFiniteInterior();
void autoCutsAThousandthAtEitherEnd();
void aSingleToneChannelKeepsItsRange();
void contrastSpansTheWidestChannel();
void neutralCentresTheMeanWithinItsFloor();
void samplingCalibratesEachChannelAndResetsTheRest();
void histogramRowsKeepTheirOwnCoverage();
};
void LevelsModelTests::rangesClampIntoRangeAndResetWhatIsNoNumber()
{
const LevelRange high = LevelRange{300, 20, 400, 300, 300}.normalized();
QVERIFY(high.black == 254 && high.white == 255 && high.gamma == 9.99 && high.outputBlack == 255 && high.outputWhite == 255);
const LevelRange low = LevelRange{-5, 0.05, 100, -1, -1}.normalized();
QVERIFY(low.black == 0 && low.gamma == 0.1 && low.outputBlack == 0 && low.outputWhite == 0);
// White stays a step above black.
QCOMPARE((LevelRange{100, 1, 50, 0, 255}.normalized().white), 101.0);
// What is no number takes its default, infinity included.
QVERIFY((LevelRange{INFINITY, NAN, NAN, NAN, -INFINITY}.normalized()) == LevelRange());
}
void LevelsModelTests::applyClampsTheInputBetweenBlackAndWhite()
{
// Past white is output white; under black, output black.
QCOMPARE((LevelRange{0, 1, 128, 0, 200}.apply(1)), 200.0 / 255);
QCOMPARE((LevelRange{128, 1, 255, 50, 255}.apply(0)), 50.0 / 255);
QCOMPARE((LevelRange{128, 1, 255, 50, 255}.apply(1)), 1.0);
// Gamma 2 lifts a quarter to a half.
QVERIFY(std::abs(LevelRange{0, 2, 255, 0, 255}.apply(0.25) - 0.5) < 1e-12);
// It applies itself normalized: black 300 is 254.
QCOMPARE((LevelRange{300, 1, 255, 0, 255}.apply(254.0 / 255)), 0.0);
}
void LevelsModelTests::settingsStoreAndCompareNormalized()
{
LevelsSettings settings;
settings.channel = LevelsChannel::green;
settings.setCurrent(LevelRange{300, 1, 255, 0, 255});
QCOMPARE(settings.ranges[2].black, 254.0);
QCOMPARE(settings.current().black, 254.0);
// A raw range that normalizes to the identity is one.
LevelsSettings raw;
raw.ranges[1] = LevelRange{-5, NAN, 400, -1, 300};
QVERIFY(raw.isIdentity());
}
void LevelsModelTests::theScaleReadsTheSortedFiniteInterior()
{
// An infinite bin counts for nothing.
std::array<double, 256> spiked;
spiked.fill(100);
spiked[128] = INFINITY;
QCOMPARE(LevelsHistogramDisplay::scale(spiked), 100.0);
// The ends stay out of the typical peak.
std::array<double, 256> first{}, last{};
first[0] = last[255] = 1;
first[128] = last[128] = 100;
QCOMPARE(LevelsHistogramDisplay::scale(first), 100.0);
QCOMPARE(LevelsHistogramDisplay::scale(last), 100.0);
// Falling bins: the sorted 95th percentile, 241, times four.
std::array<double, 256> falling{};
for (size_t index = 1; index < 255; ++index)
falling[index] = double(255 - index);
falling[0] = 1e6;
QCOMPARE(LevelsHistogramDisplay::scale(falling), 964.0);
}
void LevelsModelTests::autoCutsAThousandthAtEitherEnd()
{
LevelsHistogram tails{};
for (size_t channel = 1; channel < 4; ++channel) {
tails[channel][5] = 0.5;
tails[channel][100] = 99;
tails[channel][250] = 0.5;
}
const LevelsSettings contrast = settings(LevelsAuto::contrast, tails);
QVERIFY(contrast.ranges[0].black == 5 && contrast.ranges[0].white == 250);
const LevelsSettings colour = settings(LevelsAuto::color, tails);
QVERIFY(colour.ranges[3].black == 5 && colour.ranges[3].white == 250);
}
void LevelsModelTests::aSingleToneChannelKeepsItsRange()
{
LevelsHistogram single{};
for (size_t channel = 1; channel < 4; ++channel)
single[channel][128] = 100;
for (const LevelsAuto mode : allLevelsAutos)
QVERIFY(settings(mode, single).isIdentity());
}
void LevelsModelTests::contrastSpansTheWidestChannel()
{
LevelsHistogram spread{};
spread[1][30] = spread[1][240] = 100;
spread[2][40] = spread[2][200] = 100;
spread[3][50] = spread[3][210] = 100;
const LevelsSettings contrast = settings(LevelsAuto::contrast, spread);
QVERIFY(contrast.ranges[0].black == 30 && contrast.ranges[0].white == 240);
QVERIFY(contrast.ranges[1] == LevelRange() && contrast.ranges[3] == LevelRange());
}
void LevelsModelTests::neutralCentresTheMeanWithinItsFloor()
{
// A quarter of the weight at white: gamma 2 halves.
LevelsHistogram quarter{};
for (size_t channel = 1; channel < 4; ++channel) {
quarter[channel][0] = 75;
quarter[channel][255] = 25;
}
QVERIFY(std::abs(settings(LevelsAuto::neutral, quarter).ranges[1].gamma - 2) < 1e-12);
QCOMPARE(settings(LevelsAuto::color, quarter).ranges[1].gamma, 1.0);
// A mean near white stops at gamma 0.1.
LevelsHistogram bright{};
for (size_t channel = 1; channel < 4; ++channel) {
bright[channel][10] = 0.2;
bright[channel][250] = 99.8;
}
QCOMPARE(settings(LevelsAuto::neutral, bright).ranges[2].gamma, 0.1);
}
void LevelsModelTests::samplingCalibratesEachChannelAndResetsTheRest()
{
LevelsSettings settings;
settings.ranges[0] = LevelRange{20, 1.5, 230, 10, 240};
settings.ranges[1] = LevelRange{0, 1, 100, 10, 240};
settings.ranges[2] = LevelRange{150, 1, 255, 0, 255};
settings.ranges[3] = LevelRange{0, 1, 255, 10, 255};
// Black past a channel's white stops a step below it.
const LevelsSettings black = settings.sampling({200.0 / 255, 0.2, 0.2}, LevelsSample::black);
QCOMPARE(black.ranges[0], LevelRange());
QVERIFY(black.ranges[1].black == 99 && black.ranges[1].white == 100);
QVERIFY(black.ranges[1].outputBlack == 0 && black.ranges[1].outputWhite == 255);
// White under a channel's black stops a step above it.
QCOMPARE(settings.sampling({0.2, 100.0 / 255, 0.2}, LevelsSample::white).ranges[2].white, 151.0);
// Gray at or past white leaves that channel whole.
const LevelsSettings gray = settings.sampling({1, 0.5, 0.5}, LevelsSample::gray);
QCOMPARE(gray.ranges[1], settings.ranges[1]);
QCOMPARE(gray.ranges[0], LevelRange());
// Gray just under white is stored normalized: gamma 0.1.
QCOMPARE(settings.sampling({0.5, 0.5, 254.0 / 255}, LevelsSample::gray).ranges[3].gamma, 0.1);
}
void LevelsModelTests::histogramRowsKeepTheirOwnCoverage()
{
// Three wide: mask rows are padded, pixel rows not.
QImage source(3, 2, QImage::Format_RGBA8888_Premultiplied);
for (int x = 0; x < 3; ++x) {
source.setPixelColor(x, 0, Qt::red);
source.setPixelColor(x, 1, Qt::green);
}
QPainterPath lower;
lower.addRect(0, 1, 3, 1);
const LevelsHistogram bins = LevelsFilter::histogram(job(source, {}, DocumentSelection{lower, false}.clip(QSizeF(3, 2))));
QVERIFY(bins[1][255] == 0 && bins[1][0] == 3 && bins[2][255] == 3 && bins[3][0] == 3);
QVERIFY(std::abs(bins[0][0] - 2) < 1e-12 && std::abs(bins[0][255] - 1) < 1e-12);
}
QTEST_GUILESS_MAIN(LevelsModelTests)
#include "LevelsModelTests.moc"