Come progettare un SDK per la visione artificiale multipiattaforma indipendente dal linguaggio: un tutorial pratico

(Cyrus Behroozi) (21 ottobre 2020)

Recentemente ho avuto lopportunità di presentare al meetup Venice Computer Vision . Se non hai familiarità, si tratta di un evento sponsorizzato da Trueface in cui gli sviluppatori e gli appassionati di visione artificiale possono mostrare ricerche, applicazioni e pratiche pratiche allavanguardia sulla visione artificiale tutorial.

In questo articolo, esaminerò la mia presentazione tutorial su come progettare un kit di sviluppo software (SDK) indipendente dal linguaggio per la distribuzione multipiattaforma e la massima estensibilità. Se desideri visualizzare la registrazione dal vivo della presentazione, puoi farlo qui . Ho anche reso l intero progetto open source , quindi sentiti libero di usarlo come modello per il tuo prossimo progetto di visione artificiale.

cyrusbehr / sdk_design

Come progettare un SDK indipendente dal linguaggio per la distribuzione multipiattaforma e la massima estensibilità. A Venice Computer Vision …

github.com

Perché questo tutorial è importante

Nella mia esperienza, non ho mai trovato una guida onnicomprensiva che riassume tutti i passaggi pertinenti necessari per creare un SDK multipiattaforma indipendente dalla lingua. Ho dovuto setacciare la documentazione disparata solo per le informazioni giuste, imparare ogni componente separatamente e poi ricostruirlo tutto insieme da solo. È stato frustrante. Ci è voluto molto tempo. E ora tu, caro lettore, puoi trarre vantaggio da tutto il mio lavoro. In anticipo imparerai come creare un SDK multipiattaforma indipendente dalla lingua. Tutti gli elementi essenziali sono lì. Nessuno dei fluff, salvo alcuni meme. Buon divertimento.

In questo tutorial, puoi aspettarti di imparare a:

  • Costruire una libreria di visione artificiale di base in C ++
  • Compilare e incrociare compila la libreria per AMD64, ARM64 e ARM32
  • Pacchettizza la libreria e tutte le dipendenze come una singola libreria statica
  • Automatizza unit testing
  • Configura un continuo pipeline di integrazione (CI)
  • Scrivere collegamenti python per la nostra libreria
  • Genera documentazione direttamente dalla nostra API

Per il bene di questa demo, noi creerà un SDK per il rilevamento di volti e punti di riferimento utilizzando un rilevatore di volti open source chiamato MTCNN.

Esempio di riquadri di delimitazione del viso e punti di riferimento facciali

La nostra funzione API prenderà un percorso dellimmagine, quindi restituirà le coordinate del riquadro di delimitazione facciale e dei riferimenti facciali. La capacità di rilevare i volti è molto utile nella visione artificiale in quanto è il primo passo in molte pipeline tra cui il riconoscimento del volto, la previsione delletà e loffuscamento automatico dei volti.

Nota: Per questo tutorial, lavorerò su Ubuntu 18.04.

Perché usare C ++ per la nostra libreria?

Lesecuzione di codice C ++ efficiente può sembrare così

La maggior parte della nostra libreria sarà scritta in C ++, un linguaggio compilato e digitato staticamente. Non è un segreto che il C ++ è un linguaggio di programmazione molto veloce; è abbastanza basso da darci la velocità che desideriamo e ha un sovraccarico di runtime aggiunto minimo.

Nelle applicazioni di visione artificiale, generalmente manipoliamo molte immagini, eseguiamo operazioni con matrici, eseguiamo inferenze di apprendimento automatico, tutto ciò coinvolge una quantità enorme di elaborazione. La velocità di esecuzione è quindi fondamentale. Ciò è particolarmente importante nelle applicazioni in tempo reale in cui è necessario ridurre la latenza per ottenere una frequenza fotogrammi desiderata: spesso abbiamo solo millisecondi per eseguire tutto il nostro codice.

Un altro vantaggio di C ++ è che se noi compilare per una certa architettura e collegare staticamente tutte le dipendenze, quindi possiamo eseguirlo su quellhardware senza richiedere interpreti o librerie aggiuntivi. Che tu ci creda o no, possiamo persino funzionare su un dispositivo embedded bare metal senza sistema operativo!

Struttura delle directory

Useremo la seguente struttura di directory per il nostro progetto.

3rdparty conterrà le librerie di dipendenze di terze parti richieste dal nostro progetto.

dist conterrà i file che verranno distribuiti agli utenti finali dellSDK. Nel nostro caso, sarà la libreria stessa e il file di intestazione associato.

docker conterrà il file docker che verrà utilizzato per generare unimmagine docker per le build CI.

docs conterrà gli script di compilazione necessari per generare la documentazione direttamente dal nostro file di intestazione.

include conterrà tutti i file di inclusione per lAPI pubblica.

models conterrà i file del modello di deep learning per il rilevamento dei volti.

python conterrà il codice richiesto per generare collegamenti python.

src conterrà tutti i file cpp che verranno compilati, e anche qualsiasi file di intestazione che non verrà distribuito con lSDK (file di intestazione interni).

test conterrà i nostri unit test.

tools conterrà i nostri file della toolchain CMake necessari per la compilazione incrociata.

Installazione delle librerie di dipendenze

Per questo progetto, la terza parte le librerie di dipendenze richieste sono ncnn , una libreria di inferenza di machine learning leggera, OpenCV , una libreria per il potenziamento delle immagini, Catch2 , una libreria per i test di unità e infine pybind11 , una libreria utilizzato per generare collegamenti Python. Le prime due librerie dovranno essere compilate come librerie autonome, mentre le ultime due sono solo header e quindi richiediamo solo il sorgente.

Un modo per aggiungere queste librerie ai nostri progetti è tramite i sottomoduli git . Sebbene questo approccio funzioni, personalmente sono un fan dellutilizzo di script di shell che estraggono il codice sorgente e poi compilano per le piattaforme desiderate: nel nostro caso AMD64, ARM32 e ARM64.

Ecco un esempio di quello di questi script di compilazione ha il seguente aspetto:

Lo script è piuttosto semplice. Inizia estraendo il codice sorgente della versione desiderata dal repository git. Successivamente, CMake viene utilizzato per preparare la build, quindi make viene invocato per guidare il compilatore per creare il codice sorgente.

Ciò che noterai è che la differenza principale tra la build AMD64 e le build ARM è quella le build ARM passano un parametro CMake aggiuntivo chiamato CMAKE_TOOLCHAIN_FILE. Questo argomento viene utilizzato per specificare a CMake che larchitettura di destinazione della build (ARM32 o ARM64) è diversa dallarchitettura host (AMD64 / x86_64). A CMake viene quindi chiesto di utilizzare il compilatore incrociato specificato allinterno del file toolchain selezionato per costruire la libreria (maggiori informazioni sui file toolchain più avanti in questo tutorial). Affinché questo script di shell funzioni, è necessario che sulla macchina Ubuntu siano installati i cross compilatori appropriati. Questi possono essere installati facilmente utilizzando apt-get e le istruzioni su come farlo sono mostrate qui .

La nostra API della libreria

La nostra API della libreria ha il seguente aspetto:

Dato che sono super creativo, ho deciso di chiamare il mio SDK MySDK. Nella nostra API, abbiamo unenumerazione chiamata ErrorCode, una struttura chiamata Point e, infine, abbiamo una funzione membro pubblica chiamata getFaceBoxAndLandmarks. Per lo scopo di questo tutorial, non entrerò nei dettagli dellimplementazione dellSDK. Il succo è che leggiamo limmagine in memoria utilizzando OpenCV, quindi eseguiamo linferenza di apprendimento automatico utilizzando ncnn con modelli open source per rilevare il riquadro di delimitazione del viso ei punti di riferimento. Se desideri approfondire limplementazione, puoi farlo qui .

Ciò a cui voglio che presti attenzione è il modello di progettazione che stiamo utilizzando. Stiamo usando una tecnica chiamata Puntatore allimplementazione, o pImpl in breve, che sostanzialmente rimuove i dettagli di implementazione di una classe inserendoli in una classe separata. Nel codice precedente, ciò si ottiene dichiarando in avanti la classe Impl, quindi aggiungendo unique_ptr a questa classe come variabile membro privata. In tal modo, non solo nascondiamo limplementazione agli occhi indiscreti dellutente finale (che può essere abbastanza importante in un SDK commerciale), ma riduciamo anche il numero di intestazioni da cui dipende la nostra intestazione API (e quindi impediamo il nostro Intestazione API da #include ing intestazioni della libreria delle dipendenze).

Una nota sui file di modello

Ho detto che non avremmo rivisto i dettagli dellimplementazione, ma cè qualcosa che penso valga la pena menzionare. Per impostazione predefinita, il rilevatore di volti open source che stiamo utilizzando, chiamato MTCNN, carica i file del modello di apprendimento automatico in fase di esecuzione. Non è lideale perché significa che dovremo distribuire i modelli allutente finale. Questo problema è ancora più significativo con i modelli commerciali in cui non desideri che gli utenti abbiano accesso gratuito a questi file di modello (pensa alle innumerevoli ore che sono state dedicate alla formazione di questi modelli). Una soluzione è crittografare i file di questi modelli, cosa che consiglio assolutamente di fare.Tuttavia, questo significa ancora che dobbiamo spedire i file del modello insieme allSDK. In definitiva, vogliamo ridurre il numero di file che inviamo a un utente per rendergli più facile lutilizzo del nostro software (meno file equivalgono a meno posti dove andare male). Possiamo quindi utilizzare il metodo mostrato di seguito per convertire i file del modello in file di intestazione e incorporarli effettivamente nello stesso SDK.

Il comando xdd bash viene utilizzato per generare dump esadecimali e può essere utilizzato per generare un file di intestazione da un file binario. Possiamo quindi includere i file del modello nel nostro codice come normali file di intestazione e caricarli direttamente dalla memoria. Una limitazione di questo approccio è che non è pratico con file di modello molto grandi poiché consuma troppa memoria in fase di compilazione. Invece, puoi utilizzare uno strumento come ld per convertire questi file modello di grandi dimensioni direttamente in file oggetto.

CMake e compilazione della nostra libreria

Ora possiamo usare CMake per generare i file di build per il nostro progetto. Nel caso tu non abbia familiarità, CMake è un generatore di sistema di compilazione utilizzato per gestire il processo di compilazione. Di seguito, vedrai quale parte della radice CMakeLists.txt (file CMake) appare.

Fondamentalmente, creiamo una libreria statica chiamata my_sdk_static con i due file di origine che contengono la nostra implementazione, my_sdk.cpp e mtcnn.cpp. Il motivo per cui stiamo creando una libreria statica è che, nella mia esperienza, è più facile distribuire una libreria statica agli utenti ed è più amichevole verso i dispositivi incorporati. Come accennato in precedenza, se un eseguibile è collegato a una libreria statica, può essere eseguito su un dispositivo incorporato che non dispone nemmeno di un sistema operativo. Questo semplicemente non è possibile con una libreria dinamica. Inoltre, con le librerie dinamiche, dobbiamo preoccuparci delle versioni delle dipendenze. Potremmo anche aver bisogno di un file manifest associato alla nostra libreria. Le librerie collegate staticamente hanno anche un profilo di prestazioni leggermente migliore rispetto alle loro controparti dinamiche.

La prossima cosa che facciamo nel nostro script CMake è dire a CMake dove trovare i file di intestazione di inclusione necessari richiesti dai nostri file sorgente. Qualcosa da notare: anche se la nostra libreria verrà compilata a questo punto, quando proveremo a collegarci alla nostra libreria (con un eseguibile per esempio), otterremo una tonnellata assoluta di riferimenti indefiniti agli errori dei simboli. Questo perché non abbiamo collegato nessuna delle nostre librerie di dipendenze. Quindi, se volessimo collegare con successo un eseguibile a libmy_sdk_static.a, dovremmo rintracciare e collegare anche tutte le librerie di dipendenze (moduli OpenCV, ncnn, ecc.). A differenza delle librerie dinamiche, le librerie statiche non possono risolvere le proprie dipendenze. Sono fondamentalmente solo una raccolta di file oggetto impacchettati in un archivio.

Più avanti in questo tutorial, dimostrerò come possiamo raggruppare tutte le librerie delle dipendenze nella nostra libreria statica in modo che lutente non debba preoccuparti del collegamento con qualsiasi libreria di dipendenze.

Compilazione incrociata dei nostri file di libreria e toolchain

Ledge computing è così … tagliente

Molte applicazioni di visione artificiale sono distribuite alledge. Questo generalmente implica lesecuzione del codice su dispositivi embedded a bassa potenza che di solito hanno CPU ARM. Poiché il C ++ è un linguaggio compilato, dobbiamo compilare il nostro codice per larchitettura della CPU su cui verrà eseguita lapplicazione (ogni architettura utilizza istruzioni di assembly diverse).

Prima di immergerci in esso, tocchiamo anche il differenza tra ARM32 e ARM64, chiamati anche AArch32 e AArch64. AArch64 si riferisce allestensione a 64 bit dellarchitettura ARM e dipende sia dalla CPU che dal sistema operativo. Quindi, ad esempio, anche se il Raspberry Pi 4 ha una CPU ARM a 64 bit, il sistema operativo predefinito Raspbian è a 32 bit. Pertanto un tale dispositivo richiede un binario compilato AArch32. Se dovessimo eseguire un sistema operativo a 64 bit come Gentoo su questo dispositivo Pi, allora avremmo bisogno di un binario compilato AArch64. Un altro esempio di un popolare dispositivo embedded è NVIDIA Jetson che ha una GPU integrata ed esegue AArch64.

Per eseguire la compilazione incrociata, dobbiamo specificare a CMake che non stiamo compilando per larchitettura del macchina su cui stiamo attualmente costruendo. Pertanto, è necessario specificare il compilatore incrociato che CMake dovrebbe utilizzare. Per AArch64, utilizziamo il compilatore aarch64-linux-gnu-g++ e per AArch32 utilizziamo il compilatore arm-linux-gnuebhif-g++ (hf sta per hard float ).

Il seguente è un esempio di un file toolchain. Come puoi vedere, stiamo specificando di utilizzare il compilatore incrociato AArch64.

Tornando alla nostra radice CMakeLists.txt, possiamo aggiungere il codice seguente allinizio del file.

Fondamentalmente, stiamo aggiungendo le opzioni CMake che può essere abilitato dalla riga di comando per eseguire la compilazione incrociata. Abilitando le opzioni BUILD_ARM32 o BUILD_ARM64 si selezionerà il file toolchain appropriato e si configurerà la build per una compilazione incrociata.

Pacchettizzare il nostro SDK con le librerie di dipendenze

Come accennato in precedenza, se uno sviluppatore desidera collegarsi alla nostra libreria a questo punto, dovrà anche collegarsi a tutte le librerie di dipendenze per risolvere tutti i simboli da librerie di dipendenze. Anche se la nostra app è piuttosto semplice, abbiamo già otto librerie di dipendenze! Il primo è ncnn, poi abbiamo tre librerie di moduli OpenCV, poi abbiamo quattro librerie di utilità che sono state create con OpenCV (libjpeg, libpng, zlib, libtiff). Potremmo richiedere allutente di creare le librerie delle dipendenze da solo o addirittura di spedirle insieme alla nostra libreria, ma alla fine ciò richiede più lavoro per lutente e stiamo tutti abbassando la barriera per luso. La situazione ideale è se possiamo spedire allutente una singola libreria che contiene la nostra libreria insieme a tutte le librerie di dipendenze di terze parti diverse dalle librerie di sistema standard. Si scopre che possiamo ottenere questo risultato usando un po di magia CMake.

Per prima cosa aggiungiamo un target personalizzato a il nostro CMakeLists.txt, quindi esegui quello che viene chiamato uno script MRI. Questo script MRI viene passato al comando ar -M bash, che fondamentalmente combina tutte le librerie statiche in un unico archivio. La cosa bella di questo metodo è che gestirà con garbo i nomi dei membri sovrapposti dagli archivi originali, quindi non dobbiamo preoccuparci dei conflitti lì. La creazione di questo target personalizzato produrrà libmy_sdk.a che conterrà il nostro SDK insieme a tutti gli archivi delle dipendenze.

Aspetta un secondo: facciamo il punto di ciò che abbiamo finora.

Prendi fiato. Fai uno spuntino. Chiama tua madre.

A questo punto, abbiamo una libreria statica chiamata libmy_sdk.a che contiene il nostro SDK e tutte le librerie delle dipendenze, che abbiamo impacchettato in un unico archivio. Abbiamo anche la possibilità di compilare e cross-compilare (utilizzando argomenti della riga di comando) per tutte le nostre piattaforme di destinazione.

Test unitari

Quando esegui i tuoi unit test per la prima volta

Probabilmente non è necessario spiegare perché gli unit test sono importanti, ma fondamentalmente sono una parte cruciale della progettazione dellSDK che consente allo sviluppatore di assicurarsi che lSDK funzioni come rientrato. Inoltre, se vengono apportate modifiche sostanziali lungo la linea, è utile rintracciarle e rilasciare le correzioni più velocemente.

In questo caso specifico, la creazione di un eseguibile di unit test ci dà anche lopportunità di collegarci al libreria combinata che abbiamo appena creato per assicurarci di poter collegare correttamente come previsto (e non otteniamo nessuno di quei fastidiosi errori di riferimento indefiniti a simboli).

Stiamo usando Catch2 come nostro framework di unit test . La sintassi è delineata di seguito:

Il funzionamento di Catch2 è che abbiamo questa macro chiamata TEST_CASE e unaltra macro chiamata SECTION. Per ogni SECTION, TEST_CASE viene eseguito dallinizio. Quindi, nel nostro esempio, mySdk verrà prima inizializzato, quindi verrà eseguita la prima sezione denominata “Immagine senza volto”. Successivamente, mySdk verrà decostruito prima di essere ricostruito, quindi verrà eseguita la seconda sezione denominata “Volti nellimmagine”. Questo è ottimo perché ci assicura di avere un nuovo oggetto MySDK su cui operare per ogni sezione. Possiamo quindi utilizzare macro come REQUIRE per fare le nostre affermazioni.

Possiamo usare CMake per creare un eseguibile di unit test chiamato run_tests. Come possiamo vedere nella chiamata a target_link_libraries alla riga 3 di seguito, lunica libreria a cui dobbiamo collegarci è la nostra libmy_sdk.a e nessun altra librerie di dipendenze.

Documentazione

Se solo gli utenti leggessero la dannata documentazione.

Useremo doxygen per generare la documentazione direttamente dal nostro file di intestazione. Possiamo andare avanti e documentare tutti i nostri metodi e tipi di dati nella nostra intestazione pubblica utilizzando la sintassi mostrata nello snippet di codice di seguito.Assicurati di specificare tutti i parametri di input e output per qualsiasi funzione.

Per generare effettivamente la documentazione , abbiamo bisogno di qualcosa chiamato doxyfile che è fondamentalmente un modello per istruire doxygen su come generare la documentazione. Possiamo generare un doxyfile generico eseguendo doxygen -g nel nostro terminale, supponendo che doxygen sia installato sul tuo sistema. Successivamente, possiamo modificare il file doxy. Come minimo, dobbiamo specificare la directory di output e anche i file di input.

Nel nostro case, vogliamo solo generare documentazione dal nostro file di intestazione API, motivo per cui abbiamo specificato la directory include. Infine, utilizzi CMake per creare effettivamente la documentazione, operazione che può essere eseguita in questo modo .

Associazioni Python

Sei stanco di vedere ancora gif semi-rilevanti? Sì, nemmeno io.

Siamo onesti. Il C ++ non è il linguaggio più facile o più amichevole in cui sviluppare. Pertanto, vogliamo estendere la nostra libreria per supportare le associazioni di lingue per renderlo più facile da usare per gli sviluppatori. Lo dimostrerò usando Python in quanto è un popolare linguaggio di prototipazione di visione artificiale, ma le associazioni di altri linguaggi sono altrettanto facili da scrivere. Stiamo utilizzando pybind11 per raggiungere questo obiettivo:

Iniziamo utilizzando PYBIND11_MODULE macro che crea una funzione che verrà chiamata quando viene emessa unistruzione import da Python. Quindi, nellesempio sopra, il nome del modulo python è mysdk. Successivamente, siamo in grado di definire le nostre classi e i loro membri utilizzando la sintassi pybind11.

Ecco qualcosa da notare: in C ++, è abbastanza comune passare variabili utilizzando riferimenti mutabili che consentono sia laccesso in lettura che in scrittura. Questo è esattamente ciò che abbiamo fatto con la nostra funzione membro API con i parametri faceDetected e fbAndLandmarks. In Python, tutti gli argomenti vengono passati per riferimento. Tuttavia, alcuni tipi di python di base non sono modificabili, incluso bool. Per coincidenza, il nostro parametro faceDetected è un bool che viene passato per riferimento mutabile. Dobbiamo quindi utilizzare la soluzione alternativa mostrata nel codice sopra alle righe da 31 a 34 in cui definiamo il bool allinterno della nostra funzione wrapper python, quindi passarlo alla nostra funzione C ++ prima di restituire la variabile come parte di una tupla.

Una volta creata la libreria di collegamenti Python, possiamo utilizzarla facilmente utilizzando il codice seguente:

Integrazione continua

Per la nostra pipeline di integrazione continua, utilizzeremo uno strumento chiamato CircleCI che mi piace molto perché si integra direttamente con Github. Una nuova build verrà automaticamente attivata ogni volta che si invia un commit. Per iniziare, vai al sito web di CircleCI e collegalo al tuo account Github, quindi seleziona il progetto che desideri aggiungere. Una volta aggiunto, dovrai creare una directory .circleci alla radice del progetto e creare un file chiamato config.yml allinterno di quella directory.

Per chiunque non abbia familiarità, YAML è un linguaggio di serializzazione comunemente usato per i file di configurazione. Possiamo usarlo per indicare quali operazioni vogliamo che CircleCI esegua. Nello snippet YAML di seguito, puoi vedere come creiamo prima una delle librerie delle dipendenze, poi compiliamo lSDK stesso e infine compiliamo ed eseguiamo gli unit test.

Se siamo intelligenti (e presumo che lo siate se siete arrivati ​​fin qui), possiamo usare la cache per ridurre significativamente i tempi di compilazione. Ad esempio, nello YAML sopra, memorizziamo nella cache la build OpenCV utilizzando lhash dello script di build come chiave di cache. In questo modo, la libreria OpenCV verrà ricostruita solo se lo script di build è stato modificato, altrimenti verrà utilizzata la build memorizzata nella cache. Unaltra cosa da notare è che stiamo eseguendo la build allinterno di unimmagine finestra mobile di nostra scelta. Ho selezionato unimmagine Docker personalizzata ( qui è il Dockerfile) in cui ho installato tutte le dipendenze di sistema.

Fin.

E il gioco è fatto. Come ogni prodotto ben progettato, vogliamo supportare le piattaforme più richieste e renderlo facile da usare per il maggior numero di sviluppatori. Utilizzando il tutorial sopra, abbiamo creato un SDK accessibile in diverse lingue ed è distribuibile su più piattaforme. E non dovevi nemmeno leggere da solo la documentazione di pybind11. Spero che tu abbia trovato utile e divertente questo tutorial. Buona costruzione.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *