Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Compositor/Document/Filters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import Observation
nonisolated enum FilterKind: String, CaseIterable, Sendable {
case gaussianBlur = "Gaussian Blur"
case motionBlur = "Motion Blur"
case unsharpMask = "Unsharp Mask"
case highPass = "High Pass"
case addNoise = "Add Noise"
case vignette = "Vignette"
case bloomGlow = "Bloom / Glow"
Expand Down Expand Up @@ -45,6 +47,13 @@ nonisolated struct FilterSettings: Equatable, Sendable {
var angle: Double = 0
/// Motion Blur streak length in layer pixels, 1–2000.
var distance: Double = 10
/// Unsharp Mask: strength as Photoshop's percentage (1–500), the blur it sharpens away from (a radius in layer
/// pixels, 0.1–1000), and how different a pixel must be from it, in levels (0–255), before it's touched.
var sharpenAmount: Double = 100
var sharpenRadius: Double = 1
var sharpenThreshold: Double = 0
/// High Pass radius in layer pixels, 0.1–1000.
var highPassRadius: Double = 10
/// Add Noise strength as Photoshop's percentage, 0.1–400.
var amount: Double = 10
/// Add Noise distribution: Gaussian (more speckled) instead of Uniform.
Expand Down Expand Up @@ -95,6 +104,10 @@ nonisolated struct FilterSettings: Equatable, Sendable {
result.angle = clamp(angle, -90...90, 0)
result.distance = clamp(distance, 1...2000, 10)
result.amount = clamp(amount, 0.1...400, 10)
result.sharpenAmount = clamp(sharpenAmount, 1...500, 100)
result.sharpenRadius = clamp(sharpenRadius, 0.1...1000, 1)
result.sharpenThreshold = clamp(sharpenThreshold, 0...255, 0).rounded()
result.highPassRadius = clamp(highPassRadius, 0.1...1000, 10)
result.vignetteAmount = clamp(vignetteAmount, 0...100, 35)
result.vignetteColor = vignetteColor.clamped
result.vignetteMidpoint = clamp(vignetteMidpoint, 0...100, 50)
Expand Down Expand Up @@ -210,6 +223,29 @@ nonisolated enum PixelFilter {
kCIInputAngleKey: settings.angle * .pi / 180,
])
image = try PixelAdjust.render(streaked.cropped(to: extent), width: width, height: height, isMask: false)
case .unsharpMask, .highPass:
// Blurred with the edge held, so the layer's border isn't read as detail against transparency.
let radius = (job.kind == .unsharpMask ? settings.sharpenRadius : settings.highPassRadius) * job.scale
let blurred = edges.clampedToExtent().applyingGaussianBlur(sigma: radius).cropped(to: extent)
// The blur rendered straight into memory, and the layer copied byte for byte: drawing either into a
// context would cost more than the sharpening does.
let context = try BrushRaster.copy(job.image)
let soft = try BrushRaster.context(width: width, height: height, mask: false)
guard let data = context.data?.assumingMemoryBound(to: UInt8.self),
let softData = soft.data?.assumingMemoryBound(to: UInt8.self),
context.bytesPerRow == width * 4, soft.bytesPerRow == width * 4 else { throw ExportError.render }
PixelAdjust.ciContext.render(blurred, toBitmap: softData, rowBytes: soft.bytesPerRow, bounds: extent,
format: .RGBA8, colorSpace: CGColorSpace(name: CGColorSpace.sRGB)!)
let amount = settings.sharpenAmount / 100, threshold = Int32(settings.sharpenThreshold)
let sharpens = job.kind == .unsharpMask
// Rows are packed, so the image is one long row the bands can split anywhere.
BrushRaster.inBands(count: width * height) { start, length in
let into = data + start * 4, from = softData + start * 4
if sharpens { adjust_unsharp_mask(into, from, length, 1, length * 4, length * 4, amount, threshold) }
else { adjust_high_pass(into, from, length, 1, length * 4, length * 4) }
}
guard let result = context.makeImage() else { throw ExportError.render }
image = result
case .addNoise:
// C, not Core Image: its random generator is uniform only, and Gaussian noise is needed too.
let context = try BrushRaster.context(width: width, height: height, mask: false)
Expand Down
58 changes: 58 additions & 0 deletions Compositor/Rendering/AdjustPixels.c
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,64 @@ void adjust_tonal_contrast(uint8_t *rgba, const uint8_t *blurred, size_t width,
}
}

// A premultiplied channel as straight color, 0–255.
static inline int straight(unsigned value, unsigned alpha) {
if (alpha == 255) return (int)value;
unsigned color = (value * 255u + alpha / 2) / alpha;
return color > 255 ? 255 : (int)color;
}
static inline uint8_t premultiplied(int color, unsigned alpha) {
if (color < 0) color = 0;
if (color > 255) color = 255;
return (uint8_t)(((unsigned)color * alpha + 127u) / 255u);
}

void adjust_unsharp_mask(uint8_t *rgba, const uint8_t *blurred, size_t width, size_t height,
size_t stride, size_t blurredStride, double amount, int threshold) {
if (amount <= 0) return;
// In 256ths, so an opaque pixel (nearly every one in a photo) takes no division.
int gain = (int)lround(amount * 256);
for (size_t y = 0; y < height; ++y) {
uint8_t *p = rgba + y * stride;
const uint8_t *b = blurred + y * blurredStride;
for (size_t x = 0; x < width; ++x, p += 4, b += 4) {
unsigned alpha = p[3];
if (alpha == 0 || b[3] == 0) continue;
int opaque = alpha == 255 && b[3] == 255;
for (int c = 0; c < 3; ++c) {
int value = opaque ? p[c] : straight(p[c], alpha), soft = opaque ? b[c] : straight(b[c], b[3]);
int difference = value - soft;
if (abs(difference) <= threshold) continue;
int sharpened = value + ((gain * difference + 128) >> 8);
p[c] = opaque ? (uint8_t)(sharpened < 0 ? 0 : sharpened > 255 ? 255 : sharpened) : premultiplied(sharpened, alpha);
}
}
}
}

void adjust_high_pass(uint8_t *rgba, const uint8_t *blurred, size_t width, size_t height,
size_t stride, size_t blurredStride) {
for (size_t y = 0; y < height; ++y) {
uint8_t *p = rgba + y * stride;
const uint8_t *b = blurred + y * blurredStride;
for (size_t x = 0; x < width; ++x, p += 4, b += 4) {
unsigned alpha = p[3];
if (alpha == 0) continue;
if (alpha == 255 && b[3] == 255) {
for (int c = 0; c < 3; ++c) {
int detail = 128 + p[c] - b[c];
p[c] = (uint8_t)(detail < 0 ? 0 : detail > 255 ? 255 : detail);
}
continue;
}
for (int c = 0; c < 3; ++c) {
int soft = b[3] == 0 ? straight(p[c], alpha) : straight(b[c], b[3]);
p[c] = premultiplied(128 + straight(p[c], alpha) - soft, alpha);
}
}
}
}

static double lut_at(const float *lut, double value) {
double scaled = camera_clamp(value) * 255.0;
int lo = (int)scaled;
Expand Down
8 changes: 8 additions & 0 deletions Compositor/Rendering/AdjustPixels.h
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ void adjust_colored_vignette(uint8_t *rgba, size_t width, size_t height, size_t
void adjust_tonal_contrast(uint8_t *rgba, const uint8_t *blurred, size_t width, size_t height,
size_t stride, size_t blurredStride, double amount,
double shadows, double midtones, double highlights);
// Unsharp Mask, as in Photoshop: each channel pushed away from its value in `blurred` (the same image blurred at the
// radius) by `amount` (1 is 100%), wherever the two differ by more than `threshold` levels. Straight color; the
// alpha is left as it is.
void adjust_unsharp_mask(uint8_t *rgba, const uint8_t *blurred, size_t width, size_t height,
size_t stride, size_t blurredStride, double amount, int threshold);
// High Pass: only the detail finer than `blurred`'s radius, about middle gray. Straight color; alpha kept.
void adjust_high_pass(uint8_t *rgba, const uint8_t *blurred, size_t width, size_t height,
size_t stride, size_t blurredStride);
// Manual noise reduction, then sharpening. `scale` maps radius to preview pixels. Applied after the creative grade.
void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t stride,
double sharpenAmount, double sharpenRadius, double sharpenDetail, double sharpenMasking,
Expand Down
9 changes: 9 additions & 0 deletions Compositor/UI/FilterSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ struct FilterSheet: View {
case .motionBlur:
control("Angle", \.angle, range: -90...90, unit: "°", decimals: 0, logarithmic: false)
control("Distance", \.distance, range: 1...2000, unit: "px", decimals: 0, logarithmic: true)
case .unsharpMask:
control("Amount", \.sharpenAmount, range: 1...500, unit: "%", decimals: 0, logarithmic: false)
control("Radius", \.sharpenRadius, range: 0.1...1000, unit: "px", decimals: 1, logarithmic: true)
.help("How wide an edge the sharpening reaches across")
control("Threshold", \.sharpenThreshold, range: 0...255, unit: "levels", decimals: 0, logarithmic: false)
.help("Leave alone anything this close to its surroundings, such as skin or sky, so it isn't made grainy")
case .highPass:
control("Radius", \.highPassRadius, range: 0.1...1000, unit: "px", decimals: 1, logarithmic: true)
.help("Keep detail finer than this and turn the rest middle gray; set the layer to Overlay to sharpen with it")
case .addNoise:
control("Amount", \.amount, range: 0.1...400, unit: "%", decimals: 1, logarithmic: true)
HStack(spacing: 10) {
Expand Down
88 changes: 88 additions & 0 deletions CompositorTests/SharpenFilterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import AppKit
import Testing
@testable import Compositor

@MainActor
struct SharpenFilterTests {
/// 40 × 10, gray 102 on the left half and 153 on the right, opaque.
private func step() throws -> CGImage {
let context = try BrushRaster.context(width: 40, height: 10, mask: false)
context.setFillColor(gray: 0.4, alpha: 1)
context.fill(CGRect(x: 0, y: 0, width: 20, height: 10))
context.setFillColor(gray: 0.6, alpha: 1)
context.fill(CGRect(x: 20, y: 0, width: 20, height: 10))
return try #require(context.makeImage())
}

/// The red channel along the middle row, without any color conversion.
private func row(_ image: CGImage) -> [Int] {
let bitmap = NSBitmapImageRep(cgImage: image)
var pixel = [Int](repeating: 0, count: 4)
return (0..<image.width).map { x in bitmap.getPixel(&pixel, atX: x, y: 5); return pixel[0] }
}

private func run(_ kind: FilterKind, _ settings: FilterSettings, on image: CGImage) throws -> CGImage {
try PixelFilter.run(FilterJob(kind: kind, image: image, settings: settings, scale: 1, selection: nil, mapping: .identity))
}

@Test func unsharpMaskDeepensAnEdgeAndLeavesFlatAreasAlone() throws {
let source = try step()
let before = row(source)
var settings = FilterSettings()
settings.sharpenAmount = 200
settings.sharpenRadius = 2
let after = row(try run(.unsharpMask, settings, on: source))
#expect(after[19] < before[19] - 10, "the dark side of the edge gets darker")
#expect(after[20] > before[20] + 10, "and the light side lighter")
#expect(after[2] == before[2] && after[37] == before[37], "far from it, nothing changes")
// The edge's own difference from its blur stays under the threshold, so it's left as it was.
settings.sharpenThreshold = 40
#expect(row(try run(.unsharpMask, settings, on: source)) == before)
}

/// The blur is read the right way up: a barely-there Unsharp Mask on a top-dark, bottom-light image leaves it
/// nearly as it was, where a blur upside down would swap the halves' surroundings.
@Test func theBlurLinesUpWithTheLayer() throws {
let context = try BrushRaster.context(width: 10, height: 40, mask: false)
context.setFillColor(gray: 0.1, alpha: 1)
context.fill(CGRect(x: 0, y: 0, width: 10, height: 20))
context.setFillColor(gray: 0.9, alpha: 1)
context.fill(CGRect(x: 0, y: 20, width: 10, height: 20))
let source = try #require(context.makeImage())
var settings = FilterSettings()
settings.sharpenAmount = 500
settings.sharpenRadius = 1
let result = try run(.unsharpMask, settings, on: source)
let before = NSBitmapImageRep(cgImage: source), after = NSBitmapImageRep(cgImage: result)
var a = [Int](repeating: 0, count: 4), b = [Int](repeating: 0, count: 4)
for y in [2, 10, 30, 37] {
before.getPixel(&a, atX: 5, y: y)
after.getPixel(&b, atX: 5, y: y)
#expect(a[0] == b[0], "row \(y) is far from the edge")
}
}

@Test func highPassKeepsOnlyTheEdgeAboutMiddleGrayAndTheAlpha() throws {
let context = try BrushRaster.context(width: 40, height: 10, mask: false)
context.setFillColor(gray: 0.4, alpha: 0.5)
context.fill(CGRect(x: 0, y: 0, width: 20, height: 10))
context.setFillColor(gray: 0.6, alpha: 0.5)
context.fill(CGRect(x: 20, y: 0, width: 20, height: 10))
let source = try #require(context.makeImage())
var settings = FilterSettings()
settings.highPassRadius = 2
let result = try run(.highPass, settings, on: source)
let bitmap = NSBitmapImageRep(cgImage: result)
var pixel = [Int](repeating: 0, count: 4)
func straight(_ x: Int) -> Int { bitmap.getPixel(&pixel, atX: x, y: 5); return pixel[0] * 255 / max(1, pixel[3]) }
#expect(abs(straight(2) - 128) <= 2 && abs(straight(37) - 128) <= 2, "flat areas go middle gray")
#expect(straight(19) < 118 && straight(20) > 138, "the edge stands out either way")
bitmap.getPixel(&pixel, atX: 10, y: 5)
#expect(abs(pixel[3] - 128) <= 1, "the alpha is left as it was")
}

@Test func neitherGrowsTheLayer() {
// Neither spreads past the layer.
#expect(FilterEdit.blurMargin(.unsharpMask, FilterSettings()) == 0 && FilterEdit.blurMargin(.highPass, FilterSettings()) == 0)
}
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ brew install --cask robbietilton-compositor
- Levels (with Auto), Curves, Hue/Saturation, Exposure, Gradient Map, Grain, Black & White, Color Balance and Invert
- Gaussian Blur and Motion Blur that spread past a layer's edges
- Add Noise, Vignette, Bloom / Glow, Tonal Contrast, Lens Correction and Remove Background
- Unsharp Mask (Amount, Radius, Threshold) and High Pass
- Live previews, limited to the selection when there is one

### Canvas and files
Expand Down