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>
This commit is contained in:
commit
c864304695
564 files changed
+100804
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user