From a4ac571f67165a0408fe06bbf3b8bbeb0e0ee1ee Mon Sep 17 00:00:00 2001 From: Rezaul Arif Date: Wed, 30 Sep 2026 17:04:47 +1000 Subject: [PATCH] =?UTF-8?q?Filter=20=E2=80=BA=20Unsharp=20Mask=20and=20Hig?= =?UTF-8?q?h=20Pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unsharp Mask has Photoshop's Amount (1–500%), Radius and Threshold, so it can sharpen edges and leave smooth areas like skin alone. High Pass keeps detail finer than its radius and turns the rest middle gray, for sharpening on a layer set to Overlay. Both preview live, are limited to the selection, and leave the layer's size and alpha as they are. The blur is rendered once and the pixel work runs in bands, so on an 8000 × 6000 photo Unsharp Mask takes about 1.4 times as long as Gaussian Blur. --- Compositor/Document/Filters.swift | 36 ++++++++++ Compositor/Rendering/AdjustPixels.c | 58 ++++++++++++++++ Compositor/Rendering/AdjustPixels.h | 8 +++ Compositor/UI/FilterSheet.swift | 9 +++ CompositorTests/SharpenFilterTests.swift | 88 ++++++++++++++++++++++++ README.md | 1 + 6 files changed, 200 insertions(+) create mode 100644 CompositorTests/SharpenFilterTests.swift 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