1
1
#if os(iOS) || os(tvOS)
2
2
import UIKit
3
3
import XCTest
4
- @ testable import SnapshotTesting
4
+ import SnapshotTesting
5
5
6
6
public extension Diffing where Value == UIImage {
7
7
/// A pixel-diffing strategy for UIImage's which requires a 100% match.
8
- static let imageHEIC = Diffing . imageHEIC ( precision : 1 , scale : nil , compressionQuality : . lossless )
8
+ static let imageHEIC = Diffing . imageHEIC ( )
9
9
10
10
/// A pixel-diffing strategy for UIImage that allows customizing how precise the matching must be.
11
11
///
12
12
/// - Parameter precision: A value between 0 and 1, where 1 means the images must match 100% of their pixels.
13
+ /// - Parameter 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)
13
14
/// - Parameter scale: Scale to use when loading the reference image from disk. If `nil` or the `UITraitCollection`s
14
15
/// default value of `0.0`, the screens scale is used.
15
16
/// - Parameter compressionQuality: The desired compression quality to use when writing to an image destination.
16
17
/// - Returns: A new diffing strategy.
17
18
static func imageHEIC(
18
- precision: Float ,
19
- scale: CGFloat ? ,
19
+ precision: Float = 1 ,
20
+ perceptualPrecision: Float = 1 ,
21
+ scale: CGFloat ? = nil ,
20
22
compressionQuality: CompressionQuality = . lossless
21
23
) -> Diffing {
22
24
let imageScale : CGFloat
@@ -28,32 +30,30 @@ public extension Diffing where Value == UIImage {
28
30
29
31
return Diffing (
30
32
toData: {
31
- if #available( tvOSApplicationExtension 11 . 0 , * ) {
32
- return $0. heicData ( compressionQuality: compressionQuality) ?? emptyImage ( )
33
- . heicData ( compressionQuality: compressionQuality) !
34
- } else {
35
- return $0. pngData ( ) ?? emptyImage ( ) . pngData ( ) !
36
- }
33
+ return $0. heicData ( compressionQuality: compressionQuality) ?? emptyImage ( )
34
+ . heicData ( compressionQuality: compressionQuality) !
37
35
} ,
38
- fromData: { UIImage ( data: $0, scale: imageScale) ! }
39
- ) { old, new in
40
- guard !compare( old, new, precision: precision, compressionQuality: compressionQuality)
41
- else { return nil }
42
- let difference = diffUIImage ( old, new)
43
- let message = new. size == old. size
44
- ? " Newly-taken snapshot does not match reference. "
45
- : " Newly-taken snapshot@ \( new. size) does not match reference@ \( old. size) . "
46
- let oldAttachment = XCTAttachment ( image: old)
47
- oldAttachment. name = " reference "
48
- let newAttachment = XCTAttachment ( image: new)
49
- newAttachment. name = " failure "
50
- let differenceAttachment = XCTAttachment ( image: difference)
51
- differenceAttachment. name = " difference "
52
- return (
53
- message,
54
- [ oldAttachment, newAttachment, differenceAttachment]
55
- )
56
- }
36
+ fromData: { UIImage ( data: $0, scale: imageScale) ! } ,
37
+ diff: { old, new in
38
+ guard let message = compare ( old, new,
39
+ precision: precision,
40
+ perceptualPrecision: perceptualPrecision,
41
+ compressionQuality: compressionQuality)
42
+ else { return nil }
43
+
44
+ let difference = diffImage ( old, new)
45
+ let oldAttachment = XCTAttachment ( image: old)
46
+ oldAttachment. name = " reference "
47
+ let isEmptyImage = new. size == . zero
48
+ let newAttachment = XCTAttachment ( image: isEmptyImage ? emptyImage ( ) : new)
49
+ newAttachment. name = " failure "
50
+ let differenceAttachment = XCTAttachment ( image: difference)
51
+ differenceAttachment. name = " difference "
52
+ return (
53
+ message,
54
+ [ oldAttachment, newAttachment, differenceAttachment]
55
+ )
56
+ } )
57
57
}
58
58
59
59
/// Used when the image size has no width or no height to generated the default empty image
@@ -73,117 +73,232 @@ public extension Diffing where Value == UIImage {
73
73
public extension Snapshotting where Value == UIImage , Format == UIImage {
74
74
/// A snapshot strategy for comparing images based on pixel equality.
75
75
static var imageHEIC : Snapshotting {
76
- return . imageHEIC( precision : 1 , scale : nil )
76
+ return . imageHEIC( )
77
77
}
78
78
79
79
/// A snapshot strategy for comparing images based on pixel equality.
80
80
///
81
81
/// - Parameter precision: The percentage of pixels that must match.
82
+ /// - Parameter 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)
82
83
/// - Parameter scale: The scale of the reference image stored on disk.
83
84
/// - Parameter compressionQuality: The desired compression quality to use when writing to an image destination.
84
85
static func imageHEIC(
85
- precision: Float ,
86
- scale: CGFloat ? ,
86
+ precision: Float = 1 ,
87
+ perceptualPrecision: Float = 1 ,
88
+ scale: CGFloat ? = nil ,
87
89
compressionQuality: CompressionQuality = . lossless
88
90
) -> Snapshotting {
89
- let snapshotting : Snapshotting
90
-
91
- if #available( tvOSApplicationExtension 11 . 0 , * ) {
92
- snapshotting = Snapshotting (
93
- pathExtension: " heic " ,
94
- diffing: Diffing< UIImage>
95
- . imageHEIC( precision: precision, scale: scale, compressionQuality: compressionQuality)
96
- )
97
- } else {
98
- snapshotting = Snapshotting (
99
- pathExtension: " png " ,
100
- diffing: Diffing< UIImage> . image( precision: precision, scale: scale)
101
- )
102
- }
103
-
104
- return snapshotting
91
+ return Snapshotting (
92
+ pathExtension: " heic " ,
93
+ diffing: Diffing< UIImage>
94
+ . imageHEIC( precision: precision,
95
+ perceptualPrecision: perceptualPrecision,
96
+ scale: scale,
97
+ compressionQuality: compressionQuality)
98
+ )
105
99
}
106
100
}
107
101
102
+ // remap snapshot & reference to same colorspace
103
+ private let imageContextColorSpace = CGColorSpace ( name: CGColorSpace . sRGB)
104
+ private let imageContextBitsPerComponent = 8
105
+ private let imageContextBytesPerPixel = 4
106
+
108
107
private func compare(
109
108
_ old: UIImage ,
110
109
_ new: UIImage ,
111
110
precision: Float ,
111
+ perceptualPrecision: Float ,
112
112
compressionQuality: CompressionQuality
113
- ) -> Bool {
114
- guard let oldCgImage = old. cgImage else { return false }
115
- guard let newCgImage = new. cgImage else { return false }
116
- guard oldCgImage. width != 0 else { return false }
117
- guard newCgImage. width != 0 else { return false }
118
- guard oldCgImage. width == newCgImage. width else { return false }
119
- guard oldCgImage. height != 0 else { return false }
120
- guard newCgImage. height != 0 else { return false }
121
- guard oldCgImage. height == newCgImage. height else { return false }
122
- // Values between images may differ due to padding to multiple of 64 bytes per row,
123
- // because of that a freshly taken view snapshot may differ from one stored as HEIC.
124
- // At this point we're sure that size of both images is the same, so we can go with minimal `bytesPerRow` value
125
- // and use it to create contexts.
126
- let minBytesPerRow = min ( oldCgImage. bytesPerRow, newCgImage. bytesPerRow)
127
- let byteCount = minBytesPerRow * oldCgImage. height
128
-
113
+ ) -> String ? {
114
+ guard let oldCgImage = old. cgImage else {
115
+ return " Reference image could not be loaded. "
116
+ }
117
+ guard let newCgImage = new. cgImage else {
118
+ return " Newly-taken snapshot could not be loaded. "
119
+ }
120
+ guard newCgImage. width != 0 , newCgImage. height != 0 else {
121
+ return " Newly-taken snapshot is empty. "
122
+ }
123
+ guard oldCgImage. width == newCgImage. width, oldCgImage. height == newCgImage. height else {
124
+ return " Newly-taken snapshot@ \( new. size) does not match reference@ \( old. size) . "
125
+ }
126
+ let pixelCount = oldCgImage. width * oldCgImage. height
127
+ let byteCount = imageContextBytesPerPixel * pixelCount
129
128
var oldBytes = [ UInt8] ( repeating: 0 , count: byteCount)
130
- guard let oldContext = context ( for: oldCgImage, bytesPerRow: minBytesPerRow, data: & oldBytes)
131
- else { return false }
132
- guard let oldData = oldContext. data else { return false }
133
- if let newContext = context ( for: newCgImage, bytesPerRow: minBytesPerRow) , let newData = newContext. data {
134
- if memcmp ( oldData, newData, byteCount) == 0 { return true }
129
+ guard let oldData = context ( for: oldCgImage, data: & oldBytes) ? . data else {
130
+ return " Reference image's data could not be loaded. "
135
131
}
136
-
137
- let newer : UIImage
138
-
139
- if #available( tvOSApplicationExtension 11 . 0 , * ) {
140
- newer = UIImage ( data: new. heicData ( compressionQuality: compressionQuality) !) !
141
- } else {
142
- newer = UIImage ( data: new. pngData ( ) !) !
132
+ if let newContext = context ( for: newCgImage) , let newData = newContext. data {
133
+ if memcmp ( oldData, newData, byteCount) == 0 { return nil }
143
134
}
144
-
145
- guard let newerCgImage = newer. cgImage else { return false }
146
135
var newerBytes = [ UInt8] ( repeating: 0 , count: byteCount)
147
- guard let newerContext = context ( for: newerCgImage, bytesPerRow: minBytesPerRow, data: & newerBytes)
148
- else { return false }
149
- guard let newerData = newerContext. data else { return false }
150
- if memcmp ( oldData, newerData, byteCount) == 0 { return true }
151
- if precision >= 1 { return false }
152
- var differentPixelCount = 0
153
- let threshold = 1 - precision
154
- for byte in 0 ..< byteCount {
155
- if oldBytes [ byte] != newerBytes [ byte] { differentPixelCount += 1 }
156
- if Float ( differentPixelCount) / Float( byteCount) > threshold { return false }
157
- }
158
- return true
136
+
137
+ guard
138
+ let heicData = new. heicData ( compressionQuality: compressionQuality) ,
139
+ let newerCgImage = UIImage ( data: heicData) ? . cgImage,
140
+ let newerContext = context ( for: newerCgImage, data: & newerBytes) ,
141
+ let newerData = newerContext. data
142
+ else {
143
+ return " Newly-taken snapshot's data could not be loaded. "
144
+ }
145
+ if memcmp ( oldData, newerData, byteCount) == 0 { return nil }
146
+ if precision >= 1 , perceptualPrecision >= 1 {
147
+ return " Newly-taken snapshot does not match reference. "
148
+ }
149
+ if perceptualPrecision < 1 , #available( iOS 11 . 0 , tvOS 11 . 0 , * ) {
150
+ return perceptuallyCompare (
151
+ CIImage ( cgImage: oldCgImage) ,
152
+ CIImage ( cgImage: newCgImage) ,
153
+ pixelPrecision: precision,
154
+ perceptualPrecision: perceptualPrecision
155
+ )
156
+ } else {
157
+ let byteCountThreshold = Int ( ( 1 - precision) * Float( byteCount) )
158
+ var differentByteCount = 0
159
+ for offset in 0 ..< byteCount {
160
+ if oldBytes [ offset] != newerBytes [ offset] {
161
+ differentByteCount += 1
162
+ }
163
+ }
164
+ if differentByteCount > byteCountThreshold {
165
+ let actualPrecision = 1 - Float( differentByteCount) / Float( byteCount)
166
+ return " Actual image precision \( actualPrecision) is less than required \( precision) "
167
+ }
168
+ }
169
+ return nil
159
170
}
160
171
161
- private func context( for cgImage: CGImage , bytesPerRow: Int , data: UnsafeMutableRawPointer ? = nil ) -> CGContext ? {
172
+ private func context( for cgImage: CGImage , data: UnsafeMutableRawPointer ? = nil ) -> CGContext ? {
173
+ let bytesPerRow = cgImage. width * imageContextBytesPerPixel
162
174
guard
163
- let space = cgImage . colorSpace ,
175
+ let colorSpace = imageContextColorSpace ,
164
176
let context = CGContext (
165
177
data: data,
166
178
width: cgImage. width,
167
179
height: cgImage. height,
168
- bitsPerComponent: cgImage . bitsPerComponent ,
180
+ bitsPerComponent: imageContextBitsPerComponent ,
169
181
bytesPerRow: bytesPerRow,
170
- space: space ,
182
+ space: colorSpace ,
171
183
bitmapInfo: CGImageAlphaInfo . premultipliedLast. rawValue
172
184
)
173
185
else { return nil }
174
-
186
+
175
187
context. draw ( cgImage, in: CGRect ( x: 0 , y: 0 , width: cgImage. width, height: cgImage. height) )
176
188
return context
177
189
}
178
190
179
- private func diffUIImage ( _ old: UIImage , _ new: UIImage ) -> UIImage {
191
+ private func diffImage ( _ old: UIImage , _ new: UIImage ) -> UIImage {
180
192
let width = max ( old. size. width, new. size. width)
181
193
let height = max ( old. size. height, new. size. height)
182
- UIGraphicsBeginImageContextWithOptions ( CGSize ( width: width, height: height) , true , 0 )
194
+ let scale = max ( old. scale, new. scale)
195
+ UIGraphicsBeginImageContextWithOptions ( CGSize ( width: width, height: height) , true , scale)
183
196
new. draw ( at: . zero)
184
197
old. draw ( at: . zero, blendMode: . difference, alpha: 1 )
185
198
let differenceImage = UIGraphicsGetImageFromCurrentImageContext ( ) !
186
199
UIGraphicsEndImageContext ( )
187
200
return differenceImage
188
201
}
189
202
#endif
203
+
204
+ #if os(iOS) || os(tvOS) || os(macOS)
205
+ import CoreImage. CIKernel
206
+ import MetalPerformanceShaders
207
+
208
+ @available ( iOS 10 . 0 , tvOS 10 . 0 , macOS 10 . 13 , * )
209
+ func perceptuallyCompare( _ old: CIImage , _ new: CIImage , pixelPrecision: Float , perceptualPrecision: Float ) -> String ? {
210
+ let deltaOutputImage = old. applyingFilter ( " CILabDeltaE " , parameters: [ " inputImage2 " : new] )
211
+ let thresholdOutputImage : CIImage
212
+ do {
213
+ thresholdOutputImage = try ThresholdImageProcessorKernel . apply (
214
+ withExtent: new. extent,
215
+ inputs: [ deltaOutputImage] ,
216
+ arguments: [ ThresholdImageProcessorKernel . inputThresholdKey: ( 1 - perceptualPrecision) * 100 ]
217
+ )
218
+ } catch {
219
+ return " Newly-taken snapshot's data could not be loaded. \( error) "
220
+ }
221
+ var averagePixel : Float = 0
222
+ let context = CIContext ( options: [ . workingColorSpace: NSNull ( ) , . outputColorSpace: NSNull ( ) ] )
223
+ context. render (
224
+ thresholdOutputImage. applyingFilter ( " CIAreaAverage " , parameters: [ kCIInputExtentKey: new. extent] ) ,
225
+ toBitmap: & averagePixel,
226
+ rowBytes: MemoryLayout< Float> . size,
227
+ bounds: CGRect ( x: 0 , y: 0 , width: 1 , height: 1 ) ,
228
+ format: . Rf,
229
+ colorSpace: nil
230
+ )
231
+ let actualPixelPrecision = 1 - averagePixel
232
+ guard actualPixelPrecision < pixelPrecision else { return nil }
233
+ var maximumDeltaE : Float = 0
234
+ context. render (
235
+ deltaOutputImage. applyingFilter ( " CIAreaMaximum " , parameters: [ kCIInputExtentKey: new. extent] ) ,
236
+ toBitmap: & maximumDeltaE,
237
+ rowBytes: MemoryLayout< Float> . size,
238
+ bounds: CGRect ( x: 0 , y: 0 , width: 1 , height: 1 ) ,
239
+ format: . Rf,
240
+ colorSpace: nil
241
+ )
242
+ let actualPerceptualPrecision = 1 - maximumDeltaE / 100
243
+ if pixelPrecision < 1 {
244
+ return """
245
+ Actual image precision \( actualPixelPrecision) is less than required \( pixelPrecision)
246
+ Actual perceptual precision \( actualPerceptualPrecision) is less than required \( perceptualPrecision)
247
+ """
248
+ } else {
249
+ return " Actual perceptual precision \( actualPerceptualPrecision) is less than required \( perceptualPrecision) "
250
+ }
251
+ }
252
+
253
+ // Copied from https://developer.apple.com/documentation/coreimage/ciimageprocessorkernel
254
+ @available ( iOS 10 . 0 , tvOS 10 . 0 , macOS 10 . 13 , * )
255
+ final class ThresholdImageProcessorKernel : CIImageProcessorKernel {
256
+ static let inputThresholdKey = " thresholdValue "
257
+ static let device = MTLCreateSystemDefaultDevice ( )
258
+
259
+ override class func process( with inputs: [ CIImageProcessorInput ] ? , arguments: [ String : Any ] ? , output: CIImageProcessorOutput ) throws {
260
+ guard
261
+ let device = device,
262
+ let commandBuffer = output. metalCommandBuffer,
263
+ let input = inputs? . first,
264
+ let sourceTexture = input. metalTexture,
265
+ let destinationTexture = output. metalTexture,
266
+ let thresholdValue = arguments ? [ inputThresholdKey] as? Float else {
267
+ return
268
+ }
269
+
270
+ let threshold = MPSImageThresholdBinary (
271
+ device: device,
272
+ thresholdValue: thresholdValue,
273
+ maximumValue: 1.0 ,
274
+ linearGrayColorTransform: nil
275
+ )
276
+
277
+ threshold. encode (
278
+ commandBuffer: commandBuffer,
279
+ sourceTexture: sourceTexture,
280
+ destinationTexture: destinationTexture
281
+ )
282
+ }
283
+ }
284
+ #endif
285
+
286
+
287
+ #if os(macOS)
288
+ typealias Image = NSImage
289
+ typealias View = NSView
290
+ #elseif os(iOS) || os(tvOS)
291
+ typealias Image = UIImage
292
+ typealias View = UIView
293
+ #endif
294
+
295
+ #if os(iOS) || os(tvOS)
296
+ extension View {
297
+ func asImage( ) -> Image {
298
+ let renderer = UIGraphicsImageRenderer ( bounds: bounds)
299
+ return renderer. image { rendererContext in
300
+ layer. render ( in: rendererContext. cgContext)
301
+ }
302
+ }
303
+ }
304
+ #endif
0 commit comments