Skip to content
Merged
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
25 changes: 19 additions & 6 deletions Sources/CoreRaster/Effects/BlurFamily.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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 {
Expand All @@ -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
Expand Down
56 changes: 54 additions & 2 deletions Tests/CoreRasterTests/BlurFamilyTests.swift
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)") }
}
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
42 changes: 42 additions & 0 deletions Tests/mutations/CoreRaster.BlurFamily.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Loading