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,242 @@
|
||||
#include "Document/Filters.h"
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/ContentFill.h"
|
||||
#include "Document/EditorSession+Model.h"
|
||||
#include "Document/PixelAdjust.h"
|
||||
#include "Document/SubjectRemoval.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "IO/ProjectStore.h"
|
||||
#include "Rendering/RasterSnapshot.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
|
||||
extern "C" {
|
||||
#include "BrushPixels.h"
|
||||
#include "LensPixels.h"
|
||||
#include "NoisePixels.h"
|
||||
}
|
||||
|
||||
QString rawValue(FilterKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case FilterKind::gaussianBlur: return QStringLiteral("Gaussian Blur");
|
||||
case FilterKind::motionBlur: return QStringLiteral("Motion Blur");
|
||||
case FilterKind::addNoise: return QStringLiteral("Add Noise");
|
||||
case FilterKind::lensCorrection: return QStringLiteral("Lens Correction");
|
||||
case FilterKind::removeBackground: return QStringLiteral("Remove Background");
|
||||
case FilterKind::contentAwareFill: return QStringLiteral("Content-Aware Fill");
|
||||
case FilterKind::curves: return QStringLiteral("Curves");
|
||||
case FilterKind::exposure: return QStringLiteral("Exposure");
|
||||
case FilterKind::gradientMap: return QStringLiteral("Gradient Map");
|
||||
case FilterKind::grain: return QStringLiteral("Grain");
|
||||
}
|
||||
throw std::logic_error("unknown filter kind");
|
||||
}
|
||||
|
||||
bool isAutomatic(FilterKind kind)
|
||||
{
|
||||
return kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground;
|
||||
}
|
||||
|
||||
QString rawValue(BackgroundQuality quality)
|
||||
{
|
||||
return quality == BackgroundQuality::basic ? QStringLiteral("Basic") : QStringLiteral("Advanced");
|
||||
}
|
||||
|
||||
bool isImageAdjustment(FilterKind kind)
|
||||
{
|
||||
return kind == FilterKind::curves || kind == FilterKind::exposure || kind == FilterKind::gradientMap || kind == FilterKind::grain;
|
||||
}
|
||||
|
||||
FilterSettings FilterSettings::normalized() const
|
||||
{
|
||||
using ImageAdjustmentPixels::clamp;
|
||||
FilterSettings result = *this;
|
||||
result.radius = clamp(radius, 0.1, 250, 1);
|
||||
result.angle = clamp(angle, -90, 90, 0);
|
||||
result.distance = clamp(distance, 1, 2000, 10);
|
||||
result.amount = clamp(amount, 0.1, 400, 10);
|
||||
result.distortion = clamp(distortion, -100, 100, 0);
|
||||
result.refineEdges = clamp(refineEdges, 0, 40, 12);
|
||||
result.matteContrast = clamp(matteContrast, 0, 100, 25);
|
||||
result.shiftEdge = clamp(shiftEdge, -10, 10, 0);
|
||||
result.exposure = exposure.normalized();
|
||||
result.gradientMap = gradientMap.normalized();
|
||||
result.grain = grain.normalized();
|
||||
return result;
|
||||
}
|
||||
|
||||
PixelFilter::Trimmed PixelFilter::trimmed(const QImage &image, const LayerTransform &placed)
|
||||
{
|
||||
const QRectF full(image.rect());
|
||||
// Bounds read premultiplied bytes; other formats get drawn.
|
||||
QImage pixels = image;
|
||||
if (pixels.format() != QImage::Format_RGBA8888_Premultiplied) {
|
||||
pixels = BrushRaster::context(image.width(), image.height(), false);
|
||||
QPainter painter(&pixels);
|
||||
BrushRaster::draw(image, full, painter);
|
||||
}
|
||||
size_t edges[4] = {0, 0, 0, 0};
|
||||
brush_alpha_bounds(pixels.constBits(), size_t(pixels.width()), size_t(pixels.height()), size_t(pixels.bytesPerLine()), edges);
|
||||
const QRect crop(int(edges[0]), int(edges[1]), int(edges[2] - edges[0]), int(edges[3] - edges[1]));
|
||||
if (crop.width() < 1 || crop.height() < 1 || QRectF(crop) == full)
|
||||
return {image, placed};
|
||||
LayerTransform result = placed;
|
||||
result.size = {crop.width() * placed.size.width() / full.width(), crop.height() * placed.size.height() / full.height()};
|
||||
const QPointF middle = BrushRaster::pixelToDocument(placed, image.width(), image.height()).map(QRectF(crop).center());
|
||||
result.origin = {middle.x() - result.size.width() / 2, middle.y() - result.size.height() / 2};
|
||||
// Swift's crop shares pixels; a copy may find no memory.
|
||||
const QImage cropped = image.copy(crop);
|
||||
if (cropped.isNull())
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
return {cropped, result};
|
||||
}
|
||||
|
||||
namespace {
|
||||
// The image drawn into a fresh premultiplied context.
|
||||
QImage drawn(const QImage &image)
|
||||
{
|
||||
QImage context = BrushRaster::context(image.width(), image.height(), false);
|
||||
QPainter painter(&context);
|
||||
BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter);
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
QImage PixelFilter::run(const FilterJob &job)
|
||||
{
|
||||
const FilterSettings settings = job.settings.normalized();
|
||||
const int width = job.image.width(), height = job.image.height();
|
||||
QImage image;
|
||||
switch (job.kind) {
|
||||
case FilterKind::curves: image = settings.curves.apply(job.image); break;
|
||||
case FilterKind::exposure: image = settings.exposure.apply(job.image); break;
|
||||
case FilterKind::gradientMap: image = settings.gradientMap.apply(job.image); break;
|
||||
// Grain sits in layer pixels; the job's seed patterns it.
|
||||
case FilterKind::grain: image = settings.grain.apply(job.image, QPointF(), 1 / job.scale, job.seed); break;
|
||||
case FilterKind::removeBackground: image = SubjectRemoval::run(job.image, settings); break;
|
||||
case FilterKind::contentAwareFill: image = ContentFill::run(job); break;
|
||||
// Unclamped: a blur spreads into the room made for it.
|
||||
case FilterKind::gaussianBlur: image = PixelAdjust::gaussianBlur(drawn(job.image), settings.radius * job.scale, false); break;
|
||||
case FilterKind::motionBlur:
|
||||
image = PixelAdjust::motionBlur(drawn(job.image), settings.distance * job.scale * motionRadiusPerPixel, settings.angle * std::numbers::pi / 180);
|
||||
break;
|
||||
case FilterKind::addNoise:
|
||||
image = drawn(job.image);
|
||||
noise_add(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), float(settings.amount), settings.gaussian ? 1 : 0,
|
||||
settings.monochromatic ? 1 : 0, job.seed);
|
||||
break;
|
||||
case FilterKind::lensCorrection: {
|
||||
// Relative to the image's size, so previews bend alike.
|
||||
const QImage source = drawn(job.image);
|
||||
image = BrushRaster::context(width, height, false);
|
||||
lens_distort(source.constBits(), image.bits(), size_t(width), size_t(height), size_t(source.bytesPerLine()), settings.distortion / 100 * lensStrength);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!job.selection)
|
||||
return image;
|
||||
return PixelAdjust::blend(image, job.image, *job.selection, job.mapping, false);
|
||||
}
|
||||
|
||||
namespace {
|
||||
QRectF integral(const QRectF &rect)
|
||||
{
|
||||
return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom())));
|
||||
}
|
||||
}
|
||||
|
||||
FilterEdit::FilterEdit(FilterKind kind, const ImageLayer &layer, std::optional<SelectionClip> selection, const FilterSettings &settings,
|
||||
std::optional<QRectF> area)
|
||||
: kind(kind), layerID(layer.id), original(layer.asset ? *layer.asset : throw ProjectError(ProjectError::Kind::invalid)),
|
||||
transform(layer.transform), selection(std::move(selection)), settings(settings.normalized())
|
||||
{
|
||||
prepare(original, transform);
|
||||
if (area)
|
||||
grow(integral(mapping.inverted().mapRect(*area)));
|
||||
growForBlur();
|
||||
}
|
||||
|
||||
double FilterEdit::blurMargin(FilterKind kind, const FilterSettings &settings)
|
||||
{
|
||||
switch (kind) {
|
||||
case FilterKind::gaussianBlur: return settings.radius * 3 + 2;
|
||||
case FilterKind::motionBlur: return settings.distance / 2 + 2;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void FilterEdit::growForBlur()
|
||||
{
|
||||
const double margin = blurMargin(kind, settings);
|
||||
if (!(margin > grownMargin))
|
||||
return;
|
||||
const QSize size = original.size();
|
||||
grow(QRectF(-margin, -margin, size.width() + 2 * margin, size.height() + 2 * margin));
|
||||
}
|
||||
|
||||
// The layer's pixels in a grid covering `extent` (layer pixels).
|
||||
void FilterEdit::grow(const QRectF &extent)
|
||||
{
|
||||
const QSize size = original.size();
|
||||
const QRectF bounds(0, 0, size.width(), size.height());
|
||||
const QRectF target = integral(bounds.united(extent));
|
||||
if (target == bounds)
|
||||
return;
|
||||
if (target.width() > 30'000 || target.height() > 30'000 || target.width() * target.height() > 100'000'000)
|
||||
throw ProjectError(ProjectError::Kind::tooLarge);
|
||||
QImage grown = BrushRaster::context(int(target.width()), int(target.height()), false);
|
||||
const QRectF inside = bounds.translated(-target.left(), -target.top());
|
||||
QPainter painter(&grown);
|
||||
if (original.raster)
|
||||
original.raster->draw(inside, painter);
|
||||
else
|
||||
BrushRaster::draw(original.image(), inside, painter);
|
||||
painter.end();
|
||||
const QTransform toDocument = BrushRaster::pixelToDocument(transform, size.width(), size.height());
|
||||
LayerTransform expanded = transform;
|
||||
expanded.size = {target.width() * transform.size.width() / bounds.width(), target.height() * transform.size.height() / bounds.height()};
|
||||
const QPointF middle = toDocument.map(target.center());
|
||||
expanded.origin = {middle.x() - expanded.size.width() / 2, middle.y() - expanded.size.height() / 2};
|
||||
grownImage = grown;
|
||||
grownTransform = expanded;
|
||||
grownMargin = std::min({bounds.left() - target.left(), bounds.top() - target.top(), target.right() - bounds.right(), target.bottom() - bounds.bottom()});
|
||||
prepare(ImportedImage(grown, QImage(), QString()), expanded);
|
||||
}
|
||||
|
||||
void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed)
|
||||
{
|
||||
const QSize size = source.size();
|
||||
mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height());
|
||||
// Noise and grain preview whole: enlarged, grain looks coarse.
|
||||
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground;
|
||||
const double factor = whole ? 1 : std::min(1.0, previewLimit / std::max(size.width(), size.height()));
|
||||
if (!(factor < 1)) {
|
||||
previewSource = source.image();
|
||||
previewScale = 1;
|
||||
previewMapping = mapping;
|
||||
return;
|
||||
}
|
||||
const int width = std::max(1, int(size.width() * factor)), height = std::max(1, int(size.height() * factor));
|
||||
previewSource = BrushRaster::context(width, height, false);
|
||||
QPainter painter(&previewSource);
|
||||
const QRectF rect(0, 0, width, height);
|
||||
if (source.raster)
|
||||
source.raster->draw(rect, painter);
|
||||
else
|
||||
BrushRaster::draw(source.image(), rect, painter);
|
||||
painter.end();
|
||||
previewScale = double(width) / size.width();
|
||||
previewMapping = BrushRaster::pixelToDocument(placed, width, height);
|
||||
}
|
||||
|
||||
std::optional<QImage> FilterEdit::previewImage(QUuid id) const
|
||||
{
|
||||
return preview && id == layerID ? preparedPreview : std::nullopt;
|
||||
}
|
||||
|
||||
FilterJob FilterEdit::previewJob() const
|
||||
{
|
||||
return FilterJob{kind, previewSource, settings, previewScale, selection, previewMapping, seed};
|
||||
}
|
||||
Reference in new issue
Block a user