Skip to content

Commit 208cb71

Browse files
authored
Report the actual and expected image precision when an image comparison fails to match (#638)
1 parent 43f386f commit 208cb71

File tree

2 files changed

+135
-118
lines changed

2 files changed

+135
-118
lines changed

Sources/SnapshotTesting/Snapshotting/NSImage.swift

Lines changed: 50 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
#if os(macOS)
2-
import CoreImage.CIFilterBuiltins
32
import Cocoa
43
import XCTest
54

@@ -18,14 +17,17 @@ extension Diffing where Value == NSImage {
1817
toData: { NSImagePNGRepresentation($0)! },
1918
fromData: { NSImage(data: $0)! }
2019
) { old, new in
21-
guard !compare(old, new, precision: precision, perceptualPrecision: perceptualPrecision) else { return nil }
20+
guard let message = compare(old, new, precision: precision, perceptualPrecision: perceptualPrecision) else { return nil }
2221
let difference = SnapshotTesting.diff(old, new)
23-
let message = new.size == old.size
24-
? "Newly-taken snapshot does not match reference."
25-
: "Newly-taken snapshot@\(new.size) does not match reference@\(old.size)."
22+
let oldAttachment = XCTAttachment(image: old)
23+
oldAttachment.name = "reference"
24+
let newAttachment = XCTAttachment(image: new)
25+
newAttachment.name = "failure"
26+
let differenceAttachment = XCTAttachment(image: difference)
27+
differenceAttachment.name = "difference"
2628
return (
2729
message,
28-
[XCTAttachment(image: old), XCTAttachment(image: new), XCTAttachment(image: difference)]
30+
[oldAttachment, newAttachment, differenceAttachment]
2931
)
3032
}
3133
}
@@ -57,61 +59,46 @@ private func NSImagePNGRepresentation(_ image: NSImage) -> Data? {
5759
return rep.representation(using: .png, properties: [:])
5860
}
5961

60-
private func compare(_ old: NSImage, _ new: NSImage, precision: Float, perceptualPrecision: Float) -> Bool {
61-
guard let oldCgImage = old.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return false }
62-
guard let newCgImage = new.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return false }
63-
guard newCgImage.width != 0 else { return false }
64-
guard oldCgImage.width == newCgImage.width else { return false }
65-
guard newCgImage.height != 0 else { return false }
66-
guard oldCgImage.height == newCgImage.height else { return false }
67-
guard let oldContext = context(for: oldCgImage) else { return false }
68-
guard let newContext = context(for: newCgImage) else { return false }
69-
guard let oldData = oldContext.data else { return false }
70-
guard let newData = newContext.data else { return false }
62+
private func compare(_ old: NSImage, _ new: NSImage, precision: Float, perceptualPrecision: Float) -> String? {
63+
guard let oldCgImage = old.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
64+
return "Reference image could not be loaded."
65+
}
66+
guard let newCgImage = new.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
67+
return "Newly-taken snapshot could not be loaded."
68+
}
69+
guard newCgImage.width != 0, newCgImage.height != 0 else {
70+
return "Newly-taken snapshot is empty."
71+
}
72+
guard oldCgImage.width == newCgImage.width, oldCgImage.height == newCgImage.height else {
73+
return "Newly-taken snapshot@\(new.size) does not match reference@\(old.size)."
74+
}
75+
guard let oldContext = context(for: oldCgImage), let oldData = oldContext.data else {
76+
return "Reference image's data could not be loaded."
77+
}
78+
guard let newContext = context(for: newCgImage), let newData = newContext.data else {
79+
return "Newly-taken snapshot's data could not be loaded."
80+
}
7181
let byteCount = oldContext.height * oldContext.bytesPerRow
72-
if memcmp(oldData, newData, byteCount) == 0 { return true }
73-
let newer = NSImage(data: NSImagePNGRepresentation(new)!)!
74-
guard let newerCgImage = newer.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return false }
75-
guard let newerContext = context(for: newerCgImage) else { return false }
76-
guard let newerData = newerContext.data else { return false }
77-
if memcmp(oldData, newerData, byteCount) == 0 { return true }
78-
if precision >= 1, perceptualPrecision >= 1 { return false }
82+
if memcmp(oldData, newData, byteCount) == 0 { return nil }
83+
guard
84+
let pngData = NSImagePNGRepresentation(new),
85+
let newerCgImage = NSImage(data: pngData)?.cgImage(forProposedRect: nil, context: nil, hints: nil),
86+
let newerContext = context(for: newerCgImage),
87+
let newerData = newerContext.data
88+
else {
89+
return "Newly-taken snapshot's data could not be loaded."
90+
}
91+
if memcmp(oldData, newerData, byteCount) == 0 { return nil }
92+
if precision >= 1, perceptualPrecision >= 1 {
93+
return "Newly-taken snapshot does not match reference."
94+
}
7995
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
96+
return perceptuallyCompare(
97+
CIImage(cgImage: oldCgImage),
98+
CIImage(cgImage: newCgImage),
99+
pixelPrecision: precision,
100+
perceptualPrecision: perceptualPrecision
112101
)
113-
let pixelCountThreshold = 1 - precision
114-
if averagePixel > pixelCountThreshold { return false }
115102
} else {
116103
let oldRep = NSBitmapImageRep(cgImage: oldCgImage).bitmapData!
117104
let newRep = NSBitmapImageRep(cgImage: newerCgImage).bitmapData!
@@ -120,11 +107,14 @@ private func compare(_ old: NSImage, _ new: NSImage, precision: Float, perceptua
120107
for offset in 0..<byteCount {
121108
if oldRep[offset] != newRep[offset] {
122109
differentByteCount += 1
123-
if differentByteCount > byteCountThreshold { return false }
124110
}
125111
}
112+
if differentByteCount > byteCountThreshold {
113+
let actualPrecision = 1 - Float(differentByteCount) / Float(byteCount)
114+
return "Actual image precision \(actualPrecision) is less than required \(precision)"
115+
}
126116
}
127-
return true
117+
return nil
128118
}
129119

130120
private func context(for cgImage: CGImage) -> CGContext? {

Sources/SnapshotTesting/Snapshotting/UIImage.swift

Lines changed: 85 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
#if os(iOS) || os(tvOS)
2-
import CoreImage.CIFilterBuiltins
32
import UIKit
43
import XCTest
54

@@ -26,11 +25,8 @@ extension Diffing where Value == UIImage {
2625
toData: { $0.pngData() ?? emptyImage().pngData()! },
2726
fromData: { UIImage(data: $0, scale: imageScale)! }
2827
) { old, new in
29-
guard !compare(old, new, precision: precision, perceptualPrecision: perceptualPrecision) else { return nil }
28+
guard let message = compare(old, new, precision: precision, perceptualPrecision: perceptualPrecision) else { return nil }
3029
let difference = SnapshotTesting.diff(old, new)
31-
let message = new.size == old.size
32-
? "Newly-taken snapshot does not match reference."
33-
: "Newly-taken snapshot@\(new.size) does not match reference@\(old.size)."
3430
let oldAttachment = XCTAttachment(image: old)
3531
oldAttachment.name = "reference"
3632
let newAttachment = XCTAttachment(image: new)
@@ -81,76 +77,62 @@ private let imageContextColorSpace = CGColorSpace(name: CGColorSpace.sRGB)
8177
private let imageContextBitsPerComponent = 8
8278
private let imageContextBytesPerPixel = 4
8379

84-
private func compare(_ old: UIImage, _ new: UIImage, precision: Float, perceptualPrecision: Float) -> Bool {
85-
guard let oldCgImage = old.cgImage else { return false }
86-
guard let newCgImage = new.cgImage else { return false }
87-
guard newCgImage.width != 0 else { return false }
88-
guard oldCgImage.width == newCgImage.width else { return false }
89-
guard newCgImage.height != 0 else { return false }
90-
guard oldCgImage.height == newCgImage.height else { return false }
91-
80+
private func compare(_ old: UIImage, _ new: UIImage, precision: Float, perceptualPrecision: Float) -> String? {
81+
guard let oldCgImage = old.cgImage else {
82+
return "Reference image could not be loaded."
83+
}
84+
guard let newCgImage = new.cgImage else {
85+
return "Newly-taken snapshot could not be loaded."
86+
}
87+
guard newCgImage.width != 0, newCgImage.height != 0 else {
88+
return "Newly-taken snapshot is empty."
89+
}
90+
guard oldCgImage.width == newCgImage.width, oldCgImage.height == newCgImage.height else {
91+
return "Newly-taken snapshot@\(new.size) does not match reference@\(old.size)."
92+
}
9293
let pixelCount = oldCgImage.width * oldCgImage.height
9394
let byteCount = imageContextBytesPerPixel * pixelCount
9495
var oldBytes = [UInt8](repeating: 0, count: byteCount)
95-
guard let oldContext = context(for: oldCgImage, data: &oldBytes) else { return false }
96-
guard let oldData = oldContext.data else { return false }
96+
guard let oldData = context(for: oldCgImage, data: &oldBytes)?.data else {
97+
return "Reference image's data could not be loaded."
98+
}
9799
if let newContext = context(for: newCgImage), let newData = newContext.data {
98-
if memcmp(oldData, newData, byteCount) == 0 { return true }
100+
if memcmp(oldData, newData, byteCount) == 0 { return nil }
99101
}
100-
let newer = UIImage(data: new.pngData()!)!
101-
guard let newerCgImage = newer.cgImage else { return false }
102102
var newerBytes = [UInt8](repeating: 0, count: byteCount)
103-
guard let newerContext = context(for: newerCgImage, data: &newerBytes) else { return false }
104-
guard let newerData = newerContext.data else { return false }
105-
if memcmp(oldData, newerData, byteCount) == 0 { return true }
106-
if precision >= 1, perceptualPrecision >= 1 { return false }
103+
guard
104+
let pngData = new.pngData(),
105+
let newerCgImage = UIImage(data: pngData)?.cgImage,
106+
let newerContext = context(for: newerCgImage, data: &newerBytes),
107+
let newerData = newerContext.data
108+
else {
109+
return "Newly-taken snapshot's data could not be loaded."
110+
}
111+
if memcmp(oldData, newerData, byteCount) == 0 { return nil }
112+
if precision >= 1, perceptualPrecision >= 1 {
113+
return "Newly-taken snapshot does not match reference."
114+
}
107115
if perceptualPrecision < 1, #available(iOS 11.0, tvOS 11.0, *) {
108-
let deltaFilter = CIFilter(
109-
name: "CILabDeltaE",
110-
parameters: [
111-
kCIInputImageKey: CIImage(cgImage: newCgImage),
112-
"inputImage2": CIImage(cgImage: oldCgImage)
113-
]
116+
return perceptuallyCompare(
117+
CIImage(cgImage: oldCgImage),
118+
CIImage(cgImage: newCgImage),
119+
pixelPrecision: precision,
120+
perceptualPrecision: perceptualPrecision
114121
)
115-
guard let deltaOutputImage = deltaFilter?.outputImage else { return false }
116-
let extent = CGRect(x: 0, y: 0, width: oldCgImage.width, height: oldCgImage.height)
117-
guard
118-
let thresholdOutputImage = try? ThresholdImageProcessorKernel.apply(
119-
withExtent: extent,
120-
inputs: [deltaOutputImage],
121-
arguments: [ThresholdImageProcessorKernel.inputThresholdKey: (1 - perceptualPrecision) * 100]
122-
)
123-
else { return false }
124-
let averageFilter = CIFilter(
125-
name: "CIAreaAverage",
126-
parameters: [
127-
kCIInputImageKey: thresholdOutputImage,
128-
kCIInputExtentKey: extent
129-
]
130-
)
131-
guard let averageOutputImage = averageFilter?.outputImage else { return false }
132-
var averagePixel: Float = 0
133-
CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull()]).render(
134-
averageOutputImage,
135-
toBitmap: &averagePixel,
136-
rowBytes: MemoryLayout<Float>.size,
137-
bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
138-
format: .Rf,
139-
colorSpace: nil
140-
)
141-
let pixelCountThreshold = 1 - precision
142-
if averagePixel > pixelCountThreshold { return false }
143122
} else {
144123
let byteCountThreshold = Int((1 - precision) * Float(byteCount))
145124
var differentByteCount = 0
146125
for offset in 0..<byteCount {
147126
if oldBytes[offset] != newerBytes[offset] {
148127
differentByteCount += 1
149-
if differentByteCount > byteCountThreshold { return false }
150128
}
151129
}
130+
if differentByteCount > byteCountThreshold {
131+
let actualPrecision = 1 - Float(differentByteCount) / Float(byteCount)
132+
return "Actual image precision \(actualPrecision) is less than required \(precision)"
133+
}
152134
}
153-
return true
135+
return nil
154136
}
155137

156138
private func context(for cgImage: CGImage, data: UnsafeMutableRawPointer? = nil) -> CGContext? {
@@ -189,6 +171,51 @@ private func diff(_ old: UIImage, _ new: UIImage) -> UIImage {
189171
import CoreImage.CIKernel
190172
import MetalPerformanceShaders
191173

174+
@available(iOS 10.0, tvOS 10.0, macOS 10.13, *)
175+
func perceptuallyCompare(_ old: CIImage, _ new: CIImage, pixelPrecision: Float, perceptualPrecision: Float) -> String? {
176+
let deltaOutputImage = old.applyingFilter("CILabDeltaE", parameters: ["inputImage2": new])
177+
let thresholdOutputImage: CIImage
178+
do {
179+
thresholdOutputImage = try ThresholdImageProcessorKernel.apply(
180+
withExtent: new.extent,
181+
inputs: [deltaOutputImage],
182+
arguments: [ThresholdImageProcessorKernel.inputThresholdKey: (1 - perceptualPrecision) * 100]
183+
)
184+
} catch {
185+
return "Newly-taken snapshot's data could not be loaded. \(error)"
186+
}
187+
var averagePixel: Float = 0
188+
let context = CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull()])
189+
context.render(
190+
thresholdOutputImage.applyingFilter("CIAreaAverage", parameters: [kCIInputExtentKey: new.extent]),
191+
toBitmap: &averagePixel,
192+
rowBytes: MemoryLayout<Float>.size,
193+
bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
194+
format: .Rf,
195+
colorSpace: nil
196+
)
197+
let actualPixelPrecision = 1 - averagePixel
198+
guard actualPixelPrecision < pixelPrecision else { return nil }
199+
var maximumDeltaE: Float = 0
200+
context.render(
201+
deltaOutputImage.applyingFilter("CIAreaMaximum", parameters: [kCIInputExtentKey: new.extent]),
202+
toBitmap: &maximumDeltaE,
203+
rowBytes: MemoryLayout<Float>.size,
204+
bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
205+
format: .Rf,
206+
colorSpace: nil
207+
)
208+
let actualPerceptualPrecision = 1 - maximumDeltaE / 100
209+
if pixelPrecision < 1 {
210+
return """
211+
Actual image precision \(actualPixelPrecision) is less than required \(pixelPrecision)
212+
Actual perceptual precision \(actualPerceptualPrecision) is less than required \(perceptualPrecision)
213+
"""
214+
} else {
215+
return "Actual perceptual precision \(actualPerceptualPrecision) is less than required \(perceptualPrecision)"
216+
}
217+
}
218+
192219
// Copied from https://developer.apple.com/documentation/coreimage/ciimageprocessorkernel
193220
@available(iOS 10.0, tvOS 10.0, macOS 10.13, *)
194221
final class ThresholdImageProcessorKernel: CIImageProcessorKernel {

0 commit comments

Comments
 (0)