diff --git a/Sources/CoreRaster/Effects/BlurFamily.swift b/Sources/CoreRaster/Effects/BlurFamily.swift index 3795f05b..3e002ced 100644 --- a/Sources/CoreRaster/Effects/BlurFamily.swift +++ b/Sources/CoreRaster/Effects/BlurFamily.swift @@ -3,9 +3,10 @@ // running-sum passes per axis (the result approaches a Gaussian); a fractional // radius weights the two outer taps by its fraction. Motion blur averages // ceil(distance) + 1 bilinear samples one step (distance / ceil(distance), at -// most 1 px) apart along the angle, starting on a whole pixel, so an integer -// distance on an axis lands on whole pixels and spreads over distance + 1 of -// them. Both read past the edge as the nearest edge pixel. Invariants: radius +// most 1 px) apart along the angle, centred on the pixel; an integer distance +// starts on a whole pixel instead, so on an axis it lands on whole pixels and +// spreads over distance + 1 of them (an odd one sits 1/2 px toward -angle). +// Both read past the edge as the nearest edge pixel. Invariants: radius // and distance are finite and in 0...1,000 (0 is identity), degrees finite; // every weight set sums to 1, so the output is a convex mix of the input. #if canImport(Glibc) @@ -21,7 +22,11 @@ extension PixelBuffer { static let maxBlurReach = 1_000.0 /// This buffer box-blurred by `radius` pixels: three box passes along - /// each axis. Radius 0 returns the buffer unchanged. Throws + /// each axis. Each pass averages a 2·radius+1 px window, so colour spreads + /// up to 3·radius px; the result approximates a Gaussian of sigma ≈ + /// sqrt(radius·(radius+1)). Pad by 3·radius to keep the full blur. The + /// buffer reads past its edge as the nearest edge pixel. Radius 0 returns + /// the buffer unchanged. Throws /// `RasterError.invalid` for a radius that is not finite or not in 0...1,000. public func boxBlurred(radius: Double) throws -> PixelBuffer { try Self.checkReach(radius, "box blur radius") @@ -36,7 +41,13 @@ extension PixelBuffer { } /// This buffer blurred `distance` pixels along `degrees` (0 is +x, 90 is - /// +y). Distance 0 returns the buffer unchanged. Throws + /// +y). The blur is centred, so only the axis matters: each pixel averages + /// ceil(distance)+1 samples from -distance/2 to +distance/2 px along the + /// angle. An integer distance starts on the whole pixel floor(-distance/2) + /// so its samples land on whole pixels; for an odd one the window sits + /// 1/2 px toward -angle, so degrees and degrees+180 differ by 1 px. The + /// buffer reads past its edge as the nearest edge pixel. Distance 0 + /// returns the buffer unchanged. Throws /// `RasterError.invalid` for a non-finite angle, or a distance that is not /// finite or not in 0...1,000. public func motionBlurred(degrees: Double, distance: Double) throws -> PixelBuffer { @@ -53,7 +64,9 @@ extension PixelBuffer { let steps = distance.rounded(.up) let count = Int(steps) + 1 let step = distance / steps - let start = (-distance / 2).rounded(.down) + // Centred exactly; an integer distance snaps down to a whole pixel so + // it spreads over distance + 1 whole pixels instead of distance + 2. + let start = distance == steps ? (-distance / 2).rounded(.down) : -distance / 2 let src = components, c = Self.channels var out = [Float](repeating: 0, count: src.count) var r = 0.0, g = 0.0, b = 0.0, a = 0.0 diff --git a/Tests/CoreRasterTests/BlurFamilyTests.swift b/Tests/CoreRasterTests/BlurFamilyTests.swift index 9d5f68b7..1b3292a8 100644 --- a/Tests/CoreRasterTests/BlurFamilyTests.swift +++ b/Tests/CoreRasterTests/BlurFamilyTests.swift @@ -1,7 +1,8 @@ // BlurFamilyTests.swift: effects-engine FX-3's acceptance, written first (FX-1), // on since FX-3 built `boxBlurred` and `motionBlurred`: zero is identity, -// energy is kept, motion runs along its angle in degrees, transparency never -// tints an edge, bad parameters throw. `EffectFuzz` serves every effects suite. +// energy is kept, box blur runs three passes on both axes, motion runs centred +// along its angle in degrees, edges extend, transparency never tints an edge, +// bad parameters throw. `EffectFuzz` serves every effects suite. import CoreRaster import Testing @@ -53,6 +54,48 @@ enum EffectFuzz { let blurred = try disc.boxBlurred(radius: radius) #expect(abs(try EffectFixtures.alphaSum(blurred) - before) / before <= 1e-4) #expect(try EffectFixtures.occupied(blurred).columns.count > EffectFixtures.occupied(disc).columns.count) + #expect(try EffectFixtures.occupied(blurred).rows.count > EffectFixtures.occupied(disc).rows.count) + } + } + + @Test func boxSpreadsBothAxesThreePasses() throws { + let blurred = try dot().boxBlurred(radius: 1) + let o = try EffectFixtures.occupied(blurred, threshold: 0) + #expect(o.rows == Set(13...19) && o.columns == Set(13...19)) + // The three-pass kernel is [1, 3, 6, 7, 6, 3, 1] / 27 per axis. + #expect(abs(Double(try blurred.pixel(x: 16, y: 16).a) - (7.0 / 27) * (7.0 / 27)) <= 1e-6) + } + + @Test func boxReachesThreeRadii() throws { + let blurred = try dot().boxBlurred(radius: 2) + for (x, y) in [(22, 16), (10, 16), (16, 22), (16, 10)] { #expect(try blurred.pixel(x: x, y: y).a > 0) } + for (x, y) in [(23, 16), (9, 16), (16, 23), (16, 9)] { #expect(try blurred.pixel(x: x, y: y).a == 0) } + } + + @Test func opaqueEdgesStayOpaque() throws { + var flat = try PixelBuffer(width: 12, height: 12) + for y in 0..<12 { for x in 0..<12 { try flat.setPixel(x: x, y: y, red) } } + for blurred in [try flat.boxBlurred(radius: 3), try flat.motionBlurred(degrees: 0, distance: 7)] { + for y in 0..<12 { + for x in 0..<12 { + let p = try blurred.pixel(x: x, y: y) + #expect(abs(p.r - 1) <= 1e-6 && abs(p.g) <= 1e-6 && abs(p.b) <= 1e-6 && abs(p.a - 1) <= 1e-6, "(\(x), \(y))") + } + } + } + } + + @Test func motionIsCentred() throws { + for distance in [0.4, 2.5, 9] { + var centres: [Double] = [] + for degrees in [0.0, 180] { + let c = try EffectFixtures.centroid(dot().motionBlurred(degrees: degrees, distance: distance)) + #expect(c.map { abs($0.x - 16) <= 0.5 + 1e-9 && abs($0.y - 16) <= 1e-9 } == true, "\(distance) at \(degrees)") + if let c { centres.append(c.x) } + } + // Only an integer distance may sit off-centre (1/2 px toward -angle when odd). + let tolerance = distance == distance.rounded() ? 1 + 1e-9 : 1e-6 + if centres.count == 2 { #expect(abs(centres[0] - centres[1]) <= tolerance, "\(distance)") } } } @@ -71,6 +114,12 @@ enum EffectFuzz { let o = try EffectFixtures.occupied(dot().motionBlurred(degrees: degrees, distance: 9)) #expect(alongX ? o.rows == [16] && o.columns.count == 10 : o.columns == [16] && o.rows.count == 10, "\(degrees)") } + // The cos/sin residue at right angles would leak ~1e-16 into the next + // row or column where samples straddle 0 (here the sample 2 px along + // the blur, which the residue moves just past column or row 0); the + // snap keeps it exactly 0. + #expect(try dot(x: 1, y: 18).motionBlurred(degrees: 90, distance: 9).pixel(x: 0, y: 16).a == 0) + #expect(try dot(x: 14, y: 1).motionBlurred(degrees: 180, distance: 9).pixel(x: 16, y: 0).a == 0) } @Test func noBleedFromTransparent() throws { @@ -93,6 +142,9 @@ enum EffectFuzz { #expect(throws: RasterError.self) { try b.motionBlurred(degrees: 0, distance: bad) } } #expect(throws: RasterError.self) { try b.motionBlurred(degrees: .nan, distance: 3) } + // The cap is inclusive: exactly 1,000 is accepted. + #expect(try b.boxBlurred(radius: 1_000).width == 8) + #expect(try b.motionBlurred(degrees: 0, distance: 1_000).width == 8) } @Test func fuzzNeverCrashes() throws { diff --git a/Tests/mutations/CoreRaster.BlurFamily.json b/Tests/mutations/CoreRaster.BlurFamily.json index 5ea7ac3c..2665c99f 100644 --- a/Tests/mutations/CoreRaster.BlurFamily.json +++ b/Tests/mutations/CoreRaster.BlurFamily.json @@ -19,5 +19,47 @@ "anchor": "let radians = degrees * .pi / 180", "replacement": "let radians = degrees", "filter": "BlurFamilyTests" + }, + { + "note": "motion blur floors its first sample offset at every distance, so a 0.4 px blur shifts the layer 0.8 px instead of blurring it in place, caught by BlurFamilyTests.motionIsCentred", + "file": "Sources/CoreRaster/Effects/BlurFamily.swift", + "anchor": "let start = distance == steps ? (-distance / 2).rounded(.down) : -distance / 2", + "replacement": "let start = (-distance / 2).rounded(.down)", + "filter": "BlurFamilyTests" + }, + { + "note": "box blur skips its vertical pass, so it only spreads along rows, caught by BlurFamilyTests.boxSpreadsBothAxesThreePasses", + "file": "Sources/CoreRaster/Effects/BlurFamily.swift", + "anchor": "data = Self.boxPass(data, lines: width, length: height, along: width * c, across: c, radius: radius)", + "replacement": "_ = width", + "filter": "BlurFamilyTests" + }, + { + "note": "box blur runs one pass per axis instead of three, so it reaches radius px not 3 radius, caught by BlurFamilyTests.boxSpreadsBothAxesThreePasses", + "file": "Sources/CoreRaster/Effects/BlurFamily.swift", + "anchor": "for _ in 0..<3 {", + "replacement": "for _ in 0..<1 {", + "filter": "BlurFamilyTests" + }, + { + "note": "box blur pads before the start of a line with zeros instead of the edge pixel, so opaque edges darken, caught by BlurFamilyTests.opaqueEdgesStayOpaque", + "file": "Sources/CoreRaster/Effects/BlurFamily.swift", + "anchor": "if j <= 0 { return Double(j) * value(0, k) }", + "replacement": "if j <= 0 { return 0 }", + "filter": "BlurFamilyTests" + }, + { + "note": "motion blur keeps the cos residue at 90 degrees, so a vertical blur leaks a 1e-16 weight into the next column, caught by BlurFamilyTests.horizontalSpreadsColumns", + "file": "Sources/CoreRaster/Effects/BlurFamily.swift", + "anchor": "let ux = abs(cos(radians)) < 1e-12 ? 0 : cos(radians)", + "replacement": "let ux = cos(radians)", + "filter": "BlurFamilyTests" + }, + { + "note": "blur reach check excludes its upper bound, so radius or distance 1,000 throws, caught by BlurFamilyTests.overCapThrows", + "file": "Sources/CoreRaster/Effects/BlurFamily.swift", + "anchor": "value <= maxBlurReach", + "replacement": "value < maxBlurReach", + "filter": "BlurFamilyTests" } ]