Sådan designer du en Language-Agnostic Cross-Platform Computer Vision SDK: A Hands-On Tutorial

(Cyrus Behroozi) (21. okt 2020)

Jeg har for nylig haft lejlighed til at præsentere på Venedig Computer Vision -mødet. Hvis du ikke er bekendt, er det en begivenhed sponsoreret af Trueface , hvor både computersynsudviklere og entusiaster kan fremvise avanceret computersynsforskning, applikationer og praktisk tutorials.

I denne artikel vil jeg gennemgå min tutorial-præsentation om, hvordan jeg designer et sprog-agnostisk software til softwareudvikling af software (SDK) til implementering på tværs af platforme og maksimal udvidelse. Hvis du gerne vil se liveoptagelse af præsentationen, kan du gøre det her . Jeg har også lavet hele projektet open source , så du er velkommen til at bruge det som en skabelon til dit næste computersynprojekt.

cyrusbehr / sdk_design

Sådan designes en sprogagnostisk SDK til implementering på tværs af platforme og maksimal udvidelse. En Venedigs computervision …

github.com

Hvorfor denne tutorial er vigtig

Efter min erfaring fandt jeg aldrig en altomfattende guide, der opsummerer alle de relevante trin, der er nødvendige for at oprette en sprog-agnostisk SDK på tværs af platforme. Jeg var nødt til at kæmpe gennem forskellig dokumentation for lige de rigtige bit af information, lære hver komponent separat og derefter stykkevis sammen det hele selv. Det var frustrerende. Det tog meget tid. Og nu får du, kære læser, fordel af alt mit arbejde. Fremad lærer du, hvordan du opbygger et sprog-agnostisk SDK på tværs af platforme. Alt det væsentlige er der. Intet af fnug, gem et par memer. Nyd.

I denne vejledning kan du forvente at lære at:

  • Bygge et grundlæggende computersynbibliotek i C ++
  • Kompilere og krydse kompilere biblioteket til AMD64, ARM64 og ARM32
  • Pakke biblioteket og alle afhængigheder som et enkelt statisk bibliotek
  • Automatiser enhedstest
  • Opret en kontinuerlig integration (CI) pipeline
  • Skriv pythonbindinger til vores bibliotek
  • Generer dokumentation direkte fra vores API

Af hensyn til denne demo, vi vil opbygge et ansigts- og milepælsdetekterings-SDK ved hjælp af en open source-ansigtsdetektor kaldet MTCNN.

Eksempel af ansigtsafgrænsende bokse og ansigtsmærker

Vores API-funktion tager en billedsti og returnerer derefter koordinaterne for ansigtsafgrænsningsfeltet og ansigtsmærker. Evnen til at registrere ansigter er meget nyttig i computersyn, da det er det første trin i mange rørledninger, herunder ansigtsgenkendelse, aldersforudsigelse og automatiseret sløring af ansigtet.

Bemærk: Til dette tutorial, jeg arbejder på Ubuntu 18.04.

Hvorfor bruge C ++ til vores bibliotek?

At køre effektiv C ++ – kode kan føles som dette

Størstedelen af ​​vores bibliotek vil blive skrevet i C ++, et kompileret og statisk skrevet sprog. Det er ingen hemmelighed, at C ++ er et meget hurtigt programmeringssprog; det er lavt niveau nok til at give os den hastighed, vi ønsker og har minimal tilføjet runtime overhead.

I computersynsapplikationer manipulerer vi generelt mange billeder, udfører matrixoperationer, kører maskinlæringsinferens, som alle involverer en enorm mængde computing. Udførelseshastighed er derfor kritisk. Dette er især vigtigt i realtidsapplikationer, hvor du skal reducere latenstiden for at opnå en ønsket billedhastighed – ofte har vi kun millisekunder til at køre al vores kode.

En anden fordel ved C ++ er, at hvis vi kompilere til en bestemt arkitektur og forbinde alle afhængigheder statisk, så kan vi køre den på den hardware uden at kræve yderligere tolke eller biblioteker. Tro det eller ej, vi kan endda køre på en integreret enhed med blåt metal uden operativsystem!

Katalogstruktur

Vi bruger følgende katalogstruktur til vores projekt.

3rdparty indeholder de tredjepartsafhængighedsbiblioteker, der kræves af vores projekt.

dist vil indeholde de filer, der distribueres til slutbrugerne af SDK. I vores tilfælde vil det være selve biblioteket og den tilknyttede headerfil.

docker vil indeholde docker-filen, der vil blive brugt til at generere et dockerbillede til CI bygger.

docs indeholder de build-scripts, der kræves for at generere dokumentation direkte fra vores headerfil.

include vil indeholde alle inkluderende filer til den offentlige API.

models vil indeholde ansigtsgenkendelse deep learning-modelfiler.

python indeholder den kode, der kræves for at generere pythonbindinger.

src vil indeholde alle cpp-filer, der vil blive kompileret, og også alle headerfiler, der ikke distribueres med SDK (interne headerfiler).

test indeholder vores enhedstest.

tools indeholder vores CMake-værktøjskædefiler, der kræves til krydskompilering.

Installation af afhængighedsbiblioteker

Til dette projekt er tredjepart afhængighedsbiblioteker, der kræves, er ncnn , et letvægtsbibliotek for maskinlæring, OpenCV , et billedforstørrelsesbibliotek, Catch2 , et enhedstestbibliotek og endelig pybind11 , et bibliotek bruges til at generere pythonbindinger. De to første biblioteker skal kompileres som selvstændige biblioteker, mens de to sidstnævnte kun er header, og derfor kræver vi kun kilden.

En måde at tilføje disse biblioteker til vores projekter på er via git-undermoduler . Selvom denne fremgangsmåde fungerer, er jeg personligt en fan af at bruge shell-scripts, der trækker kildekoden og derefter bygger til de ønskede platforme: i vores tilfælde AMD64, ARM32 og ARM64.

Her er et eksempel på, hvad en af disse build-scripts ser ud som:

Scriptet er ret ligetil. Det starter med at trække den ønskede frigivelseskildekode fra git-arkivet. Dernæst bruges CMake til at forberede build, og derefter kaldes make til at drive compileren til at opbygge kildekoden.

Hvad du vil bemærke er, at den største forskel mellem AMD64 build og ARM builds er, at ARM-buildene videregiver en ekstra CMake-parameter kaldet CMAKE_TOOLCHAIN_FILE. Dette argument bruges til at specificere til CMake, at build-målarkitekturen (ARM32 ellerARM64) er forskellig fra værtsarkitekturen (AMD64 / x86_64). CMake instrueres derfor i at bruge den krydscompiler, der er angivet i den valgte værktøjskædefil til at opbygge biblioteket (mere om værktøjskædefiler senere i denne vejledning). For at dette shell-script skal fungere, skal du have de relevante cross compilers installeret på din Ubuntu-maskine. Disse kan let installeres ved hjælp af apt-get og instruktioner om, hvordan du gør det, vises her .

Vores biblioteks-API

Vores biblioteks-API ser sådan ud:

Da jeg er superkreativ, besluttede jeg at navngive min SDK MySDK. I vores API har vi et enum kaldet ErrorCode, vi har en struktur kaldet Point, og til sidst har vi en offentlig medlemsfunktion kaldet getFaceBoxAndLandmarks. For omfanget af denne vejledning vil jeg ikke gå i detaljer med implementeringen af ​​SDK. Kernen er, at vi læser billedet ind i hukommelsen ved hjælp af OpenCV og derefter udfører maskinlæringsinferens ved hjælp af ncnn med open source-modeller for at detektere ansigtsgrænsefeltet og landemærker. Hvis du gerne vil dykke ned i implementeringen, kan du gøre det her .

Det jeg vil have dig til at være opmærksom på er dog design mønster, vi bruger. Vi bruger en teknik kaldet Pointer til implementering, eller pImpl for kort, som grundlæggende fjerner implementeringsoplysningerne for en klasse ved at placere dem i en separat klasse. I koden ovenfor opnås dette ved at videresende erklæringen Impl klassen og derefter have en unique_ptr til denne klasse som en privat medlemsvariabel. Ved at gøre dette skjuler vi ikke kun implementeringen fra slutbrugerens nysgerrige øjne (hvilket kan være ret vigtigt i en kommerciel SDK), men vi reducerer også antallet af headere, vores API-header afhænger af (og dermed forhindrer vores API-header fra #include ing afhængighedsbibliotek overskrifter).

En note om modelfiler

Jeg sagde, at vi ikke ville gå over detaljerne i implementeringen, men der er noget, som jeg synes er værd at nævne. Som standard indlæser den open source ansigtsdetektor, vi bruger, kaldet MTCNN, maskinindlæringsmodelfiler ved kørsel. Dette er ikke ideelt, fordi det betyder, at vi bliver nødt til at distribuere modellerne til slutbrugeren. Dette problem er endnu mere vigtigt med kommercielle modeller, hvor du ikke ønsker, at brugerne skal have fri adgang til disse modelfiler (tænk på de utallige timer, der gik i at træne disse modeller). En løsning er at kryptere disse modellers filer, hvilket jeg absolut anbefaler at gøre.Dette betyder dog stadig, at vi skal sende modelfiler sammen med SDK. I sidste ende ønsker vi at reducere antallet af filer, vi sender en bruger for at gøre det lettere for dem at bruge vores software (færre filer svarer til færre steder at gå galt). Vi kan derfor bruge metoden vist nedenfor til at konvertere modelfiler til headerfiler og faktisk integrere dem i selve SDK.

xdd bash-kommandoen bruges til at generere hex-dumps og kan bruges til at generere en headerfil fra en binær fil. Vi kan derfor inkludere modelfiler i vores kode som normale headerfiler og indlæse dem direkte fra hukommelsen. En begrænsning af denne tilgang er, at den ikke er praktisk med meget store modelfiler, da den bruger for meget hukommelse på kompileringstidspunktet. I stedet kan du bruge et værktøj som ld til at konvertere disse store modelfiler direkte til objektfiler.

CMake og kompilere vores bibliotek

Vi kan nu bruge CMake til at generere build-filerne til vores projekt. Hvis du ikke er bekendt, er CMake en build-systemgenerator, der bruges til at styre byggeprocessen. Nedenfor ser du, hvilken del af roden CMakeLists.txt (CMake-fil) ser ud.

Grundlæggende opretter vi et statisk bibliotek kaldet my_sdk_static med de to kildefiler, der indeholder vores implementering, my_sdk.cpp og mtcnn.cpp. Årsagen til, at vi opretter et statisk bibliotek, er, at det efter min erfaring er lettere at distribuere et statisk bibliotek til brugerne, og det er mere venligt over for indlejrede enheder. Som jeg nævnte ovenfor, hvis en eksekverbar enhed er knyttet til et statisk bibliotek, kan den køres på en indlejret enhed, der ikke engang har et operativsystem. Dette er simpelthen ikke muligt med et dynamisk bibliotek. Derudover skal vi med dynamiske biblioteker bekymre sig om afhængighedsversioner. Vi har muligvis endda brug for en manifestfil tilknyttet vores bibliotek. Statisk linkede biblioteker har også en lidt bedre ydeevne profil end deres dynamiske modstykker.

Den næste ting, vi gør i vores CMake script, er at fortælle CMake, hvor de skal finde de nødvendige, inkluderer header filer, som vores kildefiler kræver. Noget at bemærke: selvom vores bibliotek vil kompilere på dette tidspunkt, når vi prøver at linke mod vores bibliotek (med f.eks. En eksekverbar fil), får vi et absolut ton udefineret henvisning til symbolfejl. Dette skyldes, at vi ikke har linket nogen af ​​vores afhængighedsbiblioteker. Så hvis vi ønskede at linke en eksekverbar med succes mod libmy_sdk_static.a, ville vi også skulle spore og linke alle afhængighedsbiblioteker (OpenCV-moduler, ncnn osv.). I modsætning til dynamiske biblioteker kan statiske biblioteker ikke løse deres egne afhængigheder. De er dybest set kun en samling af objektfiler pakket i et arkiv.

Senere i denne tutorial demonstrerer jeg, hvordan vi kan samle alle afhængighedsbiblioteker i vores statiske bibliotek, så brugeren ikke behøver at bekymre dig om at linke mod nogen af ​​afhængighedsbibliotekerne.

Kryds-kompilering af vores biblioteks- og værktøjskædefiler

Edge computing er så … kantet

Mange computersynsapplikationer er implementeret i udkanten. Dette generelt indebærer at køre koden på indbyggede enheder med lav effekt, som normalt har ARM-CPUer. Da C ++ er et kompileret sprog, skal vi kompilere vores kode til den CPU-arkitektur, som applikationen køres på (hver arkitektur bruger forskellige monteringsinstruktioner).

Før vi dykker ned i den, lad os også røre ved forskellen mellem ARM32 og ARM64, også kaldet AArch32 og AArch64. AArch64 henviser til 64-bit udvidelsen af ​​ARM-arkitekturen og er både CPU- og operativsystemafhængig. Så for eksempel, selvom Raspberry Pi 4 har en 64 bit ARM-CPU, er standardoperativsystemet Raspbian 32 bit. Derfor kræver en sådan enhed en AArch32-kompileret binær. Hvis vi skulle køre et 64-bit operativsystem som Gentoo på denne Pi-enhed, ville vi kræve en AArch64-kompileret binær. Et andet eksempel på en populær integreret enhed er NVIDIA Jetson, som har en indbygget GPU og kører AArch64.

For at krydskompilere er vi nødt til at specificere til CMake, at vi ikke kompilerer for arkitekturen i maskine, som vi i øjeblikket bygger på. Derfor er vi nødt til at specificere den krydskompilator, som CMake skal bruge. Til AArch64 bruger vi aarch64-linux-gnu-g++ kompilatoren, og til AArch32 bruger vi arm-linux-gnuebhif-g++ compileren (hf står for hard float ).

Det følgende er et eksempel på en værktøjskædefil. Som du kan se, specificerer vi at bruge AArch64 cross compiler.

Tilbage ved vores rod CMakeLists.txt kan vi tilføj følgende kode til toppen af ​​filen.

Grundlæggende tilføjer vi CMake-indstillinger, som kan aktiveres fra kommandolinjen for at krydskompilere. Aktivering af enten indstillingerne BUILD_ARM32 eller BUILD_ARM64 vælger den relevante værktøjskædefil og konfigurerer build til en krydssamling.

Emballering af vores SDK med afhængighedsbiblioteker

Som nævnt tidligere, hvis en udvikler ønsker at linke mod vores bibliotek på dette tidspunkt, skal de også linke mod alle afhængighedsbiblioteker for at løse alle symboler fra afhængighedsbiblioteker. Selvom vores app er ret enkel, har vi allerede otte afhængighedsbiblioteker! Den første er ncnn, så har vi tre OpenCV-modulbiblioteker, så har vi fire hjælpebiblioteker, der blev bygget med OpenCV (libjpeg, libpng, zlib, libtiff). Vi kan kræve, at brugeren selv bygger afhængighedsbibliotekerne eller endda sender dem sammen med vores bibliotek, men i sidste ende kræver det mere arbejde for brugeren, og vi handler om at sænke barrieren til brug. Den ideelle situation er, hvis vi kan sende brugeren et enkelt bibliotek, der indeholder vores bibliotek sammen med alle 3. parts afhængighedsbiblioteker bortset fra standardsystembibliotekerne. Det viser sig, at vi kan opnå dette ved hjælp af noget CMake-magi.

Vi tilføjer først et brugerdefineret mål til vores CMakeLists.txt, og udfør derefter det, der kaldes et MRI-script. Dette MR-script sendes til ar -M bash-kommandoen, som grundlæggende kombinerer alle de statiske biblioteker i et enkelt arkiv. Hvad der er sejt ved denne metode er, at den på en yndefuld måde vil håndtere overlappende medlemsnavne fra de originale arkiver, så vi behøver ikke bekymre os om konflikter der. Opbygning af dette brugerdefinerede mål vil producere libmy_sdk.a som indeholder vores SDK sammen med alle afhængighedsarkiver.

Hold et øjeblik: Lad os tage status over det, vi er gjort indtil videre.

Tag et åndedrag. Tag en snack. Ring til din mor.

På dette tidspunkt har vi et statisk bibliotek kaldet libmy_sdk.a, der indeholder vores SDK og alle afhængighedsbiblioteker, som vi har pakket i et enkelt arkiv. Vi har også evnen til at kompilere og krydskompilere (ved hjælp af kommandolinjeargumenter) til alle vores målplatforme.

Enhedstests

Når du kører dine enhedstest for første gang

behøver jeg sandsynligvis ikke forklare, hvorfor enhedstest er vigtige, men dybest set er de en vigtig del af SDK-design, der giver udvikleren mulighed for at sikre, at SDK fungerer som indrykket. Derudover, hvis der foretages brydende ændringer langs linjen, hjælper det med at spore dem og skubbe rettelser ud hurtigere.

I dette specifikke tilfælde giver oprettelse af en eksekverbar enhedstest os også en mulighed for at linke mod kombineret bibliotek, vi netop oprettede for at sikre, at vi kan linke korrekt som beregnet (og vi ikke får nogen af ​​de grimme udefinerede reference-til-symbolfejl).

Vi bruger Catch2 som vores enhedstestningsramme . Syntaksen er beskrevet nedenfor:

Hvordan Catch2 fungerer, er at vi har denne makro kaldet TEST_CASE og en anden makro kaldet SECTION. For hver SECTION udføres TEST_CASE fra starten. Så i vores eksempel initialiseres mySdk, og derefter køres det første afsnit med navnet “Non face image”. Derefter dekonstrueres mySdk, før de rekonstrueres, og derefter kører det andet afsnit med navnet “Ansigter i billedet”. Dette er fantastisk, fordi det sikrer, at vi har et nyt MySDK objekt til at operere for hver sektion. Vi kan derefter bruge makroer som REQUIRE til at komme med vores påstande.

Vi kan bruge CMake til at opbygge en enhedstest eksekverbar kaldet run_tests. Som vi kan se i opkaldet til target_link_libraries på linje 3 nedenfor, er det eneste bibliotek, vi har brug for at linke mod, vores libmy_sdk.a og ingen andre afhængighedsbiblioteker.

Dokumentation

Hvis kun brugerne læser den forbandede dokumentation.

Vi bruger doxygen for at generere dokumentation direkte fra vores headerfil. Vi kan gå videre og dokumentere alle vores metoder og datatyper i vores offentlige overskrift ved hjælp af syntaksen vist i kodestykket nedenfor.Sørg for at angive alle input- og outputparametre for alle funktioner.

For faktisk at generere dokumentation , vi har brug for noget, der kaldes en doxyfile, som grundlæggende er en plan for at instruere doxygen, hvordan man genererer dokumentationen. Vi kan generere en generisk doxyfile ved at køre doxygen -g i vores terminal, forudsat at du har doxygen installeret på dit system. Dernæst kan vi redigere doxyfile. Som et minimum skal vi specificere outputmappen og også inputfilerne.

I vores tilfælde, vi ønsker kun at generere dokumentation fra vores API-headerfil, hvorfor vi har specificeret include-kataloget. Endelig bruger du CMake til faktisk at opbygge dokumentationen, hvilket kan gøres sådan .

Python-bindinger

Er du træt af at se halvrelevante gifs endnu? Ja, heller ikke mig.

Lad os være ærlige. C ++ er ikke det nemmeste eller mest venlige sprog at udvikle sig på. Derfor vil vi udvide vores bibliotek til at understøtte sprogbindinger for at gøre det lettere at bruge for udviklere. Jeg demonstrerer dette ved hjælp af python, da det er et populært prototypesprog til computersyn, men andre sprogbindinger er lige så nemme at skrive. Vi bruger pybind11 til at opnå dette:

Vi starter med at bruge PYBIND11_MODULE makro, der opretter en funktion, der kaldes, når der udstedes en importerklæring fra python. Så i eksemplet ovenfor er python-modulnavnet mysdk. Dernæst er vi i stand til at definere vores klasser og deres medlemmer ved hjælp af pybind11-syntaksen.

Her er noget at bemærke: I C ++ er det ret almindeligt at videregive variabler ved hjælp af mutabel reference, der giver både læse- og skriveadgang. Dette er præcis, hvad vi har gjort med vores API-medlemsfunktion med parametrene faceDetected og fbAndLandmarks. I python sendes alle argumenter som reference. Visse grundlæggende pythontyper er imidlertid uforanderlige, inklusive bool. Tilfældigt er vores faceDetected -parameter en bool, der sendes med mutabel reference. Vi skal derfor bruge den løsning, der er vist i koden ovenfor på linier 31 til 34, hvor vi definerer bool inden for vores pythonindpakningsfunktion og derefter sende den til vores C ++ – funktion, før vi returnerer variablen som en del af en tuple. > Når vi har bygget pythonbindingsbiblioteket, kan vi nemt bruge det ved hjælp af koden nedenfor:

Kontinuerlig integration

Til vores kontinuerlige integrationspipeline bruger vi et værktøj kaldet CircleCI, som jeg virkelig kan lide, fordi det integreres direkte med Github. En ny build udløses automatisk hver gang du trykker på en forpligtelse. For at komme i gang skal du gå til CircleCI webstedet og oprette forbindelse til din Github-konto og derefter vælge det projekt, du vil tilføje. Når du er tilføjet, skal du oprette en .circleci -mappe ved roden af ​​dit projekt og oprette en fil, der hedder config.yml i den mappe.

For alle, der ikke er bekendt, er YAML et serialiseringssprog, der ofte bruges til konfigurationsfiler. Vi kan bruge den til at instruere, hvilke operationer vi ønsker, at CircleCI skal udføre. I YAML-uddraget nedenfor kan du se, hvordan vi først bygger et af afhængighedsbibliotekerne, derefter bygger vi selve SDK og endelig bygger og kører enhedstestene.

Hvis vi er intelligente (og jeg antager, at du er, hvis du har nået det så langt), kan vi bruge caching til at reducere byggetiderne betydeligt. For eksempel cache vi i YAML ovenfor OpenCV-build ved hjælp af hash af build-scriptet som cache-nøgle. På denne måde genopbygges OpenCV-biblioteket kun, hvis build-scriptet er blevet ændret – ellers bruges den cachelagrede build. En anden ting at bemærke er, at vi kører bygningen inde i et dockerbillede efter eget valg. Jeg har valgt et brugerdefineret dockerbillede ( her er Dockerfile), hvor jeg har installeret alle systemafhængigheder.

Fin.

Og der har du det. Som ethvert veldesignet produkt ønsker vi at støtte de mest efterspurgte platforme og gøre det let at bruge for det største antal udviklere. Ved hjælp af tutorialen ovenfor har vi bygget en SDK, der er tilgængelig på flere sprog og kan implementeres på tværs af flere platforme. Og du behøvede ikke engang selv at læse dokumentationen til pybind11. Jeg håber, du har fundet denne tutorial nyttig og underholdende. Glad bygning.

Skriv et svar

Din e-mailadresse vil ikke blive publiceret. Krævede felter er markeret med *