I am using Apple's Vision framework to run multiple detection requests against one image.
VNImageRequestHandler.perform()
schedules multiple requests (likely in a separate thread). Completion handlers are defined for the individual requests. In the end, results should be assigned to a class var.
From my understanding, continuations are the best way to convert completion handler based APIs to async code. However, continuations can only be resumed once.
AsyncStream
seems like a more viable solution. However, results can arrive in any order and I only have a static number of requests.
What are the most idiomatic async paradigms to use with the following code?
// non-async code
let handler = VNImageRequestHandler(url: url)
let faceQualityRequest = VNDetectFaceCaptureQualityRequest { request, err in
guard err == nil else { return }
self.faceDetectionResults = request.results
}
let featurePrintRequest = VNGenerateImageFeaturePrintRequest { request, err in
guard err == nil else { return }
self.featurePrintResults = request.results
}
try handler.perform([faceQualityRequest, featurePrintRequest])
I understand why, when seeing a series of closures, one might jump to the idea of asynchronous sequences or task groups, but we should note that perform
is synchronous, not asynchronous. So, I might suggest that the relevant question is less “what do I do with these closures”, but rather, “how do I avail myself of a slow, synchronous API within Swift concurrency”.
A few observations:
Given that perform
is a synchronous function, you should be aware that you should avoid blocking the current actor. So, you theoretically could move it into a detached task.
Having having been said, even that is not prudent. E.g., in WWDC 2022’s Visualize and optimize Swift concurrency, Apple explicitly advises moving the blocking code out of the Swift concurrency system:
Be sure to avoid blocking calls in tasks. … If you have code that needs to do these things, move that code outside of the concurrency thread pool – for example, by running it on a DispatchQueue – and bridge it to the concurrency world using continuations.
See async/await: How do I run an async function within a @MainActor class on a background thread?
You are focusing on the closures provided to VNDetectFaceCaptureQualityRequest
and VNGenerateImageFeaturePrintRequest
. But those closures are an optional convenience and you can instead just use their respective results
(here and here).
So, you might end up with something like:
func faceAndFeature(for url: URL) async throws -> ([VNFaceObservation], [VNFeaturePrintObservation]) {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global().async {
let faceQualityRequest = VNDetectFaceCaptureQualityRequest()
let featurePrintRequest = VNGenerateImageFeaturePrintRequest()
let handler = VNImageRequestHandler(url: url)
do {
try handler.perform([faceQualityRequest, featurePrintRequest])
continuation.resume(returning: (faceQualityRequest.results ?? [], featurePrintRequest.results ?? []))
} catch {
continuation.resume(throwing: error)
}
}
}
}
E.g.,
let (faceQuality, featurePrint) = try await faceAndFeature(for: url)
print("faceQuality =", faceQuality)
print("featurePrint =", featurePrint)
It should be noted that the above will not return any results until perform
finishes all the requests. You can, alternatively, create two AsyncChannel
instances, one for faces and one for features. E.g.,
let faceChannel = AsyncThrowingChannel<[VNFaceObservation], Error>()
let featureChannel = AsyncThrowingChannel<[VNFeaturePrintObservation], Error>()
func analyzeImage(at url: URL) async throws {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global().async {
let faceQualityRequest = VNDetectFaceCaptureQualityRequest { request, error in
Task { [weak self] in
guard let self else { return }
guard error == nil, let results = request.results as? [VNFaceObservation] else {
faceChannel.fail(error ?? VisionError.invalidRequestType)
return
}
await faceChannel.send(results)
}
}
let featurePrintRequest = VNGenerateImageFeaturePrintRequest { request, error in
let results = request.results
Task { [weak self, results] in
guard let self else { return }
guard error == nil, let results = results as? [VNFeaturePrintObservation] else {
featureChannel.fail(error ?? VisionError.invalidRequestType)
return
}
await featureChannel.send(results)
}
}
let handler = VNImageRequestHandler(url: url)
do {
try handler.perform([faceQualityRequest, featurePrintRequest])
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
And then monitor those channels:
await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
for try await observations in self.faceChannel {
…
}
}
group.addTask {
for try await observations in self.featureChannel {
…
}
}
}
And finally, start the request:
try await analyzeImage(at: url)
A few notes:
The Vision framework does not appear to be audited for Sendable
yet, so you might want to designate it as @preconcurrency
to silence annoying warnings about this:
@preconcurrency import Vision
Note, in SwiftUI you can launch these for
-await
-in
loops from the .task
view modifier and they will be canceled when the view is dismissed. In UIKit/AppKit, you will have to keep your own reference to the Task
that launched these and manually cancel
them when the view in question disappears.