WWDC18: ARKit 2, confronto delle prestazioni del tracking 2D
Un caso pratico con ARKit 2: tracciare biglietti da visita con ARImageTrackingConfiguration su iOS 12 e aggirare il tracking più debole di iOS 11.
Tradotto dall’inglese con l’aiuto dell’IA e rivisto dall’autore.Leggi l’originale

Uno dei trend tecnologici più interessanti dell'ultimo anno è stato di sicuro la realtà aumentata. Tutti i grandi nomi hanno fatto qualcosa in proposito, Apple compresa, che ha investito molte risorse e molto tempo su questo tema e, come ha detto Tim Cook:
…la realtà aumentata potrebbe essere la prossima grande novità…
Se il 2017 è stato l'anno della pietra miliare, con l'introduzione di un framework completo per l'AR, il 2018 è senza dubbio l'anno della maturità di questa tecnologia, grazie ad ARKit 2.
Molte cose sono cambiate a favore di usabilità, manutenibilità e semplicità: tutti aspetti che possono aiutare molto a diffondere una tecnologia presso il grande pubblico. Il resto dell'articolo racconta la mia esperienza personale con il nuovo framework ARKit, sottolineando miglioramenti e differenze tra iOS 11 e iOS 12. Come in ogni buon articolo per sviluppatori, serve un caso di studio su cui lavorare per spiegare meglio l'argomento. Partiamo da questo.
Creeremo una semplice applicazione che usa la realtà aumentata per mostrare offerte speciali e informazioni aggiuntive quando si inquadrano dei biglietti da visita.
Opzionalmente, l'applicazione caricherà le informazioni necessarie da un JSON remoto e mostrerà poi all'utente i relativi arricchimenti visivi quando servono.
Per prima cosa apriamo Xcode e creiamo un nuovo progetto AR, compilando tutti i campi richiesti:

Per impostazione predefinita Xcode crea una scena iniziale del tutto inutile ai fini di questo articolo. Eliminiamola e apriamo il file ViewController.swift creato automaticamente.

Dato che la scena generata automaticamente non ci interessa più, modifichiamo di conseguenza il metodo viewDidLoad. Ci basta assegnare all'outlet sceneView l'istanza giusta come delegate, più qualche opzione utile per il debug.
override func viewDidLoad() {
super.viewDidLoad()
sceneView.delegate = self
sceneView.showsStatistics = true
sceneView.autoenablesDefaultLighting = true
sceneView.automaticallyUpdatesLighting = true
}La classe ARSCNView è il punto di ingresso di qualsiasi app AR. Combina i contenuti virtuali con il mondo reale visto attraverso la fotocamera del dispositivo e si occupa anche di molte altre cose interessanti, come mappare i tocchi sulle coordinate del mondo reale. ARSCNView usa un approccio basato su delegate: implementando il suo ARSCNViewDelegate possiamo aggiungere contenuti virtuali a qualsiasi oggetto reale che ARKit rileva automaticamente, quasi per magia.
Come si intuisce, ARKit è il framework responsabile della realtà aumentata. Per funzionare va prima inizializzato correttamente. Aggiungiamo un metodo che lo faccia:
func resetTrackingConfiguration() {
guard let referenceImages = ARReferenceImage.referenceImages(inGroupNamed: "Photos", bundle: nil) else { return }
var configuration:ARConfiguration!
if #available(iOS 12.0, *) {
configuration = ARImageTrackingConfiguration()
(configuration as! ARImageTrackingConfiguration).trackingImages = referenceImages
(configuration as! ARImageTrackingConfiguration).maximumNumberOfTrackedImages = referenceImages.count
} else {
configuration = ARWorldTrackingConfiguration()
(configuration as! ARWorldTrackingConfiguration).detectionImages = referenceImages
}
let options: ARSession.RunOptions = [.resetTracking, .removeExistingAnchors]
sceneView.session.run(configuration, options: options)
}Il metodo resetTrackingConfiguration si occupa di creare e avviare una ARSession che funzioni correttamente, a partire da una ARConfiguration.
ARSession è un oggetto condiviso (singleton) che gestisce la fotocamera del dispositivo e i sensori di movimento, per darci un'esperienza di realtà aumentata completa. I movimenti della camera vengono fusi con i dati dei sensori di inclinazione e di spostamento e con quelli degli altri sensori, in modo da orientare in maniera rapida e coerente il mondo artificiale dentro l'applicazione. L'istanza di ARSCNView creata dallo storyboard contiene già un riferimento alla ARSession condivisa predefinita, quindi non dobbiamo inizializzarla: ci basta creare una ARConfiguration valida.
ARConfiguration è una classe astratta e non la usiamo direttamente. Useremo invece istanze di ARWorldTrackingConfiguration e di ARImageTrackingConfiguration, che ne sono implementazioni concrete. Ce ne sono altre che si occupano solo di tracciare l'orientamento del dispositivo (AROrientationTrackingConfiguration) oppure i volti e le espressioni facciali (ARFaceTrackingConfiguration), ma non sono rilevanti per questa discussione.
ARWorldTrackingConfiguration è la classe principale per le sessioni di world tracking. Introdotta con iOS 11, è in grado di rilevare e tracciare molti elementi interessanti del mondo reale, tra cui oggetti 3D, piani infiniti e immagini 2D. Sono le immagini 2D quelle che interessano a noi; tuttavia iOS 12 introduce una nuova specializzazione della classe astratta ARConfiguration, chiamata ARImageTrackingConfiguration.
Come la sua predecessora, è in grado di tracciare immagini 2D, ma con parecchi miglioramenti, come il tracking in tempo reale e la gestione automatica del caso in cui l'immagine esce dall'inquadratura. Ci torneremo tra poco.
Dato che vogliamo supportare sia iOS 11 sia iOS 12 per poterne confrontare le prestazioni, dobbiamo prevedere configurazioni personalizzate per entrambe le versioni. Se iOS 12 è disponibile, useremo la nuova implementazione di ARConfiguration:
if #available(iOS 12.0, *) {
configuration = ARImageTrackingConfiguration()
(configuration as! ARImageTrackingConfiguration).trackingImages = referenceImages
(configuration as! ARImageTrackingConfiguration).maximumNumberOfTrackedImages = referenceImages.count
}altrimenti useremo quella standard:
else {
configuration = ARWorldTrackingConfiguration()
(configuration as! ARWorldTrackingConfiguration).detectionImages = referenceImages
}in entrambi i casi diciamo alla sessione condivisa di usare la configurazione appena costruita, per tutto il resto della discussione:
let options: ARSession.RunOptions = [.resetTracking, .removeExistingAnchors]
sceneView.session.run(configuration, options: options)Ora che abbiamo configurato correttamente la ARSession, ci servono delle immagini da dare in pasto al motore di tracking della ARCamera. Selezioniamo gli Assets del progetto:

e con il tasto destro nella colonna di sinistra creiamo un nuovo AR Resource Group:

Chiamiamolo Photos e aggiungiamo qualche biglietto. La cosa più importante qui è indicare per ogni biglietto le dimensioni reali stimate: il motore 3D ha bisogno di questa informazione per stimare correttamente l'oggetto nel mondo 3D.

Come si vede, uno dei biglietti ha un'icona di avviso. Clicchiamoci sopra:

Xcode ci avverte che l'istogramma e i contrasti del biglietto non sono ottimali per il riconoscimento dell'immagine. Per il momento ignoriamolo.
La nostra app di prova deve poter tracciare immagini 2D e associarle a oggetti del mondo reale tramite gli anchor. ARAnchor è la classe che rappresenta posizione e orientamento nel mondo reale, mappando l'oggetto rilevato in una scena AR. Tra le altre proprietà contiene una matrix_float4x4 che definisce rotazione, traslazione e scala dell'anchor nelle coordinate del mondo.
open var transform: matrix_float4x4 { get }Quando si usa una ARWorldTrackingConfiguration o una ARImageTrackingConfiguration, ARKit crea automaticamente gli anchor corrispondenti ogni volta che trova nella scena una corrispondenza con le nostre immagini di riferimento. Dobbiamo solo implementare il relativo delegate, per aggiungere al nuovo anchor l'oggetto corrispondente sotto forma di SCNNode.
func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
if let imageAnchor = anchor as? ARImageAnchor {
let node = SCNNode()
node.name = anchor.identifier.description
var labelNode:SCNNode?
switch imageAnchor.referenceImage.name {
case "card1":
labelNode = self.addLabel(text: "Hi, Costantino!\nYour loyalties points are 2032.", anchor: imageAnchor)
case "card2":
labelNode = self.addLabel(text: "Last tweet from Costantino:\n#WWDC18 #ARKit2 rocks!", anchor: imageAnchor)
default:
break
}
if let labelNode = labelNode {
node.addChildNode(labelNode)
node.opacity = 0.0
DispatchQueue.main.async {
node.runAction(SCNAction.fadeIn(duration: 3.0))
}
return node
}
}
return nil
}SCNNode è il mattoncino base di tutte le app AR. Unisce le coordinate 3D con gli oggetti della scena, come luci, camera e altri contenuti visualizzabili. Come per le UIView, gli SCNNode seguono una gerarchia ad albero che permette di annidarli con semplici primitive come
open func addChildNode(_ child: SCNNode)
open func removeFromParentNode()Il metodo renderer:nodeFor: viene chiamato ogni volta che viene rilevata una nuova corrispondenza tra il nostro insieme di immagini e il mondo reale. Dato che viene chiamato anche per qualsiasi nuovo ARAnchor aggiunto alla scena (piani rilevati, oggetti 3D e così via), conviene verificare che si tratti di un ARImageAnchor per essere sicuri che tutto funzioni come previsto:
if let imageAnchor = anchor as? ARImageAnchor {
…
}poi creiamo un nuovo SCNNode che conterrà tutto il necessario e facciamo uno switch sul nome dell'immagine rilevata: è la logica principale che distingue le immagini del nostro dataset.
switch imageAnchor.referenceImage.name {
case “card1”:
…
case “card2”:
…
default:
…
}infine, se riusciamo a creare un nodo valido, lo aggiungiamo alla gerarchia e restituiamo il nodo principale:
if let labelNode = labelNode {
node.addChildNode(labelNode)
…
return node
}Per dare un tocco professionale, vogliamo aggiungere animazioni di fade in e fade out a tutto il flusso. Dato che renderer:nodeFor: viene chiamato sul main thread, conviene eseguire runAction in modo asincrono, per essere certi di non avere animazioni scattose.
DispatchQueue.main.async {
node.runAction(SCNAction.fadeIn(duration: 3.0))
}Per i più curiosi
addLabel è un metodo di supporto che genera un oggetto SpriteKit 2D valido. Dato che lavoriamo con immagini 2D tracciate, vogliamo aggiungere alla scena dei contenuti virtuali 2D, per dare all'utente la sensazione che le informazioni aggiuntive compaiano proprio sopra l'oggetto reale.
func addLabel(text: String, anchor: ARImageAnchor) -> SCNNode {
let plane = SCNPlane(width: anchor.referenceImage.physicalSize.width,
height: anchor.referenceImage.physicalSize.height)
let planeNode = SCNNode(geometry: plane)
planeNode.eulerAngles.x = -.pi / 2
let skScene = SKScene(size: sceneSize)
skScene.backgroundColor = UIColor(white: 0.0, alpha: 0.0)
let substrings: [String] = text.components(separatedBy: "\n")
for aSubstring in substrings {
let lbl = SKLabelNode(text: aSubstring)
lbl.fontSize = 20
lbl.numberOfLines = 1
lbl.fontColor = UIColor.white.withAlphaComponent(0.85)
lbl.fontName = "Helvetica-Bold"
let y = CGFloat(substrings.index(of: aSubstring)! + 1) * lbl.fontSize
lbl.position = CGPoint(x: 20, y: y)
lbl.horizontalAlignmentMode = .left
lbl.yScale *= -1
skScene.addChild(lbl)
}
let material = SCNMaterial()
material.isDoubleSided = false
material.diffuse.contents = skScene
plane.materials = [material]
return planeNode
}Per prima cosa creiamo uno SCNPlane con le dimensioni fisiche dell'immagine rilevata. SCNPlane è un oggetto geometrico rettangolare e piatto, che ha il compito di contenere il nostro testo bidimensionale. Ma dato che siamo in un mondo 3D, dobbiamo racchiuderlo in uno SCNNode.
let planeNode = SCNNode(geometry: plane)
planeNode.eulerAngles.x = -.pi / 2Dobbiamo anche ruotare il planeNode risultante, perché per impostazione predefinita viene generato perpendicolare alla radice. A questo punto non è ancora stato creato nessun oggetto 2D né alcun testo: per farlo ci serve una scena SpriteKit.
SKScene è l'equivalente di SCNScene nel mondo 2D gestito da SpriteKit. Rappresenta il contenuto di una scena 2D e da sola non ha nulla da mostrare, perché, come la sua gemella 3D, è solo un contenitore. Per generare il testo 2D useremo SKLabelNode, che funziona in modo molto simile alla UILabel di UIKit: si configurano testo, font e altri parametri in modo molto diretto.
let lbl = SKLabelNode(text: aSubstring)
lbl.fontSize = 20
lbl.numberOfLines = 1
lbl.fontColor = UIColor.white.withAlphaComponent(0.85)
lbl.fontName = “Helvetica-Bold”Purtroppo non c'è un supporto nativo per andare a capo su più righe con il carattere di ritorno a capo (\n), quindi dobbiamo ricrearlo a mano. Per farlo usiamo una semplice tokenizzazione della stringa originale.
let substrings: [String] = text.components(separatedBy: “\n”)
for aSubstring in substrings {
…
}La parte più interessante è come mappiamo il contenuto SpriteKit 2D nel mondo 3D. Per riuscirci usiamo SCNMaterial, la classe che definisce l'aspetto di una geometria 2D quando viene renderizzata in un mondo 3D. Come in ogni motore 3D, la nostra SCNScene ha luci e materiali che reagiscono ad esse in fase di rendering. Ci basta indicare al nostro SCNMaterial che vogliamo usare come materiale il contenuto della SKScene creata prima.
let material = SCNMaterial()
material.isDoubleSided = false
material.diffuse.contents = skSceneLa proprietà isDoubleSided è impostata a false, perché non ci interessa il lato posteriore del nostro testo. La proprietà diffuse è di tipo SCNMaterialProperty, un semplice contenitore per la texture del materiale. Per fortuna può contenere quasi qualsiasi cosa, compresa un'intera SKScene. Alla fine diciamo al piano creato prima di usare come sorgente il materiale personalizzato appena preparato.
plane.materials = [material]Se avete seguito tutto quello che abbiamo fatto finora ed eseguite il progetto su iOS 12, tutto funzionerà alla perfezione e resterete colpiti da un tracking 2D preciso e convincente. Se invece eseguite lo stesso codice su iOS 11, resterete delusi dalle prestazioni di tracking, piuttosto scarse.
Inoltre c'è un bug davvero fastidioso che lascia per sempre nel mondo 3D le immagini 2D rilevate. Se ricordate, all'inizio dell'articolo ho scritto che
una delle novità più interessanti di ARImageTrackingConfiguration è la nuova gestione degli oggetti che scompaiono dallo schermo.
Dato che su iOS 11 non c'è niente del genere, dobbiamo costruirci da soli un workaround. Useremo una struct privata per tenere tutte le informazioni necessarie:
struct SANodeAnchor {
var date:Date
var node:SCNNode
var anchor:ARAnchor
}dichiarandone un array privato nel nostro ViewController. Ci serve anche un polling periodico della scena, per eliminare gli oggetti non più validi
private var lastUpdate = [String:SANodeAnchor]()
private var scheduledTimer:Timer?il nostro metodo viewDidAppear si occuperà di definire il timer qui sopra
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if #available(iOS 12.0, *) {
}
else {
if let scheduledTimer = scheduledTimer {
scheduledTimer.invalidate()
}
scheduledTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [unowned self] (timer) in
for (_, nodeStruct) in self.lastUpdate {
if Date().timeIntervalSinceNow - nodeStruct.date.timeIntervalSinceNow > 3 {
nodeStruct.node.removeFromParentNode()
self.sceneView.session.remove(anchor: nodeStruct.anchor)
}
}
}
}
}Dobbiamo solo aggiornare l'array di cache lastUpdate quando oggetti nuovi o già rilevati vengono interpretati correttamente nella scena. Per questo useremo un altro metodo delegate di ARSCNViewDelegate:
func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
if let identifier = node.name {
let aStruct = SANodeAnchor(date: Date(), node: node, anchor: anchor)
lastUpdate[identifier] = aStruct
}
}In questo modo anche su iOS 11 riusciamo a smettere di tracciare gli oggetti 2D indesiderati sullo schermo.
Il progetto completo è disponibile su Github. Mentre scrivo, un bug affligge Xcode 10 e impedisce di usare le immagini del dataset quando si installa l'app su dispositivi con iOS 11.
Di conseguenza, su iOS 11 l'app non rileverà nessuna immagine 2D se installata da Xcode 10. Per aggirare il problema, bisogna eseguire il progetto qui sopra con Xcode 9 quando lo si installa su un dispositivo con iOS 11, commentando i simboli sconosciuti legati ad ARImageTrackingConfiguration.
Caricare i dataset da remoto
Una nota finale sul caricamento dinamico del dataset. Il codice qui sopra si basa su un dataset statico, memorizzato nel progetto. Potrebbe però essere meglio caricare il dataset dinamicamente da un server remoto. Per questo ho preparato un semplicissimo esempio in JSON.
[
{
"imageURL" : "https://www.sofapps.it/card1.jpg",
"name" : "card1",
"width" : 0.096
},
{
"imageURL" : "https://www.sofapps.it/card2.jpg",
"name" : "card2",
"width" : 0.058
}
]Scarichiamo questo JSON dalla nostra app:
func loadRemotedataset(fromURL: URL, _ completion: @escaping ([Card]?, Error?)->Void) {
URLSession.shared.dataTask(with: fromURL) { (data, response, error) in
if let error = error {
DispatchQueue.main.async {
completion(nil, error)
}
}
else if let data = data {
var retArray = [Card]()
do {
if let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [[String:Any]] {
for aRow in json {
if let aString = aRow["imageURL"] as? String,
let anURL = URL(string: aString),
let aName = aRow["name"] as? String,
let width = aRow["width"] as? CGFloat
{
retArray.append(Card(url: anURL, name: aName, width: width))
}
}
DispatchQueue.main.async {
completion(retArray, nil)
}
}
else {
throw NSError(domain: "JSON parse error", code: -1, userInfo: nil)
}
}
catch {
DispatchQueue.main.async {
completion(nil, error)
}
}
}
}.resume()
}e carichiamo i biglietti in modo dinamico:
func createDataset(_ completion: @escaping ([ARReferenceImage]?, Error?) -> Void) {
self.loadRemotedataset(fromURL: URL(string: "https://www.sofapps.it/sampleDataset.json")!) { (response, error) in
if let error = error {
print("something happened in the way to heaven...", error)
completion(nil, error)
}
else if let response = response {
var retArray = [ARReferenceImage]()
for aCard in response {
let response = URLSession.shared.synchronousDataTask(with: aCard.url)
if let error = response.2 {
print("something happened in the way to heaven...", error)
}
else if let data = response.0, let anImage = UIImage(data: data) {
var orientation = CGImagePropertyOrientation.up
switch anImage.imageOrientation {
case .up:
orientation = .up
case .down:
orientation = .down
case .left:
orientation = .left
case .right:
orientation = .right
default:
orientation = .up
}
if let cgImage = anImage.cgImage {
let aReferenceImage = ARReferenceImage.init(cgImage, orientation: orientation, physicalWidth: aCard.width)
aReferenceImage.name = aCard.name
retArray.append(aReferenceImage)
}
}
}
completion(retArray, nil)
}
}
}anche il nostro metodo resetTrackingConfiguration va adattato per tenere conto del caricamento a runtime:
func resetTrackingConfiguration() {
self.createDataset { [weak self] (referenceImages, error) in
if let error = error {
print("oops!, something happened on the way to heaven...", error)
}
else if let referenceImages = referenceImages {
var configuration:ARConfiguration!
if #available(iOS 12.0, *) {
configuration = ARImageTrackingConfiguration()
(configuration as! ARImageTrackingConfiguration).trackingImages = referenceImages
(configuration as! ARImageTrackingConfiguration).maximumNumberOfTrackedImages = referenceImages.count
} else {
configuration = ARWorldTrackingConfiguration()
(configuration as! ARWorldTrackingConfiguration).detectionImages = Set(referenceImages.map { $0 })
}
let options: ARSession.RunOptions = [.resetTracking, .removeExistingAnchors]
self?.sceneView.session.run(configuration, options: options)
}
}
}A questo punto l'app dovrebbe funzionare sia su iOS 11 sia su iOS 12, caricando dinamicamente dal server il contenuto del dataset.
Buon coding!
