Cum să proiectați un SDK de viziune computerizată multiplatăformă lingvistică: un tutorial practic

(Cyrus Behroozi) (21 octombrie 2020)

Am avut recent ocazia să prezint la întâlnirea Venice Computer Vision . Dacă nu sunteți familiarizați, este un eveniment sponsorizat de Trueface , unde dezvoltatorii de viziune pe computer și entuziaștii pot prezenta cercetări de ultimă oră, aplicații și practici de viziune pe computer. tutoriale.

În acest articol, voi trece în revistă prezentarea tutorialului meu despre cum să proiectez un kit de dezvoltator de software de viziune computerizată (SDK) pentru limbajul agnostic pentru implementarea pe mai multe platforme și extensibilitatea maximă. Dacă doriți să vizualizați înregistrarea live a prezentării, puteți face acest lucru aici . De asemenea, am creat întregul proiect open source , așa că nu ezitați să-l folosiți ca șablon pentru următorul dvs. proiect de viziune computerizată.

cyrusbehr / sdk_design

Cum să proiectăm un SDK Agnostic de limbă pentru implementarea pe mai multe platforme și extensibilitatea maximă. O viziune computerizată de la Veneția …

github.com

De ce este important acest tutorial

Din experiența mea, nu am găsit niciodată un ghid atotcuprinzător care rezumă toți pașii pertinenți necesari pentru a crea un SDK multi-platformă agnostic de limbă. A trebuit să pieptăn documentația diferită pentru a obține doar informațiile potrivite, să învăț fiecare componentă separat și apoi să o împărțesc singură. A fost frustrant. A durat mult timp. Și acum tu, dragă cititoare, poți beneficia de toată munca mea. Înainte veți învăța cum să construiți un SDK multi-platformă agnostic pentru limbă. Toate elementele esențiale sunt acolo. Niciunul din puf, salvați câteva meme. Bucurați-vă.

În acest tutorial, vă puteți aștepta să aflați cum să:

  • Construiți o bibliotecă de bază de viziune computerizată în C ++
  • Compilați și încrucișați compilați biblioteca pentru AMD64, ARM64 și ARM32. de integrare (CI)
  • Scrieți legături python pentru biblioteca noastră
  • Generați documentație direct din API-ul nostru

Pentru acest demo, vom va construi un SDK de detectare a feței și reperelor utilizând un detector de față open-source numit MTCNN.

Exemplu a casetelor de limitare a feței și a reperelor faciale

Funcția noastră API va lua o cale de imagine, apoi va returna coordonatele casetei de limitare a feței și a reperelor faciale. Capacitatea de a detecta fețele este foarte utilă în vederea computerizată, deoarece este primul pas în multe conducte, inclusiv recunoașterea feței, predicția vârstei și estomparea automată a feței.

Notă: Pentru aceasta tutorial, voi lucra la Ubuntu 18.04.

De ce să folosim C ++ pentru biblioteca noastră?

Rularea unui cod C ++ eficient se poate simți așa

Majoritatea bibliotecii noastre va fi scrisă în C ++, un limbaj compilat și tastat static. Nu este un secret faptul că C ++ este un limbaj de programare foarte rapid; este suficient de scăzut pentru a ne oferi viteza pe care o dorim și are o durată de rulare minimă adăugată.

În aplicațiile de viziune computerizată, manipulăm, în general, o mulțime de imagini, efectuăm operații matrice, executăm inferențe de învățare automată, toate acestea implicând o cantitate masivă de calcul. Viteza de execuție este, prin urmare, critică. Acest lucru este deosebit de important în aplicațiile în timp real în care trebuie să reduceți latența pentru a obține o rată de cadre dorită – de multe ori avem doar milisecunde pentru a rula tot codul nostru.

Un alt avantaj al C ++ este că dacă compilați pentru o anumită arhitectură și conectați toate dependențele în mod static, apoi îl putem rula pe acel hardware fără a necesita interpretori sau biblioteci suplimentare. Credeți sau nu, putem rula chiar și pe un dispozitiv încorporat metalic fără sistem de operare!

Structura directorului

Vom folosi următoarea structură de directoare pentru proiectul nostru.

3rdparty va conține bibliotecile de dependență terță parte cerute de proiectul nostru.

dist va conține fișierele care sunt distribuite utilizatorilor finali ai SDK-ului. În cazul nostru, aceasta va fi biblioteca în sine și fișierul antet asociat.

docker va conține fișierul docker care va fi utilizat pentru a genera o imagine docker pentru construcțiile CI.

docs va conține scripturile de compilare necesare pentru a genera documentație direct din fișierul nostru de antet.

include va conține orice fișiere de includere pentru API-ul public.

models va conține fișierele modelului de învățare profundă de detectare a feței.

python va conține codul necesar pentru a genera legături python.

src va conține orice fișier CPP care va fi compilat, și, de asemenea, orice fișier de antet care nu va fi distribuit cu SDK (fișiere de antet interne).

test va conține testele noastre unitare.

tools va conține fișierele noastre de instrumente CMake necesare pentru compilarea încrucișată.

Instalarea bibliotecilor de dependență

Pentru acest proiect, terții bibliotecile de dependență care sunt necesare sunt ncnn , o bibliotecă de inferență ușoară de învățare automată, OpenCV , o bibliotecă de mărire a imaginilor, Catch2 , o bibliotecă de testare a unității și, în final, pybind11 , o bibliotecă folosit pentru generarea de legături python. Primele două biblioteci vor trebui compilate ca biblioteci independente, în timp ce ultimele două sunt doar antet și, prin urmare, avem nevoie doar de sursă.

O modalitate de a adăuga aceste biblioteci la proiectele noastre este prin intermediul sub-modulelor git . Deși această abordare funcționează, sunt personal un fan al utilizării scripturilor shell care extrag codul sursă, apoi construiesc pentru platformele dorite: în cazul nostru AMD64, ARM32 și ARM64.

Iată un exemplu dintre aceste scripturi de compilare arată:

Scriptul este destul de simplu. Începe prin extragerea codului sursă de lansare dorit din depozitul git. Apoi, CMake este folosit pentru a pregăti compilarea, apoi se invocă make pentru a conduce compilatorul să construiască codul sursă.

Ceea ce veți observa este că diferența principală între versiunea AMD64 și versiunile ARM este că versiunile ARM trec un parametru suplimentar CMake numit CMAKE_TOOLCHAIN_FILE. Acest argument este folosit pentru a specifica lui CMake că arhitectura țintă de construire (ARM32 sau ARM64) este diferită de arhitectura gazdă (AMD64 / x86_64). Prin urmare, CMake este instruit să utilizeze compilatorul încrucișat specificat în fișierul de instrumente selectat pentru a construi biblioteca (mai multe despre fișierele de instrumente de instrumente mai târziu în acest tutorial). Pentru ca acest script shell să funcționeze, va trebui să aveți instalate compilatoarele încrucișate corespunzătoare pe mașina dvs. Ubuntu. Acestea pot fi instalate cu ușurință utilizând apt-get, iar instrucțiunile despre cum se face acest lucru sunt afișate aici .

API-ul nostru pentru bibliotecă

API-ul nostru pentru bibliotecă arată astfel:

Deoarece sunt super creativ, am decis să-mi numesc SDK-ul MySDK. În API-ul nostru, avem o enum numită ErrorCode, avem o structură numită Point și, în final, avem o funcție de membru public numită getFaceBoxAndLandmarks. În scopul acestui tutorial, nu voi intra în detalii despre implementarea SDK-ului. Esențialul este că citim imaginea în memorie folosind OpenCV, apoi efectuăm inferența de învățare automată folosind ncnn cu modele open source pentru a detecta caseta de limitare a feței și reperele. Dacă doriți să vă scufundați în implementare, puteți face acest lucru aici .

Totuși, la care vreau să fiți atenți model de design pe care îl folosim. Folosim o tehnică numită Pointer to implementation, sau pImpl pe scurt, care elimină practic detaliile de implementare ale unei clase plasându-le într-o clasă separată. În codul de mai sus, acest lucru se realizează prin declararea înainte a clasei Impl, apoi având o unique_ptr către această clasă ca variabilă de membru privat. Făcând acest lucru, nu numai că ascundem implementarea de ochii curioși ai utilizatorului final (ceea ce poate fi destul de important într-un SDK comercial), dar reducem și numărul de anteturi de care depinde antetul API-ului nostru (și astfel împiedică Antetul API din #include ing antetele bibliotecii de dependență).

O notă despre fișierele model

Am spus că nu vom trece peste detaliile implementării, dar cred că merită menționat ceva. În mod implicit, detectorul de față open source pe care îl folosim, numit MTCNN, încarcă fișierele modelului de învățare automată în timp de execuție. Acest lucru nu este ideal, deoarece înseamnă că va trebui să distribuim modelele către utilizatorul final. Această problemă este cu atât mai semnificativă în cazul modelelor comerciale în care nu doriți ca utilizatorii să aibă acces gratuit la aceste fișiere model (gândiți-vă la nenumăratele ore care au trecut la instruirea acestor modele). O soluție este criptarea fișierelor acestor modele, lucru pe care vă sfătuiesc absolut să îl faceți.Cu toate acestea, acest lucru înseamnă totuși că trebuie să livrăm fișierele model împreună cu SDK-ul. În cele din urmă, dorim să reducem numărul de fișiere pe care le trimitem unui utilizator pentru a facilita utilizarea software-ului nostru (mai puține fișiere sunt mai puține locuri pentru a greși). Prin urmare, putem folosi metoda prezentată mai jos pentru a converti fișierele model în fișiere antet și pentru a le încorpora în SDK-ul în sine.

Comanda xdd bash este utilizată pentru generarea de goluri hexagonale și poate fi utilizată pentru a genera un fișier antet dintr-un fișier binar. Prin urmare, putem include fișierele model în codul nostru, cum ar fi fișierele antet normale și le putem încărca direct din memorie. O limitare a acestei abordări este că nu este practic cu fișiere de model foarte mari, deoarece consumă prea multă memorie în timpul compilării. În schimb, puteți utiliza un instrument precum ld pentru a converti aceste fișiere mari de model direct în fișiere obiect.

CMake and Compiling our Library

Acum putem folosi CMake pentru a genera fișierele de construcție pentru proiectul nostru. În cazul în care nu sunteți familiarizați, CMake este un generator de sistem de construcție folosit pentru a gestiona procesul de construire. Mai jos, veți vedea cum arată partea din rădăcină CMakeLists.txt (fișier CMake).

Practic, creăm o bibliotecă statică numită my_sdk_static cu cele două fișiere sursă care conțin implementarea noastră, my_sdk.cpp și mtcnn.cpp. Motivul pentru care creăm o bibliotecă statică este că, din experiența mea, este mai ușor să distribuiți o bibliotecă statică către utilizatori și este mai prietenos cu dispozitivele încorporate. După cum am menționat mai sus, dacă un executabil este legat de o bibliotecă statică, acesta poate fi rulat pe un dispozitiv încorporat care nu are nici măcar un sistem de operare. Acest lucru pur și simplu nu este posibil cu o bibliotecă dinamică. În plus, cu bibliotecile dinamice, trebuie să ne facem griji cu privire la versiunile de dependență. S-ar putea chiar să avem nevoie de un fișier manifest asociat bibliotecii noastre. Bibliotecile conectate static au, de asemenea, un profil de performanță puțin mai bun decât omologii lor dinamici.

Următorul lucru pe care îl facem în scriptul nostru CMake este să-i spunem lui CMake unde să găsească fișierele antet necesare care sunt necesare fișierelor sursă. Ceva de remarcat: deși biblioteca noastră va compila în acest moment, atunci când încercăm să facem legătura cu biblioteca noastră (cu un executabil de exemplu), vom obține o tonă absolută de referință nedefinită la erorile de simbol. Acest lucru se datorează faptului că nu am legat niciuna dintre bibliotecile noastre de dependență. Deci, dacă am dori să legăm cu succes un executabil împotriva libmy_sdk_static.a, atunci ar trebui să urmărim și să legăm și toate bibliotecile de dependență (module OpenCV, ncnn etc.). Spre deosebire de bibliotecile dinamice, bibliotecile statice nu își pot rezolva propriile dependențe. Practic, acestea sunt doar o colecție de fișiere obiect pachetate într-o arhivă.

Mai târziu, în acest tutorial, voi demonstra cum putem grupa toate bibliotecile de dependență în biblioteca noastră statică, astfel încât utilizatorul să nu aibă nevoie să vă faceți griji în legătură cu oricare dintre bibliotecile de dependență.

Compilarea încrucișată a bibliotecii și a fișierelor noastre de instrumente

Calculul de margine este atât de … nebun

Multe aplicații de viziune pe computer sunt implementate la margine. implică rularea codului pe dispozitive încorporate de consum redus de energie, care de obicei au procesoare ARM. Deoarece C ++ este un limbaj compilat, trebuie să compilăm codul nostru pentru arhitectura CPU pe care va fi rulată aplicația (fiecare arhitectură folosește instrucțiuni de asamblare diferite).

Înainte de a ne arunca cu capul în el, să atingem și diferență între ARM32 și ARM64, numite și AArch32 și AArch64. AArch64 se referă la extensia pe 64 de biți a arhitecturii ARM și este atât dependent de CPU, cât și de sistemul de operare. De exemplu, deși Raspberry Pi 4 are un procesor ARM pe 64 de biți, sistemul de operare implicit Raspbian este pe 32 de biți. Prin urmare, un astfel de dispozitiv necesită un binar compilat AArch32. Dacă ar fi să rulăm un sistem de operare pe 64 de biți, cum ar fi Gentoo pe acest dispozitiv Pi, atunci am avea nevoie de un binar compilat AArch64. Un alt exemplu de dispozitiv încorporat popular este NVIDIA Jetson care are o GPU integrată și rulează AArch64.

Pentru a compila încrucișat, trebuie să îi specificăm lui CMake că nu compilăm pentru arhitectura mașină pe care construim în prezent. Prin urmare, trebuie să specificăm compilatorul încrucișat pe care CMake ar trebui să îl folosească. Pentru AArch64, folosim compilatorul aarch64-linux-gnu-g++, iar pentru AArch32 folosim compilatorul arm-linux-gnuebhif-g++ (hf înseamnă hard float ).

Următorul este un exemplu de fișier lanț de instrumente. După cum puteți vedea, specificăm să utilizăm compilatorul încrucișat AArch64.

Înapoi la rădăcina noastră CMakeLists.txt, putem adăugați următorul cod în partea de sus a fișierului.

Practic, adăugăm opțiuni CMake care poate fi activat din linia de comandă pentru a compila încrucișat. Activarea opțiunilor BUILD_ARM32 sau BUILD_ARM64 va selecta fișierul corespunzător lanțului de instrumente și va configura construcția pentru o compilație încrucișată.

Împachetarea SDK-ului nostru cu biblioteci de dependență

Așa cum am menționat mai devreme, dacă un dezvoltator dorește să facă legătura cu biblioteca noastră în acest moment, va trebui să facă legătura cu toate bibliotecile de dependență pentru a rezolva toate simbolurile biblioteci de dependență. Chiar dacă aplicația noastră este destul de simplă, avem deja opt biblioteci de dependență! Prima este ncnn, apoi avem trei biblioteci de module OpenCV, apoi avem patru biblioteci utilitare care au fost construite cu OpenCV (libjpeg, libpng, zlib, libtiff). Am putea solicita utilizatorului să construiască ei înșiși bibliotecile de dependență sau chiar să le livreze alături de biblioteca noastră, dar în cele din urmă asta necesită mai multă muncă pentru utilizator și suntem toți în legătură cu reducerea barierei pentru utilizare. Situația ideală este dacă putem livra utilizatorului o singură bibliotecă care conține biblioteca noastră împreună cu toate bibliotecile de dependență terță parte, altele decât bibliotecile de sistem standard. Se pare că putem realiza acest lucru folosind unele magii CMake.

Mai întâi adăugăm o țintă personalizată la CMakeLists.txt, apoi executăm ceea ce se numește un script RMN. Acest script MRI este trecut la comanda ar -M bash, care combină practic toate bibliotecile statice într-o singură arhivă. Ce este interesant în această metodă este că va gestiona cu grație numele suprapuse ale membrilor din arhivele originale, deci nu trebuie să ne facem griji cu privire la conflictele de acolo. Construirea acestei ținte personalizate va produce libmy_sdk.a care va conține SDK-ul nostru împreună cu toate arhivele de dependență.

Țineți o secundă: Să facem un bilanț cu ceea ce Am făcut până acum.

Respirați. Ia o gustare. Sunați-o pe mama dvs.

În acest moment, avem o bibliotecă statică numită libmy_sdk.a care conține SDK-ul nostru și toate bibliotecile de dependență, pe care le-am împachetat într-o singură arhivă. De asemenea, avem capacitatea de a compila și compila încrucișat (folosind argumente din linia de comandă) pentru toate platformele noastre țintă.

Teste de unitate

Când rulați testele unitare pentru prima dată

Probabil că nu trebuie să explicați de ce testele unitare sunt importante, dar practic ele reprezintă o parte crucială a proiectării SDK care permite dezvoltatorului să se asigure că SDK funcționează ca indentat. În plus, dacă se fac modificări de-a lungul liniei, vă ajută să le urmăriți și să împingeți corecțiile mai repede.

În acest caz specific, crearea unui executabil de test de unitate ne oferă, de asemenea, posibilitatea de a face legătura cu bibliotecă combinată pe care tocmai am creat-o pentru a ne asigura că ne putem conecta corect așa cum am intenționat (și nu primim niciuna dintre acele erori nedefinite de referință la simbol).

Folosim Catch2 ca cadru de testare unitară . Sintaxa este prezentată mai jos:

Cum funcționează Catch2 este că avem această macro numită TEST_CASE și o altă macro numită SECTION. Pentru fiecare SECTION, TEST_CASE este executat de la început. Deci, în exemplul nostru, mySdk va fi inițializat mai întâi, apoi va fi rulată prima secțiune numită „Imagine non-față”. Apoi, mySdk va fi deconstruit înainte de a fi reconstruit, apoi va rula a doua secțiune numită „Fețe în imagine”. Acest lucru este extraordinar, deoarece ne asigură că avem un obiect MySDK proaspăt pe care să îl operăm pentru fiecare secțiune. Putem folosi apoi macrocomenzi precum REQUIRE pentru a face afirmațiile noastre.

Putem folosi CMake pentru a construi un executabil de testare unitar numit run_tests. După cum putem vedea în apelul către target_link_libraries de pe linia 3 de mai jos, singura bibliotecă de care trebuie să facem legătura este libmy_sdk.a și nu alta biblioteci de dependență.

Documentare

Dacă numai utilizatorii ar citi nenorocita de documentație.

Vom folosi doxygen pentru a genera documentație direct din fișierul nostru de antet. Putem continua și documenta toate metodele și tipurile noastre de date în antetul nostru public folosind sintaxa prezentată în fragmentul de cod de mai jos.Asigurați-vă că specificați toți parametrii de intrare și de ieșire pentru orice funcție.

Pentru a genera efectiv documentație , avem nevoie de ceva numit doxyfile, care este practic un plan pentru a instrui doxygen cum să genereze documentația. Putem genera un doxyfile generic rulând doxygen -g în terminalul nostru, presupunând că aveți doxygen instalat pe sistemul dvs. Apoi, putem edita doxyfile. Cel puțin, trebuie să specificăm directorul de ieșire, precum și fișierele de intrare.

În caz, vrem să generăm doar documentație din fișierul nostru de antet API, motiv pentru care am specificat directorul include. În cele din urmă, utilizați CMake pentru a construi de fapt documentația, ceea ce se poate face așa .

Legături Python

V-ați săturat să vedeți gifuri semi-relevante? Da, nici eu.

Să fim sinceri. C ++ nu este cel mai ușor sau mai prietenos limbaj în care să se dezvolte. Prin urmare, dorim să extindem biblioteca noastră pentru a sprijini legările de limbă, pentru a fi mai ușor de utilizat pentru dezvoltatori. Voi demonstra acest lucru folosind python, întrucât este un limbaj popular de prototipare a viziunii pe computer, dar alte legături de limbaj sunt la fel de ușor de scris. Folosim pybind11 pentru a realiza acest lucru:

Începem prin a utiliza PYBIND11_MODULE macro care creează o funcție care va fi apelată atunci când este emisă o declarație de import din python. Deci, în exemplul de mai sus, numele modulului python este mysdk. Apoi, putem defini clasele noastre și membrii acestora folosind sintaxa pybind11.

Iată ceva de remarcat: în C ++, este destul de obișnuit să trimiți variabile folosind referințe mutabile care să permită accesul atât la citire cât și la scriere. Exact asta am făcut cu funcția noastră de membru API cu parametrii faceDetected și fbAndLandmarks. În Python, toate argumentele sunt transmise prin referință. Cu toate acestea, anumite tipuri de python de bază sunt imuabile, inclusiv bool. Întâmplător, parametrul nostru faceDetected este un bool care este trecut prin referință mutabilă. Prin urmare, trebuie să folosim soluția prezentată în codul de mai sus pe liniile 31 până la 34 unde definim boolul în funcția noastră de înfășurare python, apoi să-l transmitem funcției C ++ înainte de a returna variabila ca parte a unui tuplu.

Odată ce am construit biblioteca de legături python, o putem folosi cu ușurință folosind codul de mai jos:

Integrare continuă

Pentru conducta noastră de integrare continuă, vom folosi un instrument numit CircleCI care îmi place foarte mult pentru că se integrează direct cu Github. O nouă versiune va fi declanșată automat de fiecare dată când apăsați un commit. Pentru a începe, accesați site-ul CircleCI și conectați-l la contul dvs. Github, apoi selectați proiectul pe care doriți să îl adăugați. Odată adăugat, va trebui să creați un director .circleci la rădăcina proiectului dvs. și să creați un fișier numit config.yml în acel director.

Pentru oricine nu este familiar, YAML este un limbaj de serializare utilizat în mod obișnuit pentru fișierele de configurare. Îl putem folosi pentru a instrui ce operațiuni dorim să efectueze CircleCI. În fragmentul YAML de mai jos, puteți vedea cum construim mai întâi una dintre bibliotecile de dependență, apoi construim SDK-ul în sine și, în final, construim și rulăm testele unitare.

Dacă suntem inteligenți (și presupun că sunteți dacă ați ajuns până acum), putem folosi cache-ul pentru a reduce semnificativ timpii de construcție. De exemplu, în YAML de mai sus, memorăm în cache versiunea OpenCV folosind hash-ul scriptului de construire ca cheie cache. În acest fel, biblioteca OpenCV va fi reconstruită numai dacă scriptul de compilare a fost modificat – în caz contrar, va fi utilizată construcția cache. Un alt lucru de remarcat este că rulăm construirea în interiorul unei imagini de andocare la alegere. Am selectat o imagine de andocare personalizată ( aici este fișierul Docker) în care am instalat toate dependențele de sistem.

Fin.

Și acolo îl aveți. Ca orice produs bine conceput, vrem să sprijinim cele mai solicitate platforme și să îl facem ușor de utilizat pentru cel mai mare număr de dezvoltatori. Folosind tutorialul de mai sus, am creat un SDK accesibil în mai multe limbi și care poate fi implementat pe mai multe platforme. Și nici măcar nu a trebuit să citiți singur documentația pentru pybind11. Sper că ați găsit acest tutorial util și distractiv. Clădire fericită.

Lasă un răspuns

Adresa ta de email nu va fi publicată. Câmpurile obligatorii sunt marcate cu *