5.1. Obiectul Image¶
Un algoritm de procesare a imaginilor parcurge o imagine pixel cu pixel. La fiecare poziție face ceva simplu – citește o valoare, o compară cu un prag, o combină cu pixelul corespunzător dintr-o a doua imagine, scrie un rezultat înapoi. Repetate de-a lungul unui cadru întreg, aceste decizii simple per-pixel sunt baza din care sunt construite detectarea de muchii, urmărirea blob-urilor, decodarea codurilor QR și orice altă tehnică clasică de viziune artificială. Pentru a face această muncă eficient, algoritmul trebuie să știe unde se află fiecare pixel în memorie, ce înseamnă de fapt valoarea fiecărui pixel și ce parte a imaginii ar trebui să examineze. image.Image este obiectul care organizează aceste informații.
Vision Sensors s-a încheiat în momentul în care csi.CSI.snapshot() returnează. Tot ceea ce a făcut mecanismul de pe partea camerei pentru a produce cadrul capturat este deja realizat; aplicația are Image în mână și trebuie să știe ce să facă cu el.
5.1.1. Tamponul și proprietățile sale¶
În interiorul Image se află un pointer către un bloc contiguu de octeți din RAM și un mic antet care poartă trei elemente de metadate: lățimea imaginii în pixeli, înălțimea sa în pixeli și formatul de pixeli în care se află octeții. Octeții sunt pixelii înșiși, stocați în ordine pe rânduri (row-major) – mai întâi toți pixelii rândului de sus, apoi toți cei ai rândului al doilea și așa mai departe până la cel de jos. Proprietățile descriu cum să fie citiți.
Lățimea și înălțimea sunt simple numărători întregi. Formatul de pixeli este proprietatea mai interesantă, deoarece stabilește câți octeți ocupă fiecare pixel și ce codifică acei octeți. O imagine în tonuri de gri poartă un octet per pixel, conținând o valoare de luminozitate. O imagine RGB565 poartă doi octeți per pixel, conținând câmpurile roșu, verde și albastru împachetate într-un cuvânt pe 16 biți. O imagine Bayer poartă un octet per pixel, dar fiecare pixel este eșantionat prin unul dintre cele trei filtre de culoare alese în funcție de poziția sa în mozaic. Vision Sensors a enumerat întregul catalog; ceea ce contează aici este că exact unul dintre aceste formate este setat pe fiecare Image, iar alegerea determină aritmetica octeților per pixel și semnificația oricărui octet din tampon.
Cu un pointer către tampon, lățimea, înălțimea și formatul, orice altă proprietate de care un algoritm ar putea avea nevoie rezultă printr-un scurt calcul. Octetul cu care începe pixelul (x, y) se află la decalajul (y * width + x) * bytes_per_pixel de la începutul tamponului. Numărul total de octeți este width * height * bytes_per_pixel. Adresa rândului următor de jos este exact la width * bytes_per_pixel octeți după începutul celui curent. Image expune cele trei proprietăți prin simple apeluri de metode – width(), height(), format() – plus dimensiunea derivată size prin size(). Metode din alte părți ale modulului folosesc aceste valori pentru a face ele însele aritmetica de decalaj; codul aplicației rareori trebuie să o facă.
Un Image este un mic înveliș Python care indică spre un bloc contiguu de memorie: un antet care poartă lățimea, înălțimea și formatul de pixeli, urmat de tamponul de pixeli propriu-zis.¶
5.1.2. De unde provine tamponul¶
Povestea implicită de-a lungul acestui capitol este cea pe care Vision Sensors a tratat-o deja: un cadru capturat sosește de la snapshot, octeții se află în tamponul de cadre al camerei, iar obiectul Image returnat indică spre ei. Alte trei modalități de a obține unul apar în mod regulat, iar fiecare implică ceva diferit despre locul în care ajunge tamponul.
Încărcarea dintr-un fișier arată ca transmiterea unei căi către constructor: image.Image("/sdcard/saved.jpg"). Modulul citește fișierul într-un tampon proaspăt alocat pe heap-ul Python. Fișierele BMP, PGM și PPM sunt decodate pe parcurs, iar Image rezultat poartă un format de pixeli necomprimat. Fișierele JPEG și PNG rămân comprimate – Image poartă formatul JPEG sau PNG, iar tamponul conține fluxul de octeți al fișierului practic neschimbat. Pentru a efectua orice lucrare la nivel de pixel pe o imagine comprimată, aplicația o convertește mai întâi prin to_rgb565() sau to_grayscale(), iar acea conversie este momentul în care decomprimarea – și balonarea corespunzătoare a heap-ului, unde un JPEG de 30 KB poate deveni 600 KB de RGB565 – se produce de fapt. Încărcarea din fișier este cel mai utilă în timpul dezvoltării, când un algoritm trebuie testat pe un cadru de referință cunoscut, stocat alături de script.
Construirea uneia de la zero este cazul pânzei de desen: image.Image(320, 240, image.RGB565) îi cere modulului să aloce acel număr de octeți în acel format, să golească conținutul și să returneze învelișul. Pixelii încă nu înseamnă nimic – sunt toți zero – dar imaginea goală este calul de povară pentru câteva tipare recurente: cadre de referință din care se scade un cadru curent, pânze pe care se compun suprapuneri grafice, tampoane binare care se completează și se folosesc ca măști.
Construirea dintr-un ndarray face puntea în cealaltă direcție, de la orice calcul numeric înapoi în modulul image. Transmiterea unui ulab.numpy.ndarray de tip float32 către constructor produce un Image ale cărui dimensiuni se potrivesc cu cele ale ndarray-ului – o formă cu două axe (h, w) devine o imagine în tonuri de gri, o formă cu trei axe (h, w, 3) devine RGB565 – cu valorile float scalate din intervalul 0.0 – 255.0 în intervalul de pixeli întregi. O hartă termică (heatmap) a unei rețele neuronale, un tablou numeric de orice fel, orice produs de ml sau ulab devine ceva pe care partea de desenare și inspecție a modulului image îl poate folosi.
Toate cele patru surse returnează același tip de Image. Codul care folosește obiectul returnat nu trebuie niciodată să țină evidența de unde a provenit.
5.1.3. Două vederi asupra octeților¶
De cele mai multe ori, codul aplicației tratează un Image ca pe un obiect imagine tipizat – un lucru cu metode denumite. Cealaltă jumătate a poveștii este că același obiect apare, de asemenea, în mod transparent, ca o secvență plată de octeți pentru orice API MicroPython care acceptă un argument bytes. Octeții nu sunt o copie a tamponului; sunt o vedere directă a acestuia.
Această aranjare este ceea ce face ca trimiterea unui cadru capturat din cameră să fie o singură linie de cod. Calcularea unui hash al acestuia, trimiterea sa printr-un port serial, redirecționarea sa către un socket de rețea – niciuna dintre acestea nu necesită un pas separat de „conversie a imaginii în octeți”:
import csi
import hashlib
csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QQVGA)
img = csi0.snapshot()
uart.write(img) # transmits the raw pixel bytes
hashlib.sha256(img) # hashes the same bytes
sock.send(img) # sends them over a socket
Vederea de tip bytes este doar pentru citire în mod implicit, în mod intenționat. Tampoanele de imagine sunt mari și uneori partajate între straturile stivei de imagistică, așa că a oferi unui simplu buf[0] = 0 aflat undeva adânc într-o stivă de apeluri puterea de a corupe pe tăcute unul dintre ele este o muchie prea ascuțită pentru a fi lăsată expusă. Atunci când accesul la nivel de octet în citire-scriere este ceea ce aplicația are nevoie cu adevărat – de exemplu, scrierea unei valori de calibrare la un decalaj cunoscut – bytearray() returnează o vedere separată, explicit pentru citire-scriere, asupra aceleiași memorii, semnalizând intenția la locul apelului.
5.1.4. Unde se află tamponul¶
Tampoanele de pixeli sunt suficient de mari încât locul în care se află în RAM contează. Un cadru QQVGA RGB565 are 160 × 120 × 2 = 38.400 de octeți; un cadru VGA RGB565 are 614.400 de octeți; o intrare RGB565 de 224 × 224 pe care un clasificator de tip rețea neuronală ar putea-o consuma are aproximativ 100 KB. Heap-ul Python de pe cele mai mici camere poate avea doar câteva zeci de kiloocteți după ce runtime-ul a pornit. Păstrarea a mai mult de unul sau două cadre de date de imagine pe heap ar înghesui totul restul de pe el.
Soluția este că tampoanele de imagine în mare parte nu se află pe heap-ul Python. Ele se află în regiunea dedicată a RAM-ului pe care Vision Sensors a introdus-o ca tampon de cadre (frame buffer) – aceeași memorie în care DMA-ul camerei scrie cadrele capturate și din care previzualizarea IDE-ului citește cadrele finalizate. Majoritatea operațiilor asupra unui Image își modifică sursa pe loc: algoritmul citește pixelii, decide, scrie noi valori înapoi și nu se alocă nicio imagine de rezultat separată. Operațiile care produc un rezultat separat – conversiile de format și o mână de altele – pot fi solicitate să plaseze acel rezultat în tamponul de cadre prin argumentul cu cuvânt-cheie copy_to_fb. copy_to_fb=True face două lucruri deodată: pune imaginea de rezultat în tamponul de cadre în loc de pe heap (evitând presiunea asupra heap-ului) și face ca rezultatul să fie următorul cadru pe care previzualizarea IDE-ului îl va afișa. Adăugarea lui copy_to_fb=True la pasul final al unui pipeline, urmărirea rezultatului care apare pe ecran și iterarea de acolo este unul dintre cele mai utile idiomuri de depanare în procesarea imaginilor.
Cu un înveliș care păstrează un tampon etichetat, patru moduri de a aduce unul în existență, două vederi asupra octeților săi și un comutator care decide unde aterizează cele noi, Image nu mai este un mister. Întrebările fundamentale rămase – cum este denumită o poziție de pixel, ce conține de fapt fiecare pixel, cum să restrângi o operație la o porțiune a uneia – sunt construite deasupra lui.