5.4. Pixel lesen und schreiben

Die meisten Operationen auf einem Bild verbergen ihre Arbeit pro Pixel innerhalb eines einzigen Methodenaufrufs, bei dem die Schleifen, die jeden Pixel berühren, mit nativer Geschwindigkeit ablaufen. Es gibt jedoch Fälle, in denen der Anwendungscode einen bestimmten Pixel direkt berühren möchte: um auszulesen, was sich an einer bestimmten Position befindet, um einen neuen Wert hineinzuschreiben, um einen einzelnen Punkt für einen Kalibrierungsschritt abzutasten oder um einen Wert an einer bekannten Stelle zu debuggen. Das Modul image stellt diese Zugriffsebene über zwei Adressierungsformen bereit, die jeweils zu einer anderen Denkweise darüber passen, wo ein Pixel liegt.

5.4.1. Adressierung über Koordinaten

Die natürlichste Form ist diejenige, für die Koordinaten bereits das Vokabular entwickelt hat: einen Pixel über seine kartesischen (x, y) benennen. get_pixel() nimmt (x, y) entgegen und gibt den Wert an dieser Position zurück; set_pixel() nimmt dieselben (x, y) zusammen mit einem Wert entgegen und schreibt ihn.

Was diese Aufrufe zurückgeben oder akzeptieren, hängt vom Format des Bildes ab. Graustufen-, Binär- und Bayer-Bilder tragen einen einzigen Wert pro Pixel – eine Helligkeit für Graustufen, eine 0 oder 1 für Binärbilder, eine einzelne Farbkanal-Abtastung für Bayer – sodass get_pixel() eine einzelne Ganzzahl zurückgibt. RGB565 trägt drei Farbkanäle in 16 Bit gepackt, und get_pixel entpackt sie standardmäßig in ein (r, g, b)-Tupel, wobei jeder Kanal in den Bereich 0255 abgebildet wird.

Das Standardverhalten kann an beiden Enden umgekehrt werden. Wird rgbtuple=False an get_pixel bei einem RGB565-Bild übergeben, so wird auf das rohe, 16-Bit gepackte Wort zurückgegriffen – dieselbe Form, die der lineare Index zurückgibt, und die effiziente Form, wenn die Anwendung denselben gepackten Wert unmittelbar wieder zurückschreiben will. Wird rgbtuple=True bei einem einkanaligen Bild übergeben, geschieht das Gegenteil: der gespeicherte Wert wird vor der Rückgabe in ein RGB888-Tupel umgewandelt, wobei Bayer-Bilder einen unmittelbaren Debayer-Schritt durchlaufen. Das Argument existiert, damit aufrufender Code Pixel in einem einheitlichen Farbraum anfordern kann, unabhängig davon, wie das zugrunde liegende Bild sie speichert.

Komprimierte Bilder – JPEG und PNG – werden von get_pixel oder set_pixel nicht unterstützt. Ihre Bytes repräsentieren keine Pixel an bekannten Positionen, und die Methoden lösen einen Fehler aus, anstatt einen Wert zurückzugeben, der nichts bedeuten würde.

In der Praxis sehen die Muster so aus:

v = img.get_pixel(40, 30)            # grayscale: int 0..255
img.set_pixel(40, 30, 255)           # write white

r, g, b = img.get_pixel(40, 30)      # RGB565: defaults to (r, g, b) tuple
img.set_pixel(40, 30, (255, 0, 0))   # write red

Liegt das angeforderte (x, y) außerhalb des Bildes, gibt get_pixel None zurück und set_pixel tut nichts. Das ist bewusst nachsichtig gestaltet: viele Algorithmen laufen nahe an den Rändern eines Bildes entlang und indizieren kurzzeitig Positionen außerhalb des gültigen Bereichs, und ein stiller No-Op ist weniger störend als jedes Mal eine Ausnahme.

5.4.2. Adressierung über linearen Index

Die andere Form besteht darin, Pixel über ihre Position im zugrunde liegenden Puffer zu adressieren. Erinnern wir uns an das Layout des Puffers: Pixel werden Zeile für Zeile gespeichert, zuerst alle Pixel der obersten Zeile, dann alle der nächsten Zeile und so weiter bis nach unten. Diese Anordnung bedeutet, dass jeder Pixel einen einzigen ganzzahligen Index hat, der bei 0 oben links beginnt und entlang jeder Zeile der Reihe nach hochzählt. Der Pixel an der Koordinate (x, y) hat den linearen Index y * width + x.

Ein 4-mal-3-Raster aus Zellen. Jede Zelle trägt einen großen linearen Index von 0 oben links bis 11 unten rechts, dazu ein kleines (x, y)-Tupel darunter. Die Spalten sind oben mit x gleich 0, 1, 2, 3 beschriftet; die Zeilen sind am linken Rand mit y gleich 0, 1, 2 beschriftet. Eine Bildunterschrift darunter gibt die Beziehung an: linearer Index gleich y mal Breite plus x.

Pixel werden sowohl über kartesische (x, y) als auch über einen linearen Index adressiert, der den Puffer Zeile für Zeile von links nach rechts durchläuft.

Das Modul image stellt diesen Index über die gewöhnliche Python-Indexnotation bereit: img[i] liest den Pixel am linearen Index i, img[i] = value schreibt einen. Was die Indexform zurückgibt, ist der rohe gespeicherte Wert für das Format, nicht das entpackte Tupel, das get_pixel() standardmäßig zurückgibt. Diese Unterscheidung ist wichtig, weil das zuvor gewählte Format bestimmt, wie der rohe Wert aussieht:

  • Graustufen- und Bayer-Pixel kommen als 8-Bit-Ganzzahlen zurück.

  • RGB565- und YUV422-Pixel kommen als 16-Bit-Ganzzahlen zurück – das gepackte Wort.

  • Binärpixel kommen als 0 oder 1 zurück.

  • JPEG- und PNG-Pixel kommen als 8-Bit-Ganzzahlen zurück, jeweils ein Byte des komprimierten Datenstroms. Diese Werte sind undurchsichtig – sie sind Teile einer komprimierten Kodierung und keine Pixel im gewöhnlichen Sinne.

Die Indexform passt zu Code, der ohnehin schon in Pufferversätzen denkt: eine Schleife, die jeden Pixel einmal durchläuft, ein Algorithmus, der jeweils um eine Zeile springen muss, oder ein Codestück, das zwischen Pufferlayouts übersetzt. Code, der in x- und y-Koordinaten denkt, ist mit get_pixel und set_pixel besser bedient; die beiden Formen adressieren dieselben Pixel über unterschiedliche mentale Modelle.

Das Image ist außerdem iterierbar. for v in img: durchläuft den Puffer in derselben zeilenweisen Reihenfolge und liefert die rohen Werte jeweils einen Pixel auf einmal, und len(img) ist die Anzahl der Pixel bei unkomprimierten Formaten beziehungsweise die Anzahl der Bytes bei komprimierten Datenströmen.

5.4.3. Warum die Pixel-für-Pixel-Verarbeitung in Python der langsame Weg ist

Eine praktische Anmerkung, der gegenüber man ehrlich sein sollte. Ein Bild aus Python heraus Pixel für Pixel zu durchlaufen, ist langsam. Ein 320 × 240 großes Graustufenbild enthält 76.800 Pixel; get_pixel() für jeden davon in einer for-Schleife aufzurufen, führt Millionen von MicroPython-Bytecode-Instruktionen aus, um eine Arbeit zu erledigen, die eine entsprechende native Methode in wenigen hundert Mikrosekunden abschließen könnte. Das ist kein kleiner Faktor. Es ist der Unterschied zwischen einem Skript, das Einzelbilder in Echtzeit verarbeitet, und einem, das deutlich unter der Bildrate der Kamera dahinkriecht.

Fast jede Methode der Image-Oberfläche existiert, weil es eine schnellere, native Version eines gängigen Pixel-für-Pixel-Musters gibt. Eine Schleife, die zwei Bilder zusammenaddiert, wird zu einem einzigen nativen Aufruf. Eine Schleife, die jeden Pixel durch Mittelung mit seinen Nachbarn glättet, wird zu einem weiteren. Eine Schleife, die jeden Pixel anhand eines Schwellenwerts klassifiziert, wird zu einem dritten. Die Aufgabe der Anwendung besteht meistens darin, zu erkennen, welche Gesamtbild-Methode der Arbeit entspricht, die die Schleife geleistet hätte, und zu dieser zu greifen, anstatt die Schleife von Hand zu schreiben.

Lesen und Schreiben auf Pixelebene sind immer noch das richtige Werkzeug, wenn nichts anderes passt – eine bestimmte Messung wieder in den Puffer einzuflicken, eine Position für einen Kalibrierungsschritt abzutasten, einen Wert an einer bekannten Stelle zu debuggen. Der Punkt ist, dass sie der langsame Weg sind, der eingesetzt wird, wenn die Gesamtbild-Methoden nicht die Form haben, die die Anwendung benötigt, und nicht als Standardweg, um auf Pixeln zu operieren.