diff --git a/Compositor/Document/Filters.swift b/Compositor/Document/Filters.swift index ecc53cad9..54bf8fa5b 100644 --- a/Compositor/Document/Filters.swift +++ b/Compositor/Document/Filters.swift @@ -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" @@ -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. @@ -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) @@ -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) diff --git a/Compositor/Rendering/AdjustPixels.c b/Compositor/Rendering/AdjustPixels.c index c8619e176..9e44051f4 100644 --- a/Compositor/Rendering/AdjustPixels.c +++ b/Compositor/Rendering/AdjustPixels.c @@ -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; diff --git a/Compositor/Rendering/AdjustPixels.h b/Compositor/Rendering/AdjustPixels.h index 02321487c..ae993d48f 100644 --- a/Compositor/Rendering/AdjustPixels.h +++ b/Compositor/Rendering/AdjustPixels.h @@ -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, diff --git a/Compositor/UI/FilterSheet.swift b/Compositor/UI/FilterSheet.swift index a96ed6276..8cbd55b82 100644 --- a/Compositor/UI/FilterSheet.swift +++ b/Compositor/UI/FilterSheet.swift @@ -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) { diff --git a/CompositorTests/SharpenFilterTests.swift b/CompositorTests/SharpenFilterTests.swift new file mode 100644 index 000000000..a0b055d7a --- /dev/null +++ b/CompositorTests/SharpenFilterTests.swift @@ -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.. 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) + } +} \ No newline at end of file diff --git a/README.md b/README.md index 9540063cf..68c9d3391 100644 --- a/README.md +++ b/README.md @@ -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