From a2718d92940b231a70ba44ffe808a45232ca357b Mon Sep 17 00:00:00 2001 From: Fvzion Date: Tue, 29 Sep 2026 17:35:49 -0600 Subject: [PATCH] Camera Raw color noise reduction: clear pixels count as unsaturated The saturation plane that Color noise reduction blurs was never written under clear pixels (the luma plane beside it is), so the blur read whatever that memory held, and the pixels next to a transparent area came out differently from one run to the next. Co-Authored-By: Claude Opus 5.5 --- Compositor/Rendering/AdjustPixels.c | 4 +++- CompositorTests/CameraRawTests.swift | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Compositor/Rendering/AdjustPixels.c b/Compositor/Rendering/AdjustPixels.c index c8619e176..75f996e4d 100644 --- a/Compositor/Rendering/AdjustPixels.c +++ b/Compositor/Rendering/AdjustPixels.c @@ -945,7 +945,9 @@ void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t for (size_t x = 0; x < width; ++x) { uint8_t *p = row + x * 4; double alpha = p[3]; - if (!alpha) continue; + // Clear pixels count as unsaturated, as they count as black in the luma plane; left unwritten they + // would feed the blur whatever the memory held. + if (!alpha) { chroma[y * width + x] = 0; continue; } double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha); double h, s, l; rgb_to_hsl(r, g, b, &h, &s, &l); diff --git a/CompositorTests/CameraRawTests.swift b/CompositorTests/CameraRawTests.swift index 33ae22e46..c78590bf5 100644 --- a/CompositorTests/CameraRawTests.swift +++ b/CompositorTests/CameraRawTests.swift @@ -699,4 +699,32 @@ struct CameraRawTests { private func peakIndex(_ bins: [Double]) -> Int { bins.enumerated().max { $0.element < $1.element }?.offset ?? -1 } + + /// Color noise reduction blurs a saturation plane in which clear pixels must count as zero, as the luma plane's + /// do. Left unwritten, they held whatever that memory held before, so the pixels beside a clear area changed from + /// run to run. + @Test func colorNoiseReductionIgnoresWhatClearPixelsHeld() throws { + var settings = CameraRawSettings() + settings.detail.noiseColor = 60 + let context = try BrushRaster.context(width: 97, height: 61, mask: false) + let bytes = try #require(context.data).assumingMemoryBound(to: UInt8.self) + for y in 0..<61 { + for x in 0..<97 { + let p = y * context.bytesPerRow + x * 4 + let alpha = x < 30 ? 0 : 255 + bytes[p] = UInt8((x * 7 + y * 3) % 256 * alpha / 255); bytes[p + 1] = UInt8((x * x + y * 5) % 256 * alpha / 255) + bytes[p + 2] = UInt8((x * 2 + y * y) % 256 * alpha / 255); bytes[p + 3] = UInt8(alpha) + } + } + let picture = try #require(context.makeImage()) + func pixels(_ image: CGImage) throws -> Data { try #require(image.dataProvider?.data) as Data } + let first = try pixels(try settings.apply(picture)) + // Leaves saturation values in freed memory the size of the kernel's planes. + let red = try BrushRaster.context(width: 97, height: 61, mask: false) + red.setFillColor(CGColor(srgbRed: 1, green: 0, blue: 0.2, alpha: 1)) + red.fill(CGRect(x: 0, y: 0, width: 97, height: 61)) + let saturated = try #require(red.makeImage()) + for _ in 0..<4 { _ = try settings.apply(saturated) } + #expect(try pixels(try settings.apply(picture)) == first) + } }