Hur man utformar en Language-Agnostic Cross-Platform Computer Vision SDK: A Hands-On Tutorial

(Cyrus Behroozi) (21 okt 2020)

Jag fick nyligen möjlighet att presentera vid mötet Venedigs datorvision . Om du inte är bekant är det ett evenemang sponsrat av Trueface där både datorsynutvecklare och entusiaster kan visa upp avancerad datorsynforskning, applikationer och praktisk tutorials.

I den här artikeln kommer jag att gå igenom min självstudiepresentation om hur man utformar ett språkagnostiskt utvecklingspaket för datorvisionsprogramvara (SDK) för distribution över flera plattformar och maximal töjbarhet. Om du vill se liveinspelning av presentationen kan du göra det här . Jag har också gjort hela projektet öppen källkod , så använd gärna det som en mall för ditt nästa datorvisionsprojekt.

cyrusbehr / sdk_design

Hur man utformar en språkagnostisk SDK för distribution över flera plattformar och maximal töjbarhet. En Venedigs datorvision …

github.com

Varför det här självstudiet spelar roll

Enligt min erfarenhet hittade jag aldrig en heltäckande guide som sammanfattar alla relevanta steg som behövs för att skapa ett språkagnostiskt, plattforms-SDK. Jag var tvungen att kamma igenom olika dokumentation för precis rätt bitar av information, lära mig varje komponent separat och sedan bitvis samman allt själv. Det var frustrerande. Det tog mycket tid. Och nu kan du, kära läsare, dra nytta av allt mitt arbete. Framåt lär du dig hur du bygger en språkagnostisk, plattforms-SDK. Allt det väsentliga finns där. Inget av fluffen, spara några memer. Njut av.

I denna handledning kan du förvänta dig att lära dig att:

  • Bygga ett grundläggande datorvisionsbibliotek i C ++
  • Kompilera och korsa kompilera biblioteket för AMD64, ARM64 och ARM32
  • Paketera biblioteket och alla beroenden som ett enda statiskt bibliotek
  • Automatisera enhetstest
  • Ställ in en kontinuerlig integration (CI) pipeline
  • Skriv pythonbindningar för vårt bibliotek
  • Skapa dokumentation direkt från vårt API

För denna demo, vi bygger ett SDK för ansikts- och landmärkesdetektering med en öppen källkods ansiktsdetektor som heter MTCNN.

Exempel av ansiktsavgränsande rutor och ansiktsmärken

Vår API-funktion tar en bildsökväg och returnerar sedan koordinaterna för ansiktsavgränsningsrutan och ansiktslandmärken. Förmågan att upptäcka ansikten är mycket användbar vid datorvision eftersom det är det första steget i många rörledningar inklusive ansiktsigenkänning, åldersförutsägelse och automatiserad suddig ansikte.

Obs: För detta tutorial, jag kommer att arbeta med Ubuntu 18.04.

Varför använda C ++ för vårt bibliotek?

Att köra effektiv C ++ – kod kan kännas så här

Majoriteten av vårt bibliotek kommer att skrivas i C ++, ett sammanställt och statiskt skrivet språk. Det är ingen hemlighet att C ++ är ett mycket snabbt programmeringsspråk; det är tillräckligt låg nivå för att ge oss den hastighet vi önskar och har minimal extra körtidskostnad.

I datorsynstillämpningar manipulerar vi i allmänhet många bilder, utför matrisoperationer, kör maskininlärningsinferens, som alla involverar en enorm mängd datorer. Körningshastigheten är därför kritisk. Detta är särskilt viktigt i realtidsapplikationer där du behöver minska latensen för att uppnå önskad bildhastighet – ofta har vi bara millisekunder för att köra all vår kod.

En annan fördel med C ++ är att om vi kompilera för en viss arkitektur och länka alla beroenden statiskt, då kan vi köra den på den hårdvaran utan att behöva ytterligare tolkar eller bibliotek. Tro det eller ej, vi kan till och med köra på en inbyggd enhet utan metall operativsystem!

Katalogstruktur

Vi kommer att använda följande katalogstruktur för vårt projekt.

3rdparty kommer att innehålla de tredje parts beroendebibliotek som krävs av vårt projekt.

dist innehåller filerna som distribueras till SDK: s slutanvändare. I vårt fall kommer det att vara själva biblioteket och den tillhörande rubrikfilen.

docker kommer att innehålla dockerfilen som kommer att användas för att generera en dockerbild för CI-byggnader.

docs innehåller byggskript som krävs för att generera dokumentation direkt från vår rubrikfil.

include innehåller alla inkluderingsfiler för det offentliga API: et.

models kommer att innehålla ansiktsigenkänning djupinlärningsmodellfiler.

python innehåller koden som krävs för att generera pythonbindningar.

src innehåller alla cpp-filer som kommer att kompileras, och även alla rubrikfiler som inte kommer att distribueras med SDK (interna rubrikfiler).

test innehåller våra enhetstester.

tools kommer att innehålla våra CMake-verktygskedjefiler som krävs för tvärkompilering.

Installera beroendebiblioteken

För detta projekt, tredje part beroendebibliotek som krävs är ncnn , ett lättviktigt inlärningsbibliotek för maskininlärning, OpenCV , ett bildförstärkningsbibliotek, Catch2 , ett enhetstestbibliotek och slutligen pybind11 , ett bibliotek används för att generera pythonbindningar. De två första biblioteken kommer att behöva sammanställas som fristående bibliotek, medan de två senare endast är rubriker och därför behöver vi bara källan.

Ett sätt att lägga till dessa bibliotek i våra projekt är via git-undermoduler . Även om detta tillvägagångssätt fungerar är jag personligen ett fan av att använda skalskript som drar källkoden och sedan bygger för de önskade plattformarna: i vårt fall AMD64, ARM32 och ARM64.

Här är ett exempel på vad en av dessa byggskript ser ut som:

Skriptet är ganska enkelt. Det börjar med att dra önskad släppkällkod från gitförvaret. Därefter används CMake för att förbereda byggnaden, sedan anropas make för att driva kompilatorn för att bygga källkoden.

Vad du kommer att märka är att huvudskillnaden mellan AMD64-byggnaden och ARM-byggnaden är att ARM-byggnaderna skickar en ytterligare CMake-parameter som heter CMAKE_TOOLCHAIN_FILE. Detta argument används för att ange för CMake att byggnadsmålarkitekturen (ARM32 ellerARM64) skiljer sig från värdarkitekturen (AMD64 / x86_64). CMake instrueras därför att använda tvärkompilatorn som anges i den valda verktygskedjefilen för att bygga biblioteket (mer om verktygskedjefiler senare i den här självstudien). För att detta skalskript ska fungera måste du ha lämpliga tvärkompilatorer installerade på din Ubuntu-maskin. Dessa kan enkelt installeras med apt-get och instruktioner om hur du gör det visas här .

Vårt biblioteks-API

Vårt biblioteks-API ser ut så här:

Eftersom jag är superkreativ bestämde jag mig för att namnge min SDK MySDK. I vårt API har vi ett enum som heter ErrorCode, vi har en struktur som heter Point, och slutligen har vi en offentlig medlemsfunktion som heter getFaceBoxAndLandmarks. För omfattningen av denna handledning kommer jag inte att gå in på detaljer om implementeringen av SDK. Kärnan är att vi läser bilden i minnet med OpenCV och sedan utför maskininlärningsinferens med ncnn med öppen källkodsmodeller för att upptäcka ansiktsgränsruta och landmärken. Om du vill dyka in i implementeringen kan du göra det här .

Det jag vill att du ska vara uppmärksam på är dock designmönster vi använder. Vi använder en teknik som kallas Pekare till implementering, eller pImpl för kort, som i princip tar bort implementeringsdetaljerna för en klass genom att placera dem i en separat klass. I koden ovan uppnås detta genom att förklara Impl -klassen och sedan ha en unique_ptr till denna klass som en privat medlemsvariabel. På så sätt döljer vi inte bara implementeringen från slutanvändarens nyfikna ögon (vilket kan vara ganska viktigt i en kommersiell SDK), men vi minskar också antalet rubriker som vårt API-huvud beror på (och därmed förhindrar API-rubrik från #include beroendebiblioteksrubriker).

En anmärkning om modellfiler

Jag sa att vi inte skulle gå igenom detaljerna i genomförandet, men det finns något som jag tycker det är värt att nämna. Som standard laddar den öppna källkods ansiktsdetektorn vi använder, kallad MTCNN, maskininlärningsmodellfilerna under körning. Detta är inte perfekt eftersom det betyder att vi kommer att behöva distribuera modellerna till slutanvändaren. Det här problemet är ännu viktigare med kommersiella modeller där du inte vill att användare ska ha fri tillgång till dessa modellfiler (tänk på de otaliga timmarna som gick för att utbilda dessa modeller). En lösning är att kryptera dessa modellfiler, vilket jag absolut rekommenderar.Men det betyder fortfarande att vi måste skicka modellfilerna tillsammans med SDK. I slutändan vill vi minska antalet filer vi skickar en användare för att göra det lättare för dem att använda vår programvara (färre filer motsvarar färre platser att gå fel). Vi kan därför använda metoden som visas nedan för att konvertera modellfilerna till huvudfiler och faktiskt bädda in dem i själva SDK.

xdd bash-kommandot används för att generera hex-dumpningar och kan användas för att generera en rubrikfil från en binär fil. Vi kan därför inkludera modellfilerna i vår kod som vanliga rubrikfiler och ladda dem direkt från minnet. En begränsning av detta tillvägagångssätt är att det inte är praktiskt med mycket stora modellfiler eftersom det förbrukar för mycket minne vid kompileringstiden. Istället kan du använda ett verktyg som ld för att konvertera dessa stora modellfiler direkt till objektfiler.

CGör och kompilera vårt bibliotek

Vi kan nu använda CMake för att generera byggfilerna för vårt projekt. Om du inte är bekant är CMake en build-systemgenerator som används för att hantera byggprocessen. Nedan ser du vilken del av roten CMakeLists.txt (CMake-fil) ser ut.

I grund och botten skapar vi ett statiskt bibliotek som heter my_sdk_static med de två källfilerna som innehåller vår implementering, my_sdk.cpp och mtcnn.cpp. Anledningen till att vi skapar ett statiskt bibliotek är att det enligt min erfarenhet är lättare att distribuera ett statiskt bibliotek till användarna och det är mer vänligt gentemot inbäddade enheter. Som jag nämnde ovan, om en körbar länkas mot ett statiskt bibliotek, kan den köras på en inbäddad enhet som inte ens har ett operativsystem. Detta är helt enkelt inte möjligt med ett dynamiskt bibliotek. Dessutom, med dynamiska bibliotek, måste vi oroa oss för beroendeversioner. Vi kan till och med behöva en manifestfil associerad med vårt bibliotek. Statiskt länkade bibliotek har också en något bättre prestandaprofil än deras dynamiska motsvarigheter.

Nästa sak vi gör i vårt CMake-skript är att berätta för CMake var de ska hitta de nödvändiga inkluderar huvudfiler som våra källfiler kräver. Något att notera: även om vårt bibliotek kommer att kompilera vid denna tidpunkt, när vi försöker länka mot vårt bibliotek (med en exekverbar till exempel), kommer vi att få en absolut ton odefinierad hänvisning till symbolfel. Detta beror på att vi inte har länkat några av våra beroendebibliotek. Så om vi ville koppla en körbar framgångsrikt mot libmy_sdk_static.a, skulle vi behöva spåra och länka alla beroendebibliotek också (OpenCV-moduler, ncnn, etc). Till skillnad från dynamiska bibliotek kan statiska bibliotek inte lösa sina egna beroenden. De är i grund och botten bara en samling objektfiler som är förpackade i ett arkiv.

Senare i denna handledning kommer jag att visa hur vi kan bunta alla beroendebibliotek i vårt statiska bibliotek så att användaren inte behöver oroa dig för att länka mot något av beroendebiblioteken.

Tvärkompilering av våra biblioteks- och verktygsfiler

Edge computing är så … kantig

Många datorvisionsapplikationer distribueras vid kanten. Detta i allmänhet innebär att köra koden på inbyggda enheter med låg effekt som vanligtvis har ARM-processorer. Eftersom C ++ är ett sammanställt språk måste vi sammanställa vår kod för den CPU-arkitektur som applikationen ska köras på (varje arkitektur använder olika monteringsanvisningar).

Innan vi dyker in i det, låt oss också peka på skillnaden mellan ARM32 och ARM64, även kallad AArch32 och AArch64. AArch64 refererar till 64-bitars förlängning av ARM-arkitekturen och är både CPU- och operativsystemberoende. Så till exempel, även om Raspberry Pi 4 har en 64-bitars ARM-processor, är standardoperativsystemet Raspbian 32 bitar. Därför kräver en sådan enhet en AArch32-kompilerad binär. Om vi ​​skulle köra ett 64-bitars operativsystem som Gentoo på denna Pi-enhet, skulle vi kräva en AArch64 kompilerad binär. Ett annat exempel på en populär inbäddad enhet är NVIDIA Jetson som har en inbyggd GPU och kör AArch64.

För att korskompilera måste vi ange för CMake att vi inte kompilerar för arkitekturen i maskin vi för närvarande bygger på. Därför måste vi ange tvärkompilatorn som CMake ska använda. För AArch64 använder vi aarch64-linux-gnu-g++ kompilatorn, och för AArch32 använder vi arm-linux-gnuebhif-g++ kompilatorn (hf står för hard float ).

Följande är ett exempel på en verktygskedjefil. Som du kan se specificerar vi att använda AArch64 cross compiler.

Tillbaka vid vår rot CMakeLists.txt kan vi lägg till följande kod överst i filen.

I grund och botten lägger vi till CMake-alternativ som kan aktiveras från kommandoraden för att korskompilera. Om du aktiverar antingen BUILD_ARM32 eller BUILD_ARM64 väljer du lämplig verktygskedjefil och konfigurerar byggnaden för en tvärkompilering.

Förpackning av vårt SDK med beroendebibliotek

Som nämnts tidigare, om en utvecklare vill länka mot vårt bibliotek vid denna tidpunkt, måste de också länka mot alla beroendebibliotek för att kunna lösa alla symboler från beroendebibliotek. Även om vår app är ganska enkel har vi redan åtta beroendebibliotek! Den första är ncnn, sedan har vi tre OpenCV-modulbibliotek, sedan har vi fyra verktygsbibliotek som byggdes med OpenCV (libjpeg, libpng, zlib, libtiff). Vi kan kräva att användaren bygger beroendebiblioteken själva eller till och med skickar dem tillsammans med vårt bibliotek, men i slutändan tar det mer arbete för användaren och vi handlar om att sänka barriären för användning. Den ideala situationen är om vi kan skicka användaren ett enda bibliotek som innehåller vårt bibliotek tillsammans med alla andra beroendebibliotek från tredje part än standardsystembiblioteken. Det visar sig att vi kan uppnå detta med lite CMake-magi.

Vi lägger först till ett anpassat mål för vår CMakeLists.txt, kör sedan det som kallas ett MR-skript. Detta MR-skript skickas till ar -M bash-kommandot, som i grunden kombinerar alla statiska bibliotek till ett enda arkiv. Vad som är coolt med den här metoden är att den på ett elegant sätt kommer att hantera överlappande medlemsnamn från originalarkivet, så vi behöver inte oroa oss för konflikter där. Att bygga detta anpassade mål producerar libmy_sdk.a som kommer att innehålla vårt SDK tillsammans med alla beroendearkiv.

Vänta en stund: Låt oss göra en översikt över vad vi har gjort hittills.

Andas. Ta ett mellanmål. Ring din mamma.

Vid denna tidpunkt har vi ett statiskt bibliotek som heter libmy_sdk.a som innehåller vår SDK och alla beroendebibliotek som vi har packat i ett enda arkiv. Vi har också förmågan att kompilera och korskompilera (med hjälp av kommandoradsargument) för alla våra målplattformar.

Enhetstester

När du kör enhetstesterna för första gången

behöver jag antagligen inte förklara varför enhetstester är viktiga, men i grund och botten är de en viktig del av SDK-design som gör det möjligt för utvecklaren att säkerställa att SDK fungerar som indrag. Dessutom, om några brytande ändringar görs längs linjen, hjälper det att spåra dem och trycka ut korrigeringar snabbare.

I detta specifika fall skapar vi en enhetstestkörbarhet också en möjlighet att länka mot kombinerat bibliotek som vi just skapat för att säkerställa att vi kan länka korrekt som avsett (och vi får inte några av de otäcka odefinierade referens-till-symbolfelen).

Vi använder Catch2 som vår enhetstestram . Syntaxen beskrivs nedan:

Hur Catch2 fungerar är att vi har detta makro som heter TEST_CASE och ett annat makro som heter SECTION. För varje SECTION körs TEST_CASE från början. Så i vårt exempel initialiseras mySdk och sedan körs det första avsnittet ”Non face image”. Därefter kommer mySdk att dekonstrueras innan de rekonstrueras. Därefter körs det andra avsnittet ”Ansikten i bilden”. Det här är bra eftersom det säkerställer att vi har ett nytt MySDK -objekt att arbeta med för varje avsnitt. Vi kan sedan använda makron som REQUIRE för att göra våra påståenden.

Vi kan använda CMake för att bygga ut en enhetstestbar körning som heter run_tests. Som vi kan se i samtalet till target_link_libraries på rad 3 nedan är det enda biblioteket vi behöver länka mot vår libmy_sdk.a och ingen annan beroendebibliotek.

Dokumentation

Om bara användarna skulle läsa den jävla dokumentationen.

Vi använder doxygen för att generera dokumentation direkt från vår rubrikfil. Vi kan gå vidare och dokumentera alla våra metoder och datatyper i vårt offentliga rubrik med syntaxen som visas i kodavsnittet nedan.Var noga med att ange alla in- och utmatningsparametrar för alla funktioner.

För att faktiskt generera dokumentation , vi behöver något som kallas en doxyfile som i grunden är en ritning för att instruera doxygen hur man genererar dokumentationen. Vi kan skapa en generisk doxyfil genom att köra doxygen -g i vår terminal, förutsatt att du har doxygen installerat på ditt system. Därefter kan vi redigera doxyfilen. Vi måste åtminstone ange utdatakatalogen och även inmatningsfilerna.

I vår fall, vi vill bara generera dokumentation från vår API-huvudfil, varför vi har specificerat inkludera katalogen. Slutligen använder du CMake för att faktiskt bygga dokumentationen, vilket kan göras som så .

Pythonbindningar

Är du trött på att se halvrelevanta gifs än? Ja, jag inte heller.

Låt oss vara ärliga. C ++ är inte det enklaste eller vänligaste språket att utvecklas på. Därför vill vi utöka vårt bibliotek för att stödja språkbindningar för att göra det lättare att använda för utvecklare. Jag kommer att demonstrera detta med hjälp av python eftersom det är ett populärt datorsyn-prototypspråk, men andra språkbindningar är lika enkla att skriva. Vi använder pybind11 för att uppnå detta:

Vi börjar med att använda PYBIND11_MODULE makro som skapar en funktion som kommer att anropas när ett importuttal utfärdas från python. Så i exemplet ovan är namnet på pythonmodulen mysdk. Därefter kan vi definiera våra klasser och deras medlemmar med hjälp av pybind11-syntaxen.

Här är något att notera: I C ++ är det ganska vanligt att skicka variabler med mutabel referens som tillåter både läs- och skrivåtkomst. Detta är precis vad vi har gjort med vår API-medlemsfunktion med parametrarna faceDetected och fbAndLandmarks. I python skickas alla argument som referens. Men vissa grundläggande pythontyper är oföränderliga, inklusive bool. Tillfälligt är vår faceDetected -parameter en bool som skickas med en mutbar referens. Vi måste därför använda lösningen som visas i koden ovan på raderna 31 till 34 där vi definierar boolen i vår pythonomslagningsfunktion och sedan skickar den till vår C ++ -funktion innan vi returnerar variabeln som en del av en tuple.

När vi har byggt pythonbindningsbiblioteket kan vi enkelt använda det med koden nedan:

Kontinuerlig integration

För vår kontinuerliga integrationspipeline kommer vi att använda ett verktyg som heter CircleCI som jag verkligen gillar eftersom det integreras direkt med Github. En ny version aktiveras automatiskt varje gång du trycker på en commit. För att komma igång, gå till CircleCI webbplatsen och anslut den till ditt Github-konto och välj sedan det projekt du vill lägga till. När du väl har lagt till måste du skapa en .circleci -katalog vid roten till ditt projekt och skapa en fil som heter config.yml i den katalogen.

För alla som inte är bekanta är YAML ett serialiseringsspråk som vanligtvis används för konfigurationsfiler. Vi kan använda den för att instruera vilka operationer vi vill att CircleCI ska utföra. I YAML-utdraget nedan kan du se hur vi först bygger ett av beroendebiblioteken, sedan bygger vi själva SDK och slutligen bygger och kör enhetstesterna.

Om vi ​​är intelligenta (och jag antar att du är om du har nått det så långt) kan vi använda cachning för att avsevärt minska byggtiden. Till exempel, i YAML ovan cachar vi OpenCV-byggandet med hjälp av byggskriptets hash som cache-nyckel. På så sätt kommer OpenCV-biblioteket endast att byggas om om build-skriptet har modifierats – annars kommer den cachade byggnaden att användas. En annan sak att notera är att vi kör byggnaden inuti en dockerbild efter eget val. Jag har valt en anpassad dockningsbild ( här är Dockerfilen) där jag har installerat alla systemberoenden.

Fin.

Och där har du det. Liksom alla väldesignade produkter vill vi stödja de mest efterfrågade plattformarna och göra det enkelt att använda för det största antalet utvecklare. Med hjälp av handboken ovan har vi byggt en SDK som är tillgänglig på flera språk och kan distribueras över flera plattformar. Och du behövde inte ens själv läsa dokumentationen för pybind11. Jag hoppas att du har tyckt att den här handledningen är användbar och underhållande. Lycklig byggnad.

Lämna ett svar

Din e-postadress kommer inte publiceras. Obligatoriska fält är märkta *