Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Non-Metal based perceptual image comparison #666

Merged
merged 6 commits into from
Jan 2, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/SnapshotTesting/Snapshotting/NSImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ private func compare(_ old: NSImage, _ new: NSImage, precision: Float, perceptua
let newRep = NSBitmapImageRep(cgImage: newerCgImage).bitmapData!
let byteCountThreshold = Int((1 - precision) * Float(byteCount))
var differentByteCount = 0
for offset in 0..<byteCount {
fastForEach(in: 0..<byteCount) { offset in
if oldRep[offset] != newRep[offset] {
differentByteCount += 1
}
Expand Down
167 changes: 127 additions & 40 deletions Sources/SnapshotTesting/Snapshotting/UIImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ private func compare(_ old: UIImage, _ new: UIImage, precision: Float, perceptua
} else {
let byteCountThreshold = Int((1 - precision) * Float(byteCount))
var differentByteCount = 0
for offset in 0..<byteCount {
fastForEach(in: 0..<byteCount) { offset in
if oldBytes[offset] != newerBytes[offset] {
differentByteCount += 1
}
Expand Down Expand Up @@ -169,60 +169,137 @@ private func diff(_ old: UIImage, _ new: UIImage) -> UIImage {
#endif

#if os(iOS) || os(tvOS) || os(macOS)
import Accelerate.vImage
import CoreImage.CIKernel
import MetalPerformanceShaders

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a way to know if Metal is supported could we keep the previous implementation in case it is more performant than the alternative?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also if only metal backport of the threshold filter is the issue why to remove the use of all core image filters rather than using CIColorThreshold for iOS 14+ and fallback to CPU otherwise?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can check metal support like so:

MTLCreateSystemDefaultDevice() == nil


@available(iOS 10.0, tvOS 10.0, macOS 10.13, *)
func perceptuallyCompare(_ old: CIImage, _ new: CIImage, pixelPrecision: Float, perceptualPrecision: Float) -> String? {
let deltaOutputImage = old.applyingFilter("CILabDeltaE", parameters: ["inputImage2": new])
let thresholdOutputImage: CIImage
do {
thresholdOutputImage = try ThresholdImageProcessorKernel.apply(
withExtent: new.extent,
inputs: [deltaOutputImage],
arguments: [ThresholdImageProcessorKernel.inputThresholdKey: (1 - perceptualPrecision) * 100]
)
} catch {
return "Newly-taken snapshot's data could not be loaded. \(error)"
}
var averagePixel: Float = 0
// Calculate the deltaE values. Each pixel is a value between 0-100.
// 0 means no difference, 100 means completely opposite.
let deltaOutputImage = old.applyingLabDeltaE(new)
// Setting the working color space and output color space to NSNull disables color management. This is appropriate when the output
// of the operations is computational instead of an image intended to be displayed.
let context = CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull()])
context.render(
thresholdOutputImage.applyingFilter("CIAreaAverage", parameters: [kCIInputExtentKey: new.extent]),
toBitmap: &averagePixel,
rowBytes: MemoryLayout<Float>.size,
bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
format: .Rf,
colorSpace: nil
)
let actualPixelPrecision = 1 - averagePixel
guard actualPixelPrecision < pixelPrecision else { return nil }
let deltaThreshold = (1 - perceptualPrecision) * 100
let actualPixelPrecision: Float
var maximumDeltaE: Float = 0
context.render(
deltaOutputImage.applyingFilter("CIAreaMaximum", parameters: [kCIInputExtentKey: new.extent]),
toBitmap: &maximumDeltaE,
rowBytes: MemoryLayout<Float>.size,
bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
format: .Rf,
colorSpace: nil
)
let actualPerceptualPrecision = 1 - maximumDeltaE / 100
if pixelPrecision < 1 {
return """
Actual image precision \(actualPixelPrecision) is less than required \(pixelPrecision)
Actual perceptual precision \(actualPerceptualPrecision) is less than required \(perceptualPrecision)
"""

// Metal is supported by all iOS/tvOS devices (2013 models or later) and Macs (2012 models or later).
// Older devices do not support iOS/tvOS 13 and macOS 10.15 which are the minimum versions of swift-snapshot-testing.
// However, some virtualized hardware do not have GPUs and therefore do not support Metal.
// In this case, macOS falls back to a CPU-based OpenGL ES renderer that silently fails when a Metal command is issued.
// We need to check for Metal device support and fallback to CPU based vImage buffer iteration.
if ThresholdImageProcessorKernel.isSupported {
// Fast path - Metal processing
guard
let thresholdOutputImage = try? deltaOutputImage.applyingThreshold(deltaThreshold),
let averagePixel = thresholdOutputImage.applyingAreaAverage().renderSingleValue(in: context)
else {
return "Newly-taken snapshot's data could not be processed."
}
actualPixelPrecision = 1 - averagePixel
if actualPixelPrecision < pixelPrecision {
maximumDeltaE = deltaOutputImage.applyingAreaMaximum().renderSingleValue(in: context) ?? 0
}
} else {
return "Actual perceptual precision \(actualPerceptualPrecision) is less than required \(perceptualPrecision)"
// Slow path - CPU based vImage buffer iteration
guard let buffer = deltaOutputImage.render(in: context) else {
return "Newly-taken snapshot could not be processed."
}
defer { buffer.free() }
var failingPixelCount: Int = 0
// rowBytes must be a multiple of 8, so vImage_Buffer pads the end of each row with bytes to meet the multiple of 0 requirement.
// We must do 2D iteration of the vImage_Buffer in order to avoid loading the padding garbage bytes at the end of each row.
fastForEach(in: 0..<Int(buffer.height)) { line in
let lineOffset = buffer.rowBytes * line
fastForEach(in: 0..<Int(buffer.width)) { column in
let columnOffset = lineOffset + column * MemoryLayout<Float>.size
let deltaE = buffer.data.load(fromByteOffset: columnOffset, as: Float.self)
if deltaE > deltaThreshold {
failingPixelCount += 1
if deltaE > maximumDeltaE {
maximumDeltaE = deltaE
}
}
}
}
let failingPixelPercent = Float(failingPixelCount) / Float(deltaOutputImage.extent.width * deltaOutputImage.extent.height)
actualPixelPrecision = 1 - failingPixelPercent
}

guard actualPixelPrecision < pixelPrecision else { return nil }
// The actual perceptual precision is the perceptual precision of the pixel with the highest DeltaE.
// DeltaE is in a 0-100 scale, so we need to divide by 100 to transform it to a percentage.
let minimumPerceptualPrecision = 1 - min(maximumDeltaE / 100, 1)
return """
The percentage of pixels that match \(actualPixelPrecision) is less than required \(pixelPrecision)
The lowest perceptual color precision \(minimumPerceptualPrecision) is less than required \(perceptualPrecision)
"""
}

extension CIImage {
func applyingLabDeltaE(_ other: CIImage) -> CIImage {
applyingFilter("CILabDeltaE", parameters: ["inputImage2": other])
}

func applyingThreshold(_ threshold: Float) throws -> CIImage {
try ThresholdImageProcessorKernel.apply(
withExtent: extent,
inputs: [self],
arguments: [ThresholdImageProcessorKernel.inputThresholdKey: threshold]
)
}

func applyingAreaAverage() -> CIImage {
applyingFilter("CIAreaAverage", parameters: [kCIInputExtentKey: extent])
}

func applyingAreaMaximum() -> CIImage {
applyingFilter("CIAreaMaximum", parameters: [kCIInputExtentKey: extent])
}

func renderSingleValue(in context: CIContext) -> Float? {
guard let buffer = render(in: context) else { return nil }
defer { buffer.free() }
return buffer.data.load(fromByteOffset: 0, as: Float.self)
}

func render(in context: CIContext, format: CIFormat = CIFormat.Rh) -> vImage_Buffer? {
// Some hardware configurations (virtualized CPU renderers) do not support 32-bit float output formats,
// so use a compatible 16-bit float format and convert the output value to 32-bit floats.
guard var buffer16 = try? vImage_Buffer(width: Int(extent.width), height: Int(extent.height), bitsPerPixel: 16) else { return nil }
defer { buffer16.free() }
context.render(
self,
toBitmap: buffer16.data,
rowBytes: buffer16.rowBytes,
bounds: extent,
format: format,
colorSpace: nil
)
guard
var buffer32 = try? vImage_Buffer(width: Int(buffer16.width), height: Int(buffer16.height), bitsPerPixel: 32),
vImageConvert_Planar16FtoPlanarF(&buffer16, &buffer32, 0) == kvImageNoError
else { return nil }
return buffer32
}
}

// Copied from https://developer.apple.com/documentation/coreimage/ciimageprocessorkernel
@available(iOS 10.0, tvOS 10.0, macOS 10.13, *)
final class ThresholdImageProcessorKernel: CIImageProcessorKernel {
static let inputThresholdKey = "thresholdValue"
static let device = MTLCreateSystemDefaultDevice()

static var isSupported: Bool {
#if targetEnvironment(simulator)
guard #available(iOS 14.0, tvOS 14.0, *) else {
// The MPSSupportsMTLDevice method throws an exception on iOS/tvOS simulators older than 14.0
return false
}
#endif
return MPSSupportsMTLDevice(device)
}

override class func process(with inputs: [CIImageProcessorInput]?, arguments: [String: Any]?, output: CIImageProcessorOutput) throws {
guard
let device = device,
Expand All @@ -249,3 +326,13 @@ final class ThresholdImageProcessorKernel: CIImageProcessorKernel {
}
}
#endif

/// When the compiler doesn't have optimizations enabled, like in test targets, a `while` loop is significantly faster than a `for` loop
/// for iterating through the elements of a memory buffer. Details can be found in [SR-6983](https://github.com/apple/swift/issues/49531#issuecomment-1108286654)
func fastForEach(in range: Range<Int>, _ body: (Int) -> Void) {
var index = range.lowerBound
while index < range.upperBound {
body(index)
index += 1
}
}