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

+238
View File
@@ -0,0 +1,238 @@
#include "Document/SubjectRemoval.h"
#include "Document/GuidedMatte.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QMutex>
#include <QStandardPaths>
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <new>
#include <onnxruntime_cxx_api.h>
namespace {
QString message(SubjectRemovalError::Kind kind, const QString &detail)
{
switch (kind) {
case SubjectRemovalError::Kind::noSubject:
return QStringLiteral("No foreground subject was detected in this layer. Try an image with a more distinct subject.");
case SubjectRemovalError::Kind::model: return QStringLiteral("Remove Background could not use its model: ") + detail;
}
throw std::logic_error("unknown subject removal failure");
}
}
SubjectRemovalError::SubjectRemovalError(Kind kind, const QString &detail) : std::runtime_error(message(kind, detail).toStdString()), kind(kind)
{
}
namespace {
using SubjectRemoval::side;
QImage checked(const QImage &image)
{
if (image.isNull())
throw ExportError(ExportError::Kind::render);
return image;
}
QString modelPath()
{
const QString name = QStringLiteral("omaphoto/u2net.onnx");
const QString path = QStandardPaths::locate(QStandardPaths::GenericDataLocation, name);
if (path.isEmpty()) {
const QString folders = QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation).join(QStringLiteral(", "));
qCWarning(lcApp) << "Remove Background finds no model in" << folders;
throw SubjectRemovalError(SubjectRemovalError::Kind::model, name + QStringLiteral(" is in no data folder (") + folders + QStringLiteral(")"));
}
qCInfo(lcApp) << "Remove Background loads its model from" << path;
return path;
}
// The session and its names, loaded once for the process.
struct Model {
Ort::Env environment{ORT_LOGGING_LEVEL_WARNING, "omaphoto"};
Ort::Session session;
std::string input;
std::string output;
explicit Model(const QString &path) : session(environment, path.toLocal8Bit().constData(), Ort::SessionOptions())
{
Ort::AllocatorWithDefaultOptions allocator;
input = session.GetInputNameAllocated(0, allocator).get();
output = session.GetOutputNameAllocated(0, allocator).get();
}
};
// A failed load stays unset; the next use tries again.
Model &model()
{
static Model loaded(modelPath());
return loaded;
}
// U²-Net's mask at the image's size, white over the subject.
QImage infer(const QImage &image)
{
std::vector<float> input = SubjectRemoval::modelInput(image);
Model &loaded = model();
const std::array<int64_t, 4> dimensions{1, 3, side, side};
const Ort::MemoryInfo memory = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
const Ort::Value tensor = Ort::Value::CreateTensor<float>(memory, input.data(), input.size(), dimensions.data(), dimensions.size());
const char *inputs[] = {loaded.input.c_str()}, *outputs[] = {loaded.output.c_str()};
const std::vector<Ort::Value> result = loaded.session.Run(Ort::RunOptions{nullptr}, inputs, &tensor, 1, outputs, 1);
return SubjectRemoval::modelMask(result.front().GetTensorData<float>(), image.size());
}
// Swift's MaskCache: the last image's mask, by identity.
class MaskCache {
public:
QImage mask(const QImage &image)
{
{
const QMutexLocker lock(&m_lock);
if (m_key == image.cacheKey())
return m_value;
}
const QImage made = infer(image);
const QMutexLocker lock(&m_lock);
m_key = image.cacheKey();
m_value = made;
return made;
}
private:
QMutex m_lock;
std::optional<qint64> m_key;
QImage m_value;
};
MaskCache cache;
// The model's mask; slider moves only redo the refining.
QImage vision(const QImage &image)
{
try {
return cache.mask(image);
} catch (const Ort::Exception &error) {
qCWarning(lcApp) << "Remove Background's model failed:" << error.what();
throw SubjectRemovalError(SubjectRemovalError::Kind::model, QString::fromUtf8(error.what()));
} catch (const std::bad_alloc &) {
// Its buffers, or ONNX Runtime's wrapper, found no memory.
throw ExportError(ExportError::Kind::render);
}
}
// Advanced refines, shifts the edge, then clears the haze.
QImage refined(const QImage &mask, const QImage &guide, const FilterSettings &settings, double limit)
{
// Basic is the model's mask as it comes: quick.
if (settings.backgroundQuality != BackgroundQuality::advanced)
return mask;
QImage image = settings.refineEdges > 0 ? GuidedMatte::refine(mask, guide, settings.refineEdges, limit) : mask;
std::optional<float> level;
if (settings.shiftEdge != 0) {
// A blur, then a hard cut, moves the edge.
image = PixelAdjust::gaussianBlur(image, std::abs(settings.shiftEdge) / 2, true);
level = settings.shiftEdge < 0 ? 0.75f : 0.25f;
}
// 0 leaves the mask; 100 cuts hard at the middle.
const float slope = 1 / std::max(0.02f, 1 - float(settings.matteContrast / 100) * 0.98f);
QImage result = checked(QImage(image.size(), QImage::Format_Grayscale8));
for (int y = 0; y < image.height(); ++y) {
const uchar *in = image.constScanLine(y);
uchar *out = result.scanLine(y);
for (int x = 0; x < image.width(); ++x) {
float value = float(in[x]) / 255;
// Swift's clamp first is the last one's: Contrast only steepens.
if (level)
value = (value - *level) * 1000;
value = slope * value + (1 - slope) / 2;
out[x] = uchar(std::lround(std::clamp(value, 0.0f, 1.0f) * 255));
}
}
return result;
}
}
std::vector<float> SubjectRemoval::modelInput(const QImage &image)
{
// Scaled premultiplied, so clear pixels read as black.
const QImage small = checked(image.scaled(side, side, Qt::IgnoreAspectRatio, Qt::SmoothTransformation).convertToFormat(QImage::Format_RGBA8888_Premultiplied));
uchar brightest = 0;
for (int y = 0; y < side; ++y) {
const uchar *row = small.constScanLine(y);
for (int x = 0; x < side; ++x)
brightest = std::max({brightest, row[x * 4], row[x * 4 + 1], row[x * 4 + 2]});
}
const float peak = std::max(float(brightest), 1e-6f);
constexpr std::array<float, 3> mean{0.485f, 0.456f, 0.406f}, spread{0.229f, 0.224f, 0.225f};
std::vector<float> input(size_t(3) * side * side);
for (int y = 0; y < side; ++y) {
const uchar *row = small.constScanLine(y);
for (int x = 0; x < side; ++x) {
for (size_t channel = 0; channel < 3; ++channel)
input[channel * side * side + size_t(y) * side + size_t(x)] = (float(row[x * 4 + int(channel)]) / peak - mean[channel]) / spread[channel];
}
}
return input;
}
QImage SubjectRemoval::modelMask(const float *prediction, QSize size)
{
const auto [low, high] = std::minmax_element(prediction, prediction + side * side);
// Nothing the model takes for a subject: Swift's no instances.
if (!(*high >= 0.5f))
throw SubjectRemovalError(SubjectRemovalError::Kind::noSubject);
// Stretched from low to high, truncated, as rembg does.
const float range = std::max(*high - *low, std::numeric_limits<float>::epsilon());
QImage mask = checked(QImage(side, side, QImage::Format_Grayscale8));
for (int y = 0; y < side; ++y) {
uchar *row = mask.scanLine(y);
for (int x = 0; x < side; ++x)
row[x] = uchar((prediction[size_t(y) * side + size_t(x)] - *low) / range * 255);
}
// Premultiplied, a smaller grid is area-filtered, as in `levels`.
return checked(checked(mask.convertToFormat(QImage::Format_RGBA8888_Premultiplied))
.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)
.convertToFormat(QImage::Format_Grayscale8));
}
QImage SubjectRemoval::subjectMask(const QImage &image, const std::optional<QImage> &existing, const FilterSettings &settings)
{
if (existing && existing->size() != image.size())
throw std::logic_error("a mask to keep must share the layer's grid");
const QImage subject = refined(vision(image), image, settings, std::numeric_limits<double>::max());
if (!existing)
return subject;
// Both masks hide: what either one hides stays hidden.
const QImage base = checked(existing->convertToFormat(QImage::Format_Grayscale8));
QImage combined = checked(QImage(subject.size(), QImage::Format_Grayscale8));
for (int y = 0; y < subject.height(); ++y) {
const uchar *top = subject.constScanLine(y), *under = base.constScanLine(y);
uchar *out = combined.scanLine(y);
for (int x = 0; x < subject.width(); ++x)
out[x] = uchar((int(top[x]) * under[x] + 127) / 255);
}
return combined;
}
QImage SubjectRemoval::run(const QImage &image, const FilterSettings &settings)
{
// The preview refines a copy 1400 long at most: quick.
const QImage mask = refined(vision(image), image, settings, 1400);
// Swift's blend over clear: each premultiplied byte times the mask.
QImage result = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
// Its own pixels now, where a failed copy throws.
result.detach();
if (result.isNull())
throw ExportError(ExportError::Kind::render);
for (int y = 0; y < result.height(); ++y) {
const uchar *coverage = mask.constScanLine(y);
uchar *row = result.scanLine(y);
for (int x = 0; x < result.width() * 4; ++x)
row[x] = uchar((int(row[x]) * coverage[x / 4] + 127) / 255);
}
return result;
}