11#if os(iOS) || os(tvOS)
2- import CoreImage. CIFilterBuiltins
32import UIKit
43import 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)
8177private let imageContextBitsPerComponent = 8
8278private 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
156138private func context( for cgImage: CGImage , data: UnsafeMutableRawPointer ? = nil ) -> CGContext ? {
@@ -189,6 +171,51 @@ private func diff(_ old: UIImage, _ new: UIImage) -> UIImage {
189171import CoreImage. CIKernel
190172import 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 , * )
194221final class ThresholdImageProcessorKernel : CIImageProcessorKernel {
0 commit comments