Skip to content

Commit 1eb5ed6

Browse files
authored
Merge pull request #7 from alexey1312/feature/perceptualPrecision
Add perceptualPrecision parameter
2 parents 09ef233 + fce61ad commit 1eb5ed6

File tree

2 files changed

+214
-98
lines changed

2 files changed

+214
-98
lines changed
+212-97
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
#if os(iOS) || os(tvOS)
22
import UIKit
33
import XCTest
4-
@testable import SnapshotTesting
4+
import SnapshotTesting
55

66
public extension Diffing where Value == UIImage {
77
/// 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()
99

1010
/// A pixel-diffing strategy for UIImage that allows customizing how precise the matching must be.
1111
///
1212
/// - 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)
1314
/// - Parameter scale: Scale to use when loading the reference image from disk. If `nil` or the `UITraitCollection`s
1415
/// default value of `0.0`, the screens scale is used.
1516
/// - Parameter compressionQuality: The desired compression quality to use when writing to an image destination.
1617
/// - Returns: A new diffing strategy.
1718
static func imageHEIC(
18-
precision: Float,
19-
scale: CGFloat?,
19+
precision: Float = 1,
20+
perceptualPrecision: Float = 1,
21+
scale: CGFloat? = nil,
2022
compressionQuality: CompressionQuality = .lossless
2123
) -> Diffing {
2224
let imageScale: CGFloat
@@ -28,32 +30,30 @@ public extension Diffing where Value == UIImage {
2830

2931
return Diffing(
3032
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)!
3735
},
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+
})
5757
}
5858

5959
/// 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 {
7373
public extension Snapshotting where Value == UIImage, Format == UIImage {
7474
/// A snapshot strategy for comparing images based on pixel equality.
7575
static var imageHEIC: Snapshotting {
76-
return .imageHEIC(precision: 1, scale: nil)
76+
return .imageHEIC()
7777
}
7878

7979
/// A snapshot strategy for comparing images based on pixel equality.
8080
///
8181
/// - 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)
8283
/// - Parameter scale: The scale of the reference image stored on disk.
8384
/// - Parameter compressionQuality: The desired compression quality to use when writing to an image destination.
8485
static func imageHEIC(
85-
precision: Float,
86-
scale: CGFloat?,
86+
precision: Float = 1,
87+
perceptualPrecision: Float = 1,
88+
scale: CGFloat? = nil,
8789
compressionQuality: CompressionQuality = .lossless
8890
) -> 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+
)
10599
}
106100
}
107101

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+
108107
private func compare(
109108
_ old: UIImage,
110109
_ new: UIImage,
111110
precision: Float,
111+
perceptualPrecision: Float,
112112
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
129128
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."
135131
}
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 }
143134
}
144-
145-
guard let newerCgImage = newer.cgImage else { return false }
146135
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
159170
}
160171

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
162174
guard
163-
let space = cgImage.colorSpace,
175+
let colorSpace = imageContextColorSpace,
164176
let context = CGContext(
165177
data: data,
166178
width: cgImage.width,
167179
height: cgImage.height,
168-
bitsPerComponent: cgImage.bitsPerComponent,
180+
bitsPerComponent: imageContextBitsPerComponent,
169181
bytesPerRow: bytesPerRow,
170-
space: space,
182+
space: colorSpace,
171183
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
172184
)
173185
else { return nil }
174-
186+
175187
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
176188
return context
177189
}
178190

179-
private func diffUIImage(_ old: UIImage, _ new: UIImage) -> UIImage {
191+
private func diffImage(_ old: UIImage, _ new: UIImage) -> UIImage {
180192
let width = max(old.size.width, new.size.width)
181193
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)
183196
new.draw(at: .zero)
184197
old.draw(at: .zero, blendMode: .difference, alpha: 1)
185198
let differenceImage = UIGraphicsGetImageFromCurrentImageContext()!
186199
UIGraphicsEndImageContext()
187200
return differenceImage
188201
}
189202
#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

Tests/SnapshotTestingHEICTests/SnapshotTestingHEICTests.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// Use iPhone 8 for tests
12
import XCTest
23
import SnapshotTesting
34
@testable import SnapshotTestingHEIC
@@ -10,7 +11,7 @@ final class SnapshotTestingHEICTests: XCTestCase {
1011
override func setUp() {
1112
super.setUp()
1213
sut = TestViewController()
13-
// isRecording = true
14+
// isRecording = true
1415
}
1516

1617
override func tearDown() {

0 commit comments

Comments
 (0)