Hvordan lage en Language-Agnostic Cross-Platform Computer Vision SDK: A Hands-On Tutorial

(Cyrus Behroozi) (21. okt 2020)

Jeg hadde nylig muligheten til å presentere på Venice Computer Vision møte. Hvis du ikke er kjent, er det et arrangement sponset av Trueface der utviklere og entusiaster for datamaskinsyn kan presentere banebrytende datasynundersøkelser, applikasjoner og praktisk opplæringsprogrammer.

I denne artikkelen skal jeg gå gjennom opplæringspresentasjonen min om hvordan jeg designer et språk-agnostisk programvareutviklerpakke (SDK) for distribusjon på tvers av plattformer og maksimal utvidbarhet. Hvis du vil se liveopptak av presentasjonen, kan du gjøre det her . Jeg har også laget hele prosjektet åpen kildekode , så bruk den gjerne som en mal for ditt neste datasynprosjekt.

cyrusbehr / sdk_design

Hvordan lage en språkagnostisk SDK for distribusjon på tvers av plattformer og maksimal utvidelse. A Venice Computer Vision …

github.com

Hvorfor denne opplæringen gjelder

Etter min erfaring fant jeg aldri en altomfattende guide som oppsummerer alle relevante trinnene som trengs for å lage en språk-agnostisk, plattform-SDK. Jeg måtte kamme gjennom forskjellig dokumentasjon for akkurat de rette informasjonsbøkene, lære hver komponent separat, og deretter dele det hele sammen. Det var frustrerende. Det tok mye tid. Og nå kan du, kjære leser, dra nytte av alt arbeidet mitt. Fremover lærer du hvordan du bygger en språk-agnostisk, plattform-SDK. Alt det vesentlige er der. Ingen av lo, lagre noen memes. Kos deg.

I denne opplæringen kan du forvente å lære hvordan du:

  • Bygg et grunnleggende datasynbibliotek i C ++
  • Kompilere og krysse kompilere biblioteket for AMD64, ARM64 og ARM32
  • Pakk biblioteket og alle avhengigheter som et enkelt statisk bibliotek
  • Automatiser enhetstesting
  • Sett opp en kontinuerlig integrasjonsrørledning (CI)
  • Skriv pythonbindinger for biblioteket vårt
  • Generer dokumentasjon direkte fra API-en

Av hensyn til denne demoen, vi vil bygge et ansikts- og landemerkeoppdagings-SDK ved hjelp av en åpen kildekode-ansiktsdetektor kalt MTCNN.

Eksempel av ansiktsavgrensende bokser og ansikts landemerker

Vår API-funksjon tar en bildebane og returnerer deretter koordinatene til ansiktsavgrensningsboksen og ansikts landemerker. Evnen til å oppdage ansikter er veldig nyttig i datasyn, da det er det første trinnet i mange rørledninger, inkludert ansiktsgjenkjenning, aldersforutsigelse og automatisert uskarphet i ansiktet.

Merk: For dette tutorial vil jeg jobbe med Ubuntu 18.04.

Hvorfor bruke C ++ til biblioteket vårt?

Å kjøre effektiv C ++ -kode kan føles slik

Flertallet av biblioteket vårt blir skrevet i C ++, et kompilert og statisk skrevet språk. Det er ingen hemmelighet at C ++ er et veldig raskt programmeringsspråk; det er lavt nok til å gi oss den hastigheten vi ønsker, og har minimal ekstra kjøretid.

I datasynsapplikasjoner manipulerer vi generelt mange bilder, utfører matriseoperasjoner, kjører maskinlæringsinferanse, som alle involverer en enorm mengde databehandling. Utførelseshastighet er derfor kritisk. Dette er spesielt viktig i sanntidsapplikasjoner der du trenger å redusere ventetid for å oppnå ønsket bildefrekvens – ofte har vi bare millisekunder til å kjøre all koden vår.

En annen fordel med C ++ er at hvis vi kompilere for en viss arkitektur og koble alle avhengighetene statisk, så kan vi kjøre den på den maskinvaren uten å kreve ytterligere tolker eller biblioteker. Tro det eller ei, vi kan til og med kjøre på en innebygd bare metall-enhet uten operativsystem!

Katalogstruktur

Vi bruker følgende katalogstruktur for prosjektet vårt.

3rdparty inneholder tredjepartsavhengighetsbiblioteker som kreves av prosjektet vårt.

dist vil inneholde filene som distribueres til sluttbrukerne av SDK. I vårt tilfelle vil det være selve biblioteket, og den tilhørende toppfilen.

docker vil inneholde dockerfilen som skal brukes til å generere et dockerbilde for CI bygger.

docs inneholder byggeskriptene som kreves for å generere dokumentasjon direkte fra overskriftsfilen vår.

include vil inneholde alle inkluderingsfiler for det offentlige API-et.

models vil inneholde dyp læringsmodellfiler for ansiktsgjenkjenning.

python vil inneholde koden som kreves for å generere pythonbindinger.

src vil inneholde eventuelle cpp-filer som skal kompileres, og også eventuelle headerfiler som ikke distribueres med SDK (interne headerfiler).

test vil inneholde enhetstester.

tools vil inneholde CMake-verktøykjedefilene våre som kreves for krysskompilering.

Installere avhengighetsbibliotekene

For dette prosjektet er tredjepart avhengighetsbiblioteker som kreves er ncnn , et lettvektig maskinlæringsinferensebibliotek, OpenCV , et bildeforstørrelsesbibliotek, Catch2 , et enhetstestbibliotek, og til slutt pybind11 , et bibliotek brukes til å generere pythonbindinger. De to første bibliotekene må settes sammen som frittstående biblioteker, mens de to sistnevnte kun er header, og derfor krever vi bare kilden.

En måte å legge til disse bibliotekene i prosjektene våre er via git-undermoduler. . Selv om denne tilnærmingen fungerer, er jeg personlig tilhenger av å bruke skallskript som trekker kildekoden og bygger for de ønskede plattformene: i vårt tilfelle AMD64, ARM32 og ARM64.

Her er et eksempel på hva en av disse byggeskriptene ser ut som:

Skriptet er ganske greit. Det starter med å hente ønsket frigjøringskildekode fra git-depotet. Deretter brukes CMake til å forberede build, og deretter påkalles make for å drive kompilatoren til å bygge kildekoden.

Det du vil legge merke til er at hovedforskjellen mellom AMD64 build og ARM builds er at ARM-byggene sender en ekstra CMake-parameter kalt CMAKE_TOOLCHAIN_FILE. Dette argumentet brukes til å spesifisere for CMake at bygningsmålarkitekturen (ARM32 ellerARM64) er forskjellig fra vertsarkitekturen (AMD64 / x86_64). CMake blir derfor bedt om å bruke krysskompilatoren som er spesifisert i den valgte verktøykjedefilen for å bygge biblioteket (mer om verktøykjedefiler senere i denne opplæringen). For at dette skallskriptet skal fungere, må du ha de aktuelle krysscompilatorene installert på Ubuntu-maskinen din. Disse kan enkelt installeres ved hjelp av apt-get og instruksjoner om hvordan du gjør det vises her .

Biblioteks-API-et vårt

Biblioteket-API-et vårt ser slik ut:

Siden jeg er superkreativ, bestemte jeg meg for å kalle SDK MySDK. I API-et vårt har vi et nummer kalt ErrorCode, vi har en struktur som heter Point, og til slutt har vi en offentlig medlemsfunksjon kalt getFaceBoxAndLandmarks. For omfanget av denne veiledningen vil jeg ikke gå inn på detaljer om implementeringen av SDK. Kjernen er at vi leser bildet i minnet ved hjelp av OpenCV og deretter utfører maskinlæringsinferanse ved hjelp av ncnn med open source-modeller for å oppdage ansiktsavgrensningsboksen og landemerker. Hvis du vil dykke ned i implementeringen, kan du gjøre det her .

Det jeg vil at du skal være oppmerksom på, er imidlertid designmønster vi bruker. Vi bruker en teknikk som kalles Pointer to implementation, eller pImpl for kort, som i utgangspunktet fjerner implementeringsdetaljene til en klasse ved å plassere dem i en egen klasse. I koden ovenfor oppnås dette ved å videresende erklæringen Impl, og deretter ha en unique_ptr til denne klassen som en privat medlemsvariabel. Når vi gjør det, skjuler vi ikke bare implementeringen fra sluttbrukerens nysgjerrige øyne (som kan være ganske viktig i en kommersiell SDK), men vi reduserer også antall overskrifter API-overskriften vår er avhengig av (og dermed forhindrer API-overskrift fra #include ing avhengighetsbibliotekets overskrifter).

En merknad om modellfiler

Jeg sa at vi ikke kom til å gå over detaljene i implementeringen, men det er noe jeg synes det er verdt å nevne. Som standard laster åpen kildekode-ansiktsdetektor vi bruker, kalt MTCNN, maskinlæringsmodellfiler ved kjøretid. Dette er ikke ideelt fordi det betyr at vi må distribuere modellene til sluttbrukeren. Dette problemet er enda viktigere med kommersielle modeller der du ikke vil at brukerne skal ha fri tilgang til disse modellfilene (tenk på de utallige timene som gikk til opplæring av disse modellene). En løsning er å kryptere filene til disse modellene, noe jeg absolutt anbefaler å gjøre.Dette betyr likevel at vi må sende modellfilene sammen med SDK. Til slutt vil vi redusere antall filer vi sender en bruker for å gjøre det lettere for dem å bruke programvaren vår (færre filer tilsvarer færre steder å gå galt). Vi kan derfor bruke metoden vist nedenfor for å konvertere modellfilene til headerfiler og faktisk legge dem inn i selve SDK.

xdd bash-kommandoen brukes til å generere hex-dumper og kan brukes til å generere en headerfil fra en binær fil. Vi kan derfor inkludere modellfilene i koden vår som vanlige headerfiler og laste dem direkte fra minnet. En begrensning av denne tilnærmingen er at den ikke er praktisk med veldig store modellfiler, da den bruker for mye minne på kompileringstidspunktet. I stedet kan du bruke et verktøy som ld for å konvertere disse store modellfilene direkte til objektfiler.

CMake og kompilere biblioteket vårt

Vi kan nå bruke CMake til å generere build-filene for prosjektet vårt. Hvis du ikke er kjent, er CMake en byggesystemgenerator som brukes til å administrere byggeprosessen. Nedenfor ser du hvordan delen av roten CMakeLists.txt (CMake-fil) ser ut.

I utgangspunktet lager vi et statisk bibliotek kalt my_sdk_static med de to kildefilene som inneholder implementeringen vår, my_sdk.cpp og mtcnn.cpp. Grunnen til at vi lager et statisk bibliotek er at det etter min erfaring er lettere å distribuere et statisk bibliotek til brukerne, og det er mer vennlig mot innebygde enheter. Som en nevnt ovenfor, hvis en kjørbar fil er koblet mot et statisk bibliotek, kan den kjøres på en innebygd enhet som ikke en gang har et operativsystem. Dette er ganske enkelt ikke mulig med et dynamisk bibliotek. I tillegg, med dynamiske biblioteker, må vi bekymre oss for avhengighetsversjoner. Vi trenger til og med en manifestfil tilknyttet biblioteket vårt. Statisk koblede biblioteker har også en litt bedre ytelsesprofil enn deres dynamiske kolleger.

Den neste tingen vi gjør i CMake-skriptet vårt, er å fortelle CMake hvor du finner de nødvendige, inkluderer toppfiler som kildefilene krever. Noe å merke seg: selv om biblioteket vårt vil kompilere på dette punktet, når vi prøver å koble til biblioteket vårt (med for eksempel en kjørbar fil), vil vi få et absolutt tonn med udefinert referanse til symbolfeil. Dette fordi vi ikke har koblet noen avhengighetsbibliotek. Så hvis vi vellykket å koble en kjørbar fil til libmy_sdk_static.a, må vi også spore opp og koble alle avhengighetsbibliotekene (OpenCV-moduler, ncnn osv.). I motsetning til dynamiske biblioteker, kan ikke statiske biblioteker løse sine egne avhengigheter. De er i utgangspunktet bare en samling av objektfiler pakket i et arkiv.

Senere i denne opplæringen vil jeg demonstrere hvordan vi kan pakke alle avhengighetsbibliotekene inn i vårt statiske bibliotek, slik at brukeren ikke trenger å bekymre deg for å koble deg mot noen av avhengighetsbibliotekene.

Krysskompilering av biblioteks- og verktøykjedefiler

Edge computing er så … kantete

Mange applikasjoner for datasyn er distribuert i utkanten. Dette generelt innebærer å kjøre koden på innebygde enheter med lav effekt som vanligvis har ARM-prosessorer. Siden C ++ er et kompilert språk, må vi kompilere koden vår for CPU-arkitekturen som applikasjonen skal kjøres på (hver arkitektur bruker forskjellige monteringsanvisninger).

Før vi dykker inn i den, la oss også berøre forskjellen mellom ARM32 og ARM64, også kalt AArch32 og AArch64. AArch64 refererer til 64-biters utvidelse av ARM-arkitekturen og er både avhengig av CPU og operativsystem. Så for eksempel, selv om Raspberry Pi 4 har en 64 bit ARM-CPU, er standardoperativsystemet Raspbian 32 bit. Derfor krever en slik enhet en AArch32-kompilert binær. Hvis vi skulle kjøre et 64-biters operativsystem som Gentoo på denne Pi-enheten, ville vi kreve en AArch64-kompilert binær. Et annet eksempel på en populær innebygd enhet er NVIDIA Jetson som har en innebygd GPU og kjører AArch64.

For å krysskompilere, må vi spesifisere til CMake at vi ikke kompilerer for arkitekturen til maskin vi for tiden bygger på. Derfor må vi spesifisere tverrkompilatoren som CMake skal bruke. For AArch64 bruker vi aarch64-linux-gnu-g++ kompilatoren, og for AArch32 bruker vi arm-linux-gnuebhif-g++ kompilatoren (hf står for hard float ).

Følgende er et eksempel på en verktøykjedefil. Som du ser, spesifiserer vi å bruke AArch64 cross compiler.

Tilbake på vår rot CMakeLists.txt, kan vi legg til følgende kode øverst i filen.

I utgangspunktet legger vi til CMake-alternativer som kan aktiveres fra kommandolinjen for å krysskompilere. Hvis du aktiverer alternativene BUILD_ARM32 eller BUILD_ARM64, velges den riktige verktøykjedefilen og konfigurerer bygningen for en krysssamling.

Pakking av SDK med avhengighetsbiblioteker

Som nevnt tidligere, hvis en utvikler ønsker å koble mot biblioteket vårt på dette tidspunktet, må de også koble til alle avhengighetsbibliotekene for å løse alle symboler fra avhengighetsbibliotek. Selv om appen vår er ganske enkel, har vi allerede åtte avhengighetsbiblioteker! Den første er ncnn, så har vi tre OpenCV-modulbiblioteker, så har vi fire verktøybiblioteker som ble bygget med OpenCV (libjpeg, libpng, zlib, libtiff). Vi kan kreve at brukeren bygger avhengighetsbibliotekene selv eller til og med sender dem ved siden av biblioteket vårt, men til slutt tar det mer arbeid for brukeren, og vi handler om å senke barrieren for bruk. Den ideelle situasjonen er hvis vi kan sende brukeren et enkelt bibliotek som inneholder biblioteket vårt sammen med alle tredjepartsavhengighetsbibliotekene bortsett fra standard systembibliotek. Det viser seg at vi kan oppnå dette ved hjelp av litt CMake-magi.

Vi legger først til et tilpasset mål for vår CMakeLists.txt, og deretter utføre det som kalles et MR-skript. Dette MR-skriptet blir sendt til ar -M bash-kommandoen, som i utgangspunktet kombinerer alle de statiske bibliotekene i ett arkiv. Det som er kult med denne metoden er at den elegant vil håndtere overlappende medlemsnavn fra de originale arkivene, så vi trenger ikke bekymre oss for konflikter der. Å bygge dette tilpassede målet vil produsere libmy_sdk.a som vil inneholde SDK-en vår sammen med alle avhengighetsarkivene.

Vent et øyeblikk: La oss ta en oversikt over hva vi har gjort så langt.

Ta et pust. Ta en matbit. Ring moren din.

På dette tidspunktet har vi et statisk bibliotek kalt libmy_sdk.a som inneholder SDK og alle avhengighetsbibliotekene, som vi har pakket inn i ett arkiv. Vi har også muligheten til å kompilere og krysskompilere (ved hjelp av kommandolinjeargumenter) for alle våre målplattformer.

Enhetstester

Når du kjører enhetstestene for første gang

trenger jeg sannsynligvis ikke forklar hvorfor enhetstester er viktige, men i utgangspunktet er de en viktig del av SDK-design som gjør at utvikleren kan sikre at SDK fungerer som innrykket. I tillegg, hvis det gjøres noen endringer langs linjen, hjelper det å spore dem og skyve ut hurtigreparasjoner raskere.

I dette spesifikke tilfellet gir det oss også muligheten til å koble til en enhetstestkjørbar. kombinert bibliotek vi nettopp opprettet for å sikre at vi kan koble riktig slik det er tiltenkt (og vi får ikke noen av de stygge udefinerte referanse-til-symbolfeilene).

Vi bruker Catch2 som vårt enhetstestingsrammeverk . Syntaksen er skissert nedenfor:

Hvordan Catch2 fungerer er at vi har denne makroen kalt TEST_CASE og en annen makro kalt SECTION. For hver SECTION blir TEST_CASE utført fra starten. Så i vårt eksempel vil mySdk først initialiseres, deretter vil den første delen kalt «Non face image» kjøres. Deretter blir mySdk dekonstruert før den rekonstrueres, og deretter vil den andre delen med navnet «Ansikter i bildet» kjøre. Dette er flott fordi det sikrer at vi har et nytt MySDK objekt for å operere for hver seksjon. Vi kan da bruke makroer som REQUIRE for å gjøre våre påstander.

Vi kan bruke CMake til å bygge ut en enhetstestbar kjørbar kalt run_tests. Som vi kan se i kallet til target_link_libraries på linje 3 nedenfor, er det eneste biblioteket vi trenger å koble mot libmy_sdk.a og ingen andre avhengighetsbiblioteker.

Dokumentasjon

Hvis bare brukerne ville lese den jævla dokumentasjonen.

Vi vil bruke doxygen for å generere dokumentasjon direkte fra overskriftsfilen vår. Vi kan gå videre og dokumentere alle metodene og datatypene våre i den offentlige overskriften vår ved hjelp av syntaksen som vises i kodebiten nedenfor.Sørg for å spesifisere alle inngangs- og utgangsparametere for alle funksjoner.

For å faktisk generere dokumentasjon , trenger vi noe som kalles en doxyfile som i utgangspunktet er en blåkopi for å instruere doxygen hvordan man genererer dokumentasjonen. Vi kan generere en generisk doxyfile ved å kjøre doxygen -g i terminalen vår, forutsatt at du har doxygen installert på systemet ditt. Deretter kan vi redigere doksyfilen. Som et minimum må vi spesifisere utdatakatalogen og også inngangsfilene.

I vår i tilfelle, vil vi bare generere dokumentasjon fra API-headerfilen vår, og det er derfor vi har spesifisert inkluderer katalogen. Til slutt bruker du CMake til å faktisk lage dokumentasjonen, som kan gjøres slik .

Python Bindings

Er du lei av å se halvrelevante gifs ennå? Ja, jeg heller ikke.

La oss være ærlige. C ++ er ikke det enkleste eller mest vennlige språket å utvikle seg på. Derfor ønsker vi å utvide biblioteket vårt til å støtte språkbindinger for å gjøre det enklere å bruke for utviklere. Jeg skal demonstrere dette ved hjelp av python, da det er et populært språk for prototyping av datamaskinsyn, men andre språkbindinger er like enkle å skrive. Vi bruker pybind11 for å oppnå dette:

Vi starter med å bruke PYBIND11_MODULE makro som oppretter en funksjon som vil bli kalt når en importerklæring utstedes fra python. Så i eksemplet ovenfor er navnet på pythonmodulen mysdk. Deretter er vi i stand til å definere klassene våre og medlemmene deres ved hjelp av pybind11-syntaksen.

Her er noe å merke seg: I C ++ er det ganske vanlig å sende variabler ved hjelp av mutabel referanse som gir både lese- og skrivetilgang. Dette er nøyaktig hva vi har gjort med API-medlemsfunksjonen vår med parametrene faceDetected og fbAndLandmarks. I python sendes alle argumenter som referanse. Imidlertid er visse grunnleggende pythontyper uforanderlige, inkludert bool. Tilfeldigvis er vår faceDetected -parameter en bool som sendes med en mutbar referanse. Vi må derfor bruke løsningen vist i koden ovenfor på linjene 31 til 34 der vi definerer boolen i vår python-omslagsfunksjon, og deretter sende den til vår C ++ -funksjon før vi returnerer variabelen som en del av en tuple.

Når vi har bygget pythonbindingsbiblioteket, kan vi enkelt bruke det ved hjelp av koden nedenfor:

Kontinuerlig integrasjon

For vår kontinuerlige integrasjonsrørledning vil vi bruke et verktøy som heter CircleCI, som jeg virkelig liker fordi det integreres direkte med Github. En ny versjon vil automatisk bli utløst hver gang du skyver en forpliktelse. For å komme i gang, gå til CircleCI nettstedet og koble det til Github-kontoen din, og velg deretter prosjektet du vil legge til. Når du er lagt til, må du opprette en .circleci -katalog ved roten til prosjektet og opprette en fil som heter config.yml i den katalogen.

For alle som ikke er kjent, er YAML et serialisasjonsspråk som ofte brukes til konfigurasjonsfiler. Vi kan bruke den til å instruere hvilke operasjoner vi vil at CircleCI skal utføre. I YAML-utdraget nedenfor kan du se hvordan vi først bygger en av avhengighetsbibliotekene, deretter bygger vi selve SDK og til slutt bygger og kjører enhetstestene.

Hvis vi er intelligente (og jeg antar at du er hvis du har kommet dit så langt), kan vi bruke caching til å redusere byggetiden betydelig. For eksempel, i YAML ovenfor, cache vi OpenCV-bygningen ved hjelp av hash av build-skriptet som hurtignøkkel. På denne måten blir OpenCV-biblioteket bare gjenoppbygget hvis build-skriptet er endret – ellers blir bufret build brukt. En annen ting å merke seg er at vi kjører bygningen inne i et dockerbilde etter eget valg. Jeg har valgt et tilpasset dockerbilde ( her er Dockerfile) der jeg har installert alle systemavhengighetene.

Fin.

Og der har du det. Som ethvert godt designet produkt, ønsker vi å støtte de mest etterspurte plattformene og gjøre det enkelt å bruke for det største antallet utviklere. Ved hjelp av veiledningen ovenfor har vi bygget en SDK som er tilgjengelig på flere språk og kan distribueres på flere plattformer. Og du trengte ikke engang å lese dokumentasjonen for pybind11 selv. Jeg håper du har funnet denne veiledningen nyttig og underholdende. Lykkelig bygning.

Legg igjen en kommentar

Din e-postadresse vil ikke bli publisert. Obligatoriske felt er merket med *