5.1. Das Image-Objekt¶
Ein Bildverarbeitungsalgorithmus arbeitet ein Bild Pixel für Pixel durch. An jeder Position führt er etwas Einfaches aus – einen Wert lesen, ihn mit einem Schwellenwert vergleichen, ihn mit dem entsprechenden Pixel eines zweiten Bildes kombinieren, ein Ergebnis zurückschreiben. Über ein ganzes Einzelbild hinweg wiederholt, sind es diese einfachen Pro-Pixel-Entscheidungen, aus denen Kantenerkennung, Blob-Verfolgung, QR-Code-Dekodierung und jede andere klassische Computer-Vision-Technik aufgebaut sind. Um diese Arbeit effizient zu erledigen, muss der Algorithmus wissen, wo jedes Pixel im Speicher liegt, was der Wert jedes Pixels tatsächlich bedeutet und welchen Teil des Bildes er betrachten soll. Das image.Image ist das Objekt, das diese Informationen organisiert.
Vision Sensors endete in dem Moment, in dem csi.CSI.snapshot() zurückkehrt. Was auch immer die kameraseitige Mechanik getan hat, um das aufgenommene Einzelbild zu erzeugen, ist bereits erledigt; die Anwendung hat das Image in der Hand und muss wissen, was damit zu tun ist.
5.1.1. Der Puffer und seine Eigenschaften¶
Innerhalb des Image befindet sich ein Zeiger auf einen zusammenhängenden Block von Bytes im RAM sowie ein kleiner Header, der drei Metadaten trägt: die Breite des Bildes in Pixeln, seine Höhe in Pixeln und das Pixelformat, in dem die Bytes vorliegen. Die Bytes sind die Pixel selbst, gespeichert in zeilenweiser Reihenfolge (row-major) – zuerst alle Pixel der obersten Zeile, dann alle der zweiten Zeile und so weiter bis nach unten. Die Eigenschaften beschreiben, wie sie zu lesen sind.
Breite und Höhe sind einfache Ganzzahlangaben. Das Pixelformat ist die interessantere Eigenschaft, denn es legt fest, wie viele Bytes jedes Pixel belegt und was diese Bytes kodieren. Ein Graustufenbild trägt ein Byte pro Pixel, das einen Helligkeitswert enthält. Ein RGB565-Bild trägt zwei Bytes pro Pixel, die Rot-, Grün- und Blau-Felder enthalten, gepackt in ein 16-Bit-Wort. Ein Bayer-Bild trägt ein Byte pro Pixel, aber jedes Pixel wird durch einen von drei Farbfiltern abgetastet, der durch seine Position im Mosaik bestimmt wird. Vision Sensors zählte den gesamten Katalog auf; was hier zählt, ist, dass genau eines dieser Formate auf jedem Image gesetzt ist und die Wahl die Bytes-pro-Pixel-Arithmetik sowie die Bedeutung jedes einzelnen Bytes im Puffer bestimmt.
Mit einem Zeiger auf den Puffer, der Breite, der Höhe und dem Format ergibt sich jede andere Eigenschaft, die ein Algorithmus benötigen könnte, aus einer kurzen Berechnung. Das Byte, das das Pixel (x, y) beginnt, sitzt am Offset (y * width + x) * bytes_per_pixel vom Anfang des Puffers. Die Gesamtzahl der Bytes beträgt width * height * bytes_per_pixel. Die Adresse der nächsten Zeile darunter liegt genau width * bytes_per_pixel Bytes nach dem Anfang der aktuellen. Das Image stellt die drei Eigenschaften über einfache Methodenaufrufe bereit – width(), height(), format() – sowie die abgeleitete size über size(). Methoden an anderer Stelle im Modul verwenden diese Werte, um die Offset-Arithmetik selbst durchzuführen; Anwendungscode muss das nur selten tun.
Ein Image ist ein kleiner Python-Wrapper, der auf einen zusammenhängenden Speicherblock zeigt: ein Header, der Breite, Höhe und Pixelformat trägt, gefolgt vom Pixelpuffer selbst.¶
5.1.2. Woher der Puffer kommt¶
Der Standardfall in diesem gesamten Kapitel ist der, den Vision Sensors bereits behandelt hat: ein aufgenommenes Einzelbild trifft von snapshot ein, die Bytes liegen im Framebuffer der Kamera, und das zurückgegebene Image zeigt auf sie. Drei weitere Möglichkeiten, eines zu erhalten, kommen regelmäßig vor, und jede impliziert etwas anderes darüber, wo der Puffer letztlich landet.
Das Laden aus einer Datei sieht so aus, als würde man dem Konstruktor einen Pfad übergeben: image.Image("/sdcard/saved.jpg"). Das Modul liest die Datei in einen frisch allokierten Puffer auf dem Python-Heap. BMP-, PGM- und PPM-Dateien werden beim Einlesen dekodiert, und das resultierende Image trägt ein unkomprimiertes Pixelformat. JPEG- und PNG-Dateien bleiben komprimiert – das Image trägt das Format JPEG oder PNG, und der Puffer hält den Byte-Stream der Datei im Wesentlichen unverändert. Um irgendeine Arbeit auf Pixelebene an einem komprimierten Bild durchzuführen, konvertiert die Anwendung es zunächst über to_rgb565() oder to_grayscale(), und bei dieser Konvertierung findet die Dekompression – und die entsprechende Heap-Aufblähung, bei der aus einem 30 KB großen JPEG 600 KB RGB565 werden können – tatsächlich statt. Das Laden aus einer Datei ist während der Entwicklung am nützlichsten, wenn ein Algorithmus gegen ein bekanntes Referenz-Einzelbild getestet werden muss, das neben dem Skript gespeichert ist.
Eines von Grund auf zu erstellen ist der Leinwand-Fall: image.Image(320, 240, image.RGB565) weist das Modul an, so viele Bytes in diesem Format zu allokieren, den Inhalt auf null zu setzen und den Wrapper zurückzugeben. Die Pixel bedeuten noch nichts – sie sind alle null – aber das leere Bild ist das Arbeitspferd für eine Handvoll wiederkehrender Muster: Referenz-Einzelbilder, von denen ein aktuelles Einzelbild subtrahiert wird, Leinwände, auf denen Grafik-Overlays komponiert werden, binäre Puffer, die ausgefüllt und als Masken verwendet werden.
Das Konstruieren aus einem ndarray überbrückt in die andere Richtung, von jeder numerischen Berechnung zurück in das image-Modul. Das Übergeben eines float32-ulab.numpy.ndarray an den Konstruktor erzeugt ein Image, dessen Dimensionen mit dem ndarray übereinstimmen – eine zweiachsige (h, w)-Form wird zu einem Graustufenbild, eine dreiachsige (h, w, 3)-Form wird zu RGB565 – wobei die Float-Werte von 0.0 – 255.0 in den Ganzzahl-Pixelbereich skaliert werden. Eine Heatmap eines neuronalen Netzes, ein numerisches Array beliebiger Art, alles, was von ml oder ulab erzeugt wird, wird zu etwas, das die Zeichen- und Inspektionsseite des image-Moduls verwenden kann.
Alle vier Quellen geben dieselbe Art von Image zurück. Code, der das zurückgegebene Objekt verwendet, muss nie nachverfolgen, woher es kam.
5.1.3. Zwei Sichten auf die Bytes¶
Meistens behandelt Anwendungscode ein Image als typisiertes Bildobjekt – ein Ding mit benannten Methoden. Die andere Hälfte der Geschichte ist, dass dasselbe Objekt auch, transparent, als flache Folge von Bytes für jede MicroPython-API erscheint, die ein bytes-Argument annimmt. Die Bytes sind keine Kopie des Puffers; sie sind eine direkte Sicht darauf.
Diese Anordnung ist es, die das Hinausschieben eines aufgenommenen Einzelbildes aus der Kamera zu einem Einzeiler macht. Es zu hashen, über einen seriellen Port zu senden, an einen Netzwerk-Socket weiterzuleiten – keiner dieser Vorgänge benötigt einen separaten Schritt zum „Konvertieren des Bildes in Bytes“:
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
Die bytes-artige Sicht ist standardmäßig schreibgeschützt, und das mit Absicht. Bildpuffer sind groß und werden manchmal zwischen Schichten des Bildverarbeitungs-Stacks geteilt, sodass es eine zu scharfe Kante wäre, einem beiläufigen buf[0] = 0 irgendwo tief in einem Aufrufstapel die Macht zu geben, einen solchen Puffer stillschweigend zu beschädigen. Wenn die Anwendung tatsächlich Lese-Schreib-Zugriff auf Byte-Ebene benötigt – etwa um einen Kalibrierungswert an einen bekannten Offset zu schreiben – gibt bytearray() eine separate, ausdrücklich lese-schreibbare Sicht auf denselben Speicher zurück und signalisiert die Absicht an der Aufrufstelle.
5.1.4. Wo der Puffer lebt¶
Pixelpuffer sind groß genug, dass es eine Rolle spielt, wo sie im RAM liegen. Ein QQVGA-RGB565-Einzelbild ist 160 × 120 × 2 = 38.400 Bytes; ein VGA-RGB565-Einzelbild ist 614.400 Bytes; ein 224 × 224 großes RGB565-Eingabebild, das ein Klassifizierer auf Basis eines neuronalen Netzes verarbeiten könnte, ist etwa 100 KB groß. Der Python-Heap auf den kleinsten Kameras kann, sobald die Laufzeitumgebung gebootet hat, nur wenige Dutzend Kilobyte betragen. Mehr als ein oder zwei Einzelbilder an Bilddaten auf dem Heap zu halten, würde alles andere von ihm verdrängen.
Der Ausweg ist, dass Bildpuffer größtenteils nicht auf dem Python-Heap liegen. Sie liegen in dem dedizierten RAM-Bereich, den Vision Sensors als Framebuffer eingeführt hat – denselben Speicher, in den die Kamera-DMA aufgenommene Einzelbilder schreibt und aus dem die IDE-Vorschau fertige Einzelbilder liest. Die meisten Operationen auf einem Image modifizieren ihre Quelle direkt vor Ort: der Algorithmus liest Pixel, entscheidet, schreibt neue Werte zurück, und es wird kein separates Ergebnisbild allokiert. Die Operationen, die doch ein separates Ergebnis erzeugen – Formatkonvertierungen und eine Handvoll anderer – können angewiesen werden, dieses Ergebnis über das Schlüsselwortargument copy_to_fb in den Framebuffer zu legen. copy_to_fb=True tut zwei Dinge auf einmal: es legt das Ergebnisbild in den Framebuffer statt auf den Heap (umgeht so den Heap-Druck) und macht das Ergebnis zum nächsten Einzelbild, das die IDE-Vorschau anzeigt. Das Anhängen von copy_to_fb=True an den letzten Schritt einer Pipeline, das Beobachten, wie das Ergebnis auf dem Bildschirm erscheint, und das Iterieren von dort aus ist eine der nützlichsten Debugging-Redewendungen in der Bildverarbeitung.
Mit einem Wrapper, der einen beschrifteten Puffer hält, vier Wegen, einen ins Dasein zu bringen, zwei Sichten auf seine Bytes und einem Schalter, der entscheidet, wo neue landen, ist das Image kein Rätsel mehr. Die verbleibenden grundlegenden Fragen – wie eine Pixelposition benannt wird, was jedes Pixel tatsächlich hält, wie man eine Operation auf einen Teil eines Bildes eingrenzt – bauen darauf auf.