5.1. Het Image-object

Een beeldverwerkingsalgoritme loopt pixel voor pixel over een afbeelding. Op elke positie doet het iets eenvoudigs – een waarde uitlezen, deze vergelijken met een drempelwaarde, deze combineren met de bijbehorende pixel van een tweede afbeelding, een resultaat terugschrijven. Herhaald over een heel frame zijn die eenvoudige beslissingen per pixel datgene waaruit randdetectie, blob-tracking, het decoderen van QR-codes en elke andere klassieke computer-vision-techniek is opgebouwd. Om dat werk efficiënt te doen, moet het algoritme weten waar elke pixel in het geheugen zit, wat de waarde van elke pixel daadwerkelijk betekent, en naar welk deel van de afbeelding het moet kijken. De image.Image is het object dat die informatie organiseert.

Vision Sensors eindigde op het moment dat csi.CSI.snapshot() terugkeert. Wat de machinerie aan de camerakant ook deed om het vastgelegde frame te produceren, is al gedaan; de applicatie heeft de Image in handen en moet weten wat ermee te doen.

5.1.1. De buffer en zijn eigenschappen

Binnen de Image bevindt zich een pointer naar een aaneengesloten blok bytes in RAM en een kleine header die drie stukjes metadata draagt: de breedte van de afbeelding in pixels, de hoogte in pixels, en het pixelformaat waarin de bytes staan. De bytes zijn de pixels zelf, opgeslagen in rij-eerst-volgorde – eerst alle pixels van de bovenste rij, dan alle van de tweede rij, enzovoort tot helemaal onderaan. De eigenschappen beschrijven hoe ze gelezen moeten worden.

Breedte en hoogte zijn gewone gehele aantallen. Het pixelformaat is de interessantere eigenschap, omdat het bepaalt hoeveel bytes elke pixel inneemt en wat die bytes coderen. Een grijswaarden-afbeelding draagt één byte per pixel met een helderheidswaarde. Een RGB565-afbeelding draagt twee bytes per pixel met rode, groene en blauwe velden samengepakt in een 16-bits woord. Een Bayer-afbeelding draagt één byte per pixel, maar elke pixel wordt bemonsterd via een van drie kleurenfilters die door zijn positie in het mozaïek worden gekozen. Vision Sensors somde de hele catalogus op; wat hier van belang is, is dat precies één van die formaten is ingesteld op elke Image, en die keuze bepaalt de rekenkunde voor bytes-per-pixel en de betekenis van elke afzonderlijke byte in de buffer.

Met een pointer naar de buffer, de breedte, de hoogte en het formaat volgt elke andere eigenschap die een algoritme zou kunnen willen uit een korte berekening. De byte waarmee pixel (x, y) begint, bevindt zich op offset (y * width + x) * bytes_per_pixel vanaf het begin van de buffer. Het totale aantal bytes is width * height * bytes_per_pixel. Het adres van de volgende rij eronder ligt precies width * bytes_per_pixel bytes na het begin van de huidige. De Image stelt de drie eigenschappen beschikbaar via gewone methodeaanroepen – width(), height(), format() – plus de afgeleide size via size(). Methoden elders in de module gebruiken die waarden om de offset-rekenkunde zelf te doen; applicatiecode hoeft dat zelden.

Een vak met het label image.Image -- Python-wrapper bovenaan, met een pijl die omlaag wijst met het label "verwijst naar" naar twee gestapelde vakken -- een dun headervak met breedte, hoogte en pixelformaat, en een dikker pixelbuffervak met een rij kleine cellen die individuele pixels voorstellen. Een bijschrift eronder vermeldt dat de buffer standaard op de heap leeft en in de framebuffer wanneer copy_to_fb waar is.

Een Image is een kleine Python-wrapper die wijst naar een aaneengesloten blok geheugen: een header met de breedte, hoogte en het pixelformaat, gevolgd door de pixelbuffer zelf.

5.1.2. Waar de buffer vandaan komt

Het standaardverhaal in dit hele hoofdstuk is het verhaal dat Vision Sensors al behandelde: een vastgelegd frame komt aan van snapshot, de bytes staan in de framebuffer van de camera, en de teruggegeven Image wijst ernaar. Drie andere manieren om er een te verkrijgen komen regelmatig voor, en elk impliceert iets anders over waar de buffer uiteindelijk terechtkomt.

Laden vanuit een bestand ziet eruit als het doorgeven van een pad aan de constructor: image.Image("/sdcard/saved.jpg"). De module leest het bestand in een vers toegewezen buffer op de Python-heap. BMP-, PGM- en PPM-bestanden worden onderweg gedecodeerd en de resulterende Image draagt een ongecomprimeerd pixelformaat. JPEG- en PNG-bestanden blijven gecomprimeerd – de Image draagt het formaat JPEG of PNG, en de buffer bevat de bytestream van het bestand vrijwel onveranderd. Om enig werk op pixelniveau op een gecomprimeerde afbeelding te doen, converteert de applicatie deze eerst via to_rgb565() of to_grayscale(), en die conversie is waar decompressie – en de bijbehorende heap-explosie, waarbij een JPEG van 30 KB 600 KB aan RGB565 kan worden – daadwerkelijk plaatsvindt. Laden vanuit een bestand is het nuttigst tijdens de ontwikkeling, wanneer een algoritme getest moet worden tegen een bekend referentieframe dat naast het script is opgeslagen.

Er een vanaf nul opbouwen is het canvas-geval: image.Image(320, 240, image.RGB565) vraagt de module om dat aantal bytes in dat formaat toe te wijzen, de inhoud op nul te zetten en de wrapper terug te geven. De pixels betekenen nog niets – ze zijn allemaal nul – maar de lege afbeelding is het werkpaard voor een handvol terugkerende patronen: referentieframes waarvan een huidig frame wordt afgetrokken, canvassen waarop grafische overlays worden samengesteld, binaire buffers die worden gevuld en als maskers worden gebruikt.

Construeren vanuit een ndarray overbrugt in de andere richting, van elke numerieke berekening terug naar de image-module. Het doorgeven van een float32-ulab.numpy.ndarray aan de constructor produceert een Image waarvan de afmetingen overeenkomen met de ndarray – een twee-assige (h, w)-vorm wordt een grijswaarden-afbeelding, een drie-assige (h, w, 3)-vorm wordt RGB565 – waarbij de float-waarden worden geschaald van 0.0255.0 naar het gehele pixelbereik. Een heatmap van een neuraal netwerk, een numerieke array van welke aard dan ook, alles wat geproduceerd wordt door ml of ulab wordt iets dat de teken- en inspectiekant van de image-module kan gebruiken.

Alle vier bronnen geven hetzelfde soort Image terug. Code die het teruggegeven object gebruikt, hoeft nooit bij te houden waar het vandaan kwam.

5.1.3. Twee weergaven over de bytes

Meestal behandelt applicatiecode een Image als een getypeerd afbeeldingsobject – een ding met benoemde methoden. De andere helft van het verhaal is dat hetzelfde object ook, transparant, verschijnt als een platte reeks bytes voor elke MicroPython-API die een bytes-argument aanneemt. De bytes zijn geen kopie van de buffer; ze zijn een directe weergave ervan.

Die opzet is wat het naar buiten sturen van een vastgelegd frame uit de cam tot een one-liner maakt. Het hashen ervan, het verzenden over een seriële poort, het doorsturen naar een netwerksocket – geen van die heeft een aparte stap “converteer de afbeelding naar bytes” nodig:

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

De bytes-achtige weergave is standaard alleen-lezen, met opzet. Beeldbuffers zijn groot en worden soms gedeeld tussen lagen van de beeldstack, dus een terloopse buf[0] = 0 ergens diep in een aanroepstack de macht geven om er stilletjes een te beschadigen is een te scherpe rand om bloot te laten liggen. Wanneer lees-schrijf-toegang op byteniveau is wat de applicatie daadwerkelijk nodig heeft – bijvoorbeeld het schrijven van een kalibratiewaarde naar een bekende offset – geeft bytearray() een aparte, expliciet lees-schrijf-weergave over hetzelfde geheugen terug, die de intentie op de aanroepplek aangeeft.

5.1.4. Waar de buffer leeft

Pixelbuffers zijn groot genoeg dat het uitmaakt waar ze in RAM zitten. Een QQVGA-RGB565-frame is 160 × 120 × 2 = 38.400 bytes; een VGA-RGB565-frame is 614.400 bytes; een 224 × 224-RGB565-invoer die een neuraal-netwerk-classifier zou kunnen verbruiken is ongeveer 100 KB. De Python-heap op de kleinste cams kan slechts enkele tientallen kilobytes zijn zodra de runtime is opgestart. Meer dan een frame of twee aan beeldgegevens op de heap houden zou al het andere ervan verdringen.

De uitweg is dat beeldbuffers grotendeels niet op de Python-heap leven. Ze leven in de speciale RAM-regio die Vision Sensors introduceerde als de framebuffer – hetzelfde geheugen waar de camera-DMA vastgelegde frames in schrijft en waar de IDE-preview afgewerkte frames uit leest. De meeste bewerkingen op een Image wijzigen hun bron ter plekke: het algoritme leest pixels, beslist, schrijft nieuwe waarden terug, en er wordt geen aparte resultaatafbeelding toegewezen. De bewerkingen die wel een apart resultaat produceren – formaatconversies en een handvol andere – kunnen worden gevraagd om dat resultaat in de framebuffer te plaatsen via het copy_to_fb-sleutelwoordargument. copy_to_fb=True doet twee dingen tegelijk: het plaatst de resultaatafbeelding in de framebuffer in plaats van op de heap (waarmee de heap-druk wordt vermeden) en het maakt het resultaat tot het volgende frame dat de IDE-preview zal weergeven. copy_to_fb=True aan de laatste stap van een pijplijn toevoegen, het resultaat op het scherm zien verschijnen en van daaruit itereren is een van de nuttigste debug-idiomen in beeldverwerking.

Met een wrapper die een gelabelde buffer bevat, vier manieren om er een tot bestaan te brengen, twee weergaven over zijn bytes, en een schakelaar die beslist waar nieuwe terechtkomen, is de Image niet langer een mysterie. De resterende fundamentele vragen – hoe een pixelpositie wordt benoemd, wat elke pixel daadwerkelijk bevat, hoe een bewerking tot een deel ervan te beperken – zijn erop gebouwd.