Skip to content

Commit 36694f3

Browse files
Perceptual image precision + 90% speed improvement (#628)
* Add an optional perceptualPrecision parameter for image snapshot comparisons The perceptual precision number is between 0 & 1 that gets translated to a CIE94 tolerance https://en.wikipedia.org/wiki/Color_difference * Use CIColorThreshold and CIAreaAverage for a 70% faster image diff on iOS 14, tvOS 14, and macOS 11 * Add a unit test demonstrating the difference between pixel precision and perceptual precision * Update the reference image for the image precision test * Backport the threshold filter to macOS 10.13 by creating a CIImageProcessorKernel implementation * Update Sources/SnapshotTesting/Snapshotting/UIImage.swift * Update NSImage.swift Co-authored-by: Stephen Celis <[email protected]> Co-authored-by: Stephen Celis <[email protected]>
1 parent f13a134 commit 36694f3

18 files changed

+245
-84
lines changed

Sources/SnapshotTesting/Snapshotting/Any.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ extension Snapshotting where Format == String {
77
}
88
}
99

10-
@available(macOS 10.13, watchOS 4.0, *)
10+
@available(macOS 10.13, watchOS 4.0, tvOS 11.0, *)
1111
extension Snapshotting where Format == String {
1212
/// A snapshot strategy for comparing any structure based on their JSON representation.
1313
public static var json: Snapshotting {

Sources/SnapshotTesting/Snapshotting/CALayer.swift

+8-5
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ extension Snapshotting where Value == CALayer, Format == NSImage {
99

1010
/// A snapshot strategy for comparing layers based on pixel equality.
1111
///
12-
/// - Parameter precision: The percentage of pixels that must match.
13-
public static func image(precision: Float) -> Snapshotting {
14-
return SimplySnapshotting.image(precision: precision).pullback { layer in
12+
/// - Parameters:
13+
/// - precision: The percentage of pixels that must match.
14+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
15+
public static func image(precision: Float, perceptualPrecision: Float = 1) -> Snapshotting {
16+
return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision).pullback { layer in
1517
let image = NSImage(size: layer.bounds.size)
1618
image.lockFocus()
1719
let context = NSGraphicsContext.current!.cgContext
@@ -36,10 +38,11 @@ extension Snapshotting where Value == CALayer, Format == UIImage {
3638
///
3739
/// - Parameters:
3840
/// - precision: The percentage of pixels that must match.
41+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
3942
/// - traits: A trait collection override.
40-
public static func image(precision: Float = 1, traits: UITraitCollection = .init())
43+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1, traits: UITraitCollection = .init())
4144
-> Snapshotting {
42-
return SimplySnapshotting.image(precision: precision, scale: traits.displayScale).pullback { layer in
45+
return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale).pullback { layer in
4346
renderer(bounds: layer.bounds, for: traits).image { ctx in
4447
layer.setNeedsLayout()
4548
layer.layoutIfNeeded()

Sources/SnapshotTesting/Snapshotting/CGPath.swift

+10-6
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ extension Snapshotting where Value == CGPath, Format == NSImage {
99

1010
/// A snapshot strategy for comparing bezier paths based on pixel equality.
1111
///
12-
/// - Parameter precision: The percentage of pixels that must match.
13-
public static func image(precision: Float = 1, drawingMode: CGPathDrawingMode = .eoFill) -> Snapshotting {
14-
return SimplySnapshotting.image(precision: precision).pullback { path in
12+
/// - Parameters:
13+
/// - precision: The percentage of pixels that must match.
14+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
15+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1, drawingMode: CGPathDrawingMode = .eoFill) -> Snapshotting {
16+
return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision).pullback { path in
1517
let bounds = path.boundingBoxOfPath
1618
var transform = CGAffineTransform(translationX: -bounds.origin.x, y: -bounds.origin.y)
1719
let path = path.copy(using: &transform)!
@@ -38,9 +40,11 @@ extension Snapshotting where Value == CGPath, Format == UIImage {
3840

3941
/// A snapshot strategy for comparing bezier paths based on pixel equality.
4042
///
41-
/// - Parameter precision: The percentage of pixels that must match.
42-
public static func image(precision: Float = 1, scale: CGFloat = 1, drawingMode: CGPathDrawingMode = .eoFill) -> Snapshotting {
43-
return SimplySnapshotting.image(precision: precision, scale: scale).pullback { path in
43+
/// - Parameters:
44+
/// - precision: The percentage of pixels that must match.
45+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
46+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat = 1, drawingMode: CGPathDrawingMode = .eoFill) -> Snapshotting {
47+
return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision, scale: scale).pullback { path in
4448
let bounds = path.boundingBoxOfPath
4549
let format: UIGraphicsImageRendererFormat
4650
if #available(iOS 11.0, tvOS 11.0, *) {

Sources/SnapshotTesting/Snapshotting/NSBezierPath.swift

+5-3
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ extension Snapshotting where Value == NSBezierPath, Format == NSImage {
99

1010
/// A snapshot strategy for comparing bezier paths based on pixel equality.
1111
///
12-
/// - Parameter precision: The percentage of pixels that must match.
13-
public static func image(precision: Float = 1) -> Snapshotting {
14-
return SimplySnapshotting.image(precision: precision).pullback { path in
12+
/// - Parameters:
13+
/// - precision: The percentage of pixels that must match.
14+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
15+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1) -> Snapshotting {
16+
return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision).pullback { path in
1517
// Move path info frame:
1618
let bounds = path.bounds
1719
let transform = AffineTransform(translationByX: -bounds.origin.x, byY: -bounds.origin.y)

Sources/SnapshotTesting/Snapshotting/NSImage.swift

+61-23
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
#if os(macOS)
2+
import CoreImage.CIFilterBuiltins
23
import Cocoa
34
import XCTest
45

56
extension Diffing where Value == NSImage {
67
/// A pixel-diffing strategy for NSImage's which requires a 100% match.
7-
public static let image = Diffing.image(precision: 1)
8+
public static let image = Diffing.image()
89

910
/// A pixel-diffing strategy for NSImage that allows customizing how precise the matching must be.
1011
///
11-
/// - Parameter precision: A value between 0 and 1, where 1 means the images must match 100% of their pixels.
12+
/// - Parameters:
13+
/// - precision: The percentage of pixels that must match.
14+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
1215
/// - Returns: A new diffing strategy.
13-
public static func image(precision: Float) -> Diffing {
16+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1) -> Diffing {
1417
return .init(
1518
toData: { NSImagePNGRepresentation($0)! },
1619
fromData: { NSImage(data: $0)! }
1720
) { old, new in
18-
guard !compare(old, new, precision: precision) else { return nil }
21+
guard !compare(old, new, precision: precision, perceptualPrecision: perceptualPrecision) else { return nil }
1922
let difference = SnapshotTesting.diff(old, new)
2023
let message = new.size == old.size
2124
? "Newly-taken snapshot does not match reference."
@@ -31,16 +34,18 @@ extension Diffing where Value == NSImage {
3134
extension Snapshotting where Value == NSImage, Format == NSImage {
3235
/// A snapshot strategy for comparing images based on pixel equality.
3336
public static var image: Snapshotting {
34-
return .image(precision: 1)
37+
return .image()
3538
}
3639

3740
/// A snapshot strategy for comparing images based on pixel equality.
3841
///
39-
/// - Parameter precision: The percentage of pixels that must match.
40-
public static func image(precision: Float) -> Snapshotting {
42+
/// - Parameters:
43+
/// - precision: The percentage of pixels that must match.
44+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
45+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1) -> Snapshotting {
4146
return .init(
4247
pathExtension: "png",
43-
diffing: .image(precision: precision)
48+
diffing: .image(precision: precision, perceptualPrecision: perceptualPrecision)
4449
)
4550
}
4651
}
@@ -52,13 +57,11 @@ private func NSImagePNGRepresentation(_ image: NSImage) -> Data? {
5257
return rep.representation(using: .png, properties: [:])
5358
}
5459

55-
private func compare(_ old: NSImage, _ new: NSImage, precision: Float) -> Bool {
60+
private func compare(_ old: NSImage, _ new: NSImage, precision: Float, perceptualPrecision: Float) -> Bool {
5661
guard let oldCgImage = old.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return false }
5762
guard let newCgImage = new.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return false }
58-
guard oldCgImage.width != 0 else { return false }
5963
guard newCgImage.width != 0 else { return false }
6064
guard oldCgImage.width == newCgImage.width else { return false }
61-
guard oldCgImage.height != 0 else { return false }
6265
guard newCgImage.height != 0 else { return false }
6366
guard oldCgImage.height == newCgImage.height else { return false }
6467
guard let oldContext = context(for: oldCgImage) else { return false }
@@ -72,19 +75,54 @@ private func compare(_ old: NSImage, _ new: NSImage, precision: Float) -> Bool {
7275
guard let newerContext = context(for: newerCgImage) else { return false }
7376
guard let newerData = newerContext.data else { return false }
7477
if memcmp(oldData, newerData, byteCount) == 0 { return true }
75-
if precision >= 1 { return false }
76-
let oldRep = NSBitmapImageRep(cgImage: oldCgImage)
77-
let newRep = NSBitmapImageRep(cgImage: newerCgImage)
78-
var differentPixelCount = 0
79-
let pixelCount = oldRep.pixelsWide * oldRep.pixelsHigh
80-
let threshold = (1 - precision) * Float(pixelCount)
81-
let p1: UnsafeMutablePointer<UInt8> = oldRep.bitmapData!
82-
let p2: UnsafeMutablePointer<UInt8> = newRep.bitmapData!
83-
for offset in 0 ..< pixelCount * 4 {
84-
if p1[offset] != p2[offset] {
85-
differentPixelCount += 1
78+
if precision >= 1, perceptualPrecision >= 1 { return false }
79+
if perceptualPrecision < 1, #available(macOS 10.13, *) {
80+
let deltaFilter = CIFilter(
81+
name: "CILabDeltaE",
82+
parameters: [
83+
kCIInputImageKey: CIImage(cgImage: newCgImage),
84+
"inputImage2": CIImage(cgImage: oldCgImage)
85+
]
86+
)
87+
guard let deltaOutputImage = deltaFilter?.outputImage else { return false }
88+
let extent = CGRect(x: 0, y: 0, width: oldCgImage.width, height: oldCgImage.height)
89+
guard
90+
let thresholdOutputImage = try? ThresholdImageProcessorKernel.apply(
91+
withExtent: extent,
92+
inputs: [deltaOutputImage],
93+
arguments: [ThresholdImageProcessorKernel.inputThresholdKey: (1 - perceptualPrecision) * 100]
94+
)
95+
else { return false }
96+
let averageFilter = CIFilter(
97+
name: "CIAreaAverage",
98+
parameters: [
99+
kCIInputImageKey: thresholdOutputImage,
100+
kCIInputExtentKey: extent
101+
]
102+
)
103+
guard let averageOutputImage = averageFilter?.outputImage else { return false }
104+
var averagePixel: Float = 0
105+
CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull()]).render(
106+
averageOutputImage,
107+
toBitmap: &averagePixel,
108+
rowBytes: MemoryLayout<Float>.size,
109+
bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
110+
format: .Rf,
111+
colorSpace: nil
112+
)
113+
let pixelCountThreshold = 1 - precision
114+
if averagePixel > pixelCountThreshold { return false }
115+
} else {
116+
let oldRep = NSBitmapImageRep(cgImage: oldCgImage).bitmapData!
117+
let newRep = NSBitmapImageRep(cgImage: newerCgImage).bitmapData!
118+
let byteCountThreshold = Int((1 - precision) * Float(byteCount))
119+
var differentByteCount = 0
120+
for offset in 0..<byteCount {
121+
if oldRep[offset] != newRep[offset] {
122+
differentByteCount += 1
123+
if differentByteCount > byteCountThreshold { return false }
124+
}
86125
}
87-
if Float(differentPixelCount) > threshold { return false }
88126
}
89127
return true
90128
}

Sources/SnapshotTesting/Snapshotting/NSView.swift

+3-2
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ extension Snapshotting where Value == NSView, Format == NSImage {
1111
///
1212
/// - Parameters:
1313
/// - precision: The percentage of pixels that must match.
14+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
1415
/// - size: A view size override.
15-
public static func image(precision: Float = 1, size: CGSize? = nil) -> Snapshotting {
16-
return SimplySnapshotting.image(precision: precision).asyncPullback { view in
16+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil) -> Snapshotting {
17+
return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision).asyncPullback { view in
1718
let initialSize = view.frame.size
1819
if let size = size { view.frame.size = size }
1920
guard view.frame.width > 0, view.frame.height > 0 else {

Sources/SnapshotTesting/Snapshotting/NSViewController.swift

+3-2
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ extension Snapshotting where Value == NSViewController, Format == NSImage {
1111
///
1212
/// - Parameters:
1313
/// - precision: The percentage of pixels that must match.
14+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
1415
/// - size: A view size override.
15-
public static func image(precision: Float = 1, size: CGSize? = nil) -> Snapshotting {
16-
return Snapshotting<NSView, NSImage>.image(precision: precision, size: size).pullback { $0.view }
16+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil) -> Snapshotting {
17+
return Snapshotting<NSView, NSImage>.image(precision: precision, perceptualPrecision: perceptualPrecision, size: size).pullback { $0.view }
1718
}
1819
}
1920

Sources/SnapshotTesting/Snapshotting/SceneKit.swift

+8-6
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ extension Snapshotting where Value == SCNScene, Format == NSImage {
1212
///
1313
/// - Parameters:
1414
/// - precision: The percentage of pixels that must match.
15+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
1516
/// - size: The size of the scene.
16-
public static func image(precision: Float = 1, size: CGSize) -> Snapshotting {
17-
return .scnScene(precision: precision, size: size)
17+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) -> Snapshotting {
18+
return .scnScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size)
1819
}
1920
}
2021
#elseif os(iOS) || os(tvOS)
@@ -23,16 +24,17 @@ extension Snapshotting where Value == SCNScene, Format == UIImage {
2324
///
2425
/// - Parameters:
2526
/// - precision: The percentage of pixels that must match.
27+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
2628
/// - size: The size of the scene.
27-
public static func image(precision: Float = 1, size: CGSize) -> Snapshotting {
28-
return .scnScene(precision: precision, size: size)
29+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) -> Snapshotting {
30+
return .scnScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size)
2931
}
3032
}
3133
#endif
3234

3335
fileprivate extension Snapshotting where Value == SCNScene, Format == Image {
34-
static func scnScene(precision: Float, size: CGSize) -> Snapshotting {
35-
return Snapshotting<View, Image>.image(precision: precision).pullback { scene in
36+
static func scnScene(precision: Float, perceptualPrecision: Float, size: CGSize) -> Snapshotting {
37+
return Snapshotting<View, Image>.image(precision: precision, perceptualPrecision: perceptualPrecision).pullback { scene in
3638
let view = SCNView(frame: .init(x: 0, y: 0, width: size.width, height: size.height))
3739
view.scene = scene
3840
return view

Sources/SnapshotTesting/Snapshotting/SpriteKit.swift

+8-6
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ extension Snapshotting where Value == SKScene, Format == NSImage {
1212
///
1313
/// - Parameters:
1414
/// - precision: The percentage of pixels that must match.
15+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
1516
/// - size: The size of the scene.
16-
public static func image(precision: Float = 1, size: CGSize) -> Snapshotting {
17-
return .skScene(precision: precision, size: size)
17+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) -> Snapshotting {
18+
return .skScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size)
1819
}
1920
}
2021
#elseif os(iOS) || os(tvOS)
@@ -23,16 +24,17 @@ extension Snapshotting where Value == SKScene, Format == UIImage {
2324
///
2425
/// - Parameters:
2526
/// - precision: The percentage of pixels that must match.
27+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
2628
/// - size: The size of the scene.
27-
public static func image(precision: Float = 1, size: CGSize) -> Snapshotting {
28-
return .skScene(precision: precision, size: size)
29+
public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) -> Snapshotting {
30+
return .skScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size)
2931
}
3032
}
3133
#endif
3234

3335
fileprivate extension Snapshotting where Value == SKScene, Format == Image {
34-
static func skScene(precision: Float, size: CGSize) -> Snapshotting {
35-
return Snapshotting<View, Image>.image(precision: precision).pullback { scene in
36+
static func skScene(precision: Float, perceptualPrecision: Float, size: CGSize) -> Snapshotting {
37+
return Snapshotting<View, Image>.image(precision: precision, perceptualPrecision: perceptualPrecision).pullback { scene in
3638
let view = SKView(frame: .init(x: 0, y: 0, width: size.width, height: size.height))
3739
view.presentScene(scene)
3840
return view

Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift

+3-1
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@ extension Snapshotting where Value: SwiftUI.View, Format == UIImage {
2828
/// - Parameters:
2929
/// - drawHierarchyInKeyWindow: Utilize the simulator's key window in order to render `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your tests and will _not_ work for framework test targets.
3030
/// - precision: The percentage of pixels that must match.
31+
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. [98-99% mimics the precision of the human eye.](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e)
3132
/// - layout: A view layout override.
3233
/// - traits: A trait collection override.
3334
public static func image(
3435
drawHierarchyInKeyWindow: Bool = false,
3536
precision: Float = 1,
37+
perceptualPrecision: Float = 1,
3638
layout: SwiftUISnapshotLayout = .sizeThatFits,
3739
traits: UITraitCollection = .init()
3840
)
@@ -51,7 +53,7 @@ extension Snapshotting where Value: SwiftUI.View, Format == UIImage {
5153
config = .init(safeArea: .zero, size: size, traits: traits)
5254
}
5355

54-
return SimplySnapshotting.image(precision: precision, scale: traits.displayScale).asyncPullback { view in
56+
return SimplySnapshotting.image(precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale).asyncPullback { view in
5557
var config = config
5658

5759
let controller: UIViewController

0 commit comments

Comments
 (0)