5.1. Image オブジェクト¶
画像処理アルゴリズムは、画像を1ピクセルずつ走査していきます。各位置でアルゴリズムは単純な処理を行います。値を読み取り、しきい値と比較し、2枚目の画像の対応するピクセルと組み合わせ、結果を書き戻す、といった具合です。フレーム全体にわたって繰り返されるこれらの単純なピクセル単位の判断こそが、エッジ検出、ブロブ追跡、QRコードのデコード、その他あらゆる古典的なコンピュータビジョン技術を構成しています。この処理を効率的に行うために、アルゴリズムは各ピクセルがメモリのどこに位置するか、各ピクセルの値が実際に何を意味するか、そして画像のどの部分を見るべきかを知っている必要があります。image.Image は、こうした情報を整理するオブジェクトです。
Vision Sensors は csi.CSI.snapshot() が戻った時点で終わりました。カメラ側の仕組みがキャプチャされたフレームを生成するために行ったことはすでに完了しており、アプリケーションは Image を手にして、それをどう扱うかを知る必要があります。
5.1.1. バッファとそのプロパティ¶
Image の内部には、RAM 上の連続したバイト列へのポインタと、3つのメタデータを保持する小さなヘッダーがあります。すなわち画像の幅(ピクセル数)、高さ(ピクセル数)、そしてバイト列が表すピクセルフォーマットです。バイト列はピクセルそのものであり、行優先順(row-major order)で格納されています。つまり最上行のすべてのピクセルが先に並び、次に2行目のすべて、というように下端まで続きます。プロパティはそれらをどう読み取るかを記述します。
幅と高さは単なる整数のカウントです。ピクセルフォーマットはより興味深いプロパティです。なぜなら、各ピクセルが何バイトを占めるか、そしてそれらのバイトが何をエンコードするかを決めるからです。グレースケール画像はピクセルあたり1バイトを持ち、明るさの値を格納します。RGB565 画像はピクセルあたり2バイトを持ち、赤・緑・青のフィールドを16ビットのワードにパックして格納します。Bayer 画像はピクセルあたり1バイトを持ちますが、各ピクセルはモザイク内の位置によって選ばれた3つのカラーフィルタのいずれか1つを通してサンプリングされています。Vision Sensors ではカタログ全体を列挙しました。ここで重要なのは、すべての Image にこれらのフォーマットのうちちょうど1つが設定されており、その選択がピクセルあたりのバイト数の計算と、バッファ内の任意の1バイトが持つ意味を決定づける、という点です。
バッファへのポインタ、幅、高さ、フォーマットがあれば、アルゴリズムが必要とする他のあらゆるプロパティは短い計算で導き出せます。ピクセル (x, y) の先頭バイトは、バッファの先頭からオフセット (y * width + x) * bytes_per_pixel の位置にあります。総バイト数は width * height * bytes_per_pixel です。1つ下の行の先頭アドレスは、現在の行の先頭から正確に width * bytes_per_pixel バイト後ろにあります。Image は3つのプロパティを単純なメソッド呼び出しで公開します。width()、height()、format() です。さらに導出される size は size() で得られます。モジュール内の他の場所にあるメソッドはこれらの値を使って自らオフセット計算を行うため、アプリケーションコードがそれを行う必要はほとんどありません。
Image は、連続したメモリブロックを指す小さな Python ラッパーです。すなわち幅・高さ・ピクセルフォーマットを保持するヘッダーと、それに続くピクセルバッファそのものです。¶
5.1.2. バッファの出どころ¶
この章全体を通してのデフォルトのシナリオは、Vision Sensors ですでに扱ったものです。すなわち、キャプチャされたフレームが snapshot から到着し、バイト列はカメラのフレームバッファに置かれ、返される Image がそれを指す、というものです。これ以外にも、頻繁に登場する3つの取得方法があり、それぞれバッファの最終的な置き場所について異なることを意味します。
ファイルからの読み込みは、コンストラクタにパスを渡すような形になります。image.Image("/sdcard/saved.jpg") のようにです。モジュールはファイルを Python ヒープ上に新たに確保したバッファへ読み込みます。BMP、PGM、PPM ファイルは読み込み時にデコードされ、結果として得られる Image は非圧縮のピクセルフォーマットを持ちます。JPEG と PNG ファイルは圧縮されたままで、Image は JPEG または PNG のフォーマットを持ち、バッファにはファイルのバイトストリームがほぼそのまま保持されます。圧縮画像に対してピクセルレベルの処理を行うには、アプリケーションはまず to_rgb565() または to_grayscale() で変換します。展開(および対応するヒープの膨張、たとえば 30 KB の JPEG が 600 KB の RGB565 になり得ます)が実際に起こるのは、その変換のときです。ファイルからの読み込みは、スクリプトと一緒に保存された既知の参照フレームに対してアルゴリズムをテストする必要がある開発時に、最も役立ちます。
ゼロから1枚作るのはキャンバスのケースです。image.Image(320, 240, image.RGB565) は、そのフォーマットでそのバイト数を確保し、内容をゼロで埋め、ラッパーを返すようモジュールに依頼します。ピクセルはまだ何も意味しません。すべてゼロです。しかしこの空の画像は、繰り返し現れるいくつかのパターンの主力となります。現在のフレームを差し引く対象となる参照フレーム、グラフィックスのオーバーレイを合成するキャンバス、塗りつぶしてマスクとして使うバイナリバッファなどです。
ndarray からの構築は、逆方向の橋渡しになります。すなわち、任意の数値計算から image モジュールへ戻る方向です。float32 の ulab.numpy.ndarray をコンストラクタに渡すと、その ndarray と寸法が一致する Image が生成されます。2軸の (h, w) 形状はグレースケール画像になり、3軸の (h, w, 3) 形状は RGB565 になります。float の値は 0.0 -- 255.0 から整数のピクセル範囲へスケーリングされます。ニューラルネットワークのヒートマップ、任意の種類の数値配列、ml や ulab が生成したものはすべて、image モジュールの描画・検査側で利用できるものになります。
4つの入手元はいずれも同じ種類の Image を返します。返されたオブジェクトを使うコードは、それがどこから来たかを追跡する必要は一切ありません。
5.1.3. バイト列に対する2つのビュー¶
ほとんどの場合、アプリケーションコードは Image を型付きの画像オブジェクトとして、すなわち名前付きメソッドを持つものとして扱います。物語のもう半分は、同じオブジェクトが、bytes 引数を取るあらゆる MicroPython API に対して、透過的にフラットなバイト列としても現れる、という点です。このバイト列はバッファのコピーではなく、その直接のビューです。
この仕組みのおかげで、キャプチャされたフレームをカメラから送り出すのが1行で済みます。ハッシュを取る、シリアルポート経由で送る、ネットワークソケットへ転送する。これらのいずれにも「画像をバイト列に変換する」という別工程は必要ありません。
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
bytes ライクなビューは、意図的にデフォルトで 読み取り専用 です。画像バッファは大きく、ときには画像処理スタックの各レイヤー間で共有されるため、コールスタックの奥深くにある何気ない buf[0] = 0 が静かにそれを破壊できる力を与えるのは、放置するにはあまりに鋭利な刃です。アプリケーションが実際に読み書き可能なバイトレベルアクセスを必要とするとき(たとえば既知のオフセットへキャリブレーション値を書き込む場合など)には、bytearray() が同じメモリに対する別個の、明示的に読み書き可能なビューを返し、呼び出し箇所でその意図を示します。
5.1.4. バッファが存在する場所¶
ピクセルバッファは、それが RAM のどこに置かれるかが問題になるほど大きいものです。QQVGA の RGB565 フレームは 160 × 120 × 2 = 38,400 バイト、VGA の RGB565 フレームは 614,400 バイト、ニューラルネットワーク分類器が消費し得る 224 × 224 の RGB565 入力は約 100 KB です。最小のカメラでは、ランタイムが起動した後の Python ヒープはわずか数十キロバイトしかないこともあります。フレーム1〜2枚分を超える画像データをヒープに保持すれば、他のすべてを押しのけてしまうでしょう。
その解決策は、画像バッファのほとんどが Python ヒープ上に存在しない、という点にあります。それらは、Vision Sensors が フレームバッファ として紹介した RAM の専用領域に存在します。これは、カメラの DMA がキャプチャしたフレームを書き込み、IDE プレビューが完成したフレームを読み出すのと同じメモリです。Image に対するほとんどの操作は、ソースをその場で(in place)変更します。アルゴリズムはピクセルを読み、判断し、新しい値を書き戻し、別個の結果画像は確保されません。別個の結果を 生成する 操作(フォーマット変換やそのほか少数のもの)は、copy_to_fb キーワード引数を通じて、その結果をフレームバッファに置くよう依頼できます。copy_to_fb=True は同時に2つのことを行います。結果画像をヒープではなくフレームバッファに置き(ヒープ圧迫を回避し)、その結果を IDE プレビューが次に表示するフレームにします。パイプラインの最終ステップに copy_to_fb=True を付け足し、結果が画面に現れるのを見て、そこから反復していくのは、画像処理において最も有用なデバッグの常套手段の1つです。
ラベル付けされたバッファを保持するラッパー、それを存在させる4つの方法、そのバイト列に対する2つのビュー、そして新しいものがどこに置かれるかを決めるスイッチ。これらがそろえば、Image はもはや謎ではありません。残された基礎的な疑問、すなわちピクセル位置がどう名付けられるか、各ピクセルが実際に何を保持するか、操作を画像の一部分に限定するにはどうするか、は、この上に築かれています。