画像処理における色々な領域とメモリ配置について


この記事について

組み込みプログラムで画像を扱う上で、最低限必要なのは画像データのアドレスとサイズ(width, height)になります。でも、フィルター処理などで現れる近傍ピクセルへのアクセス処理やメモリ転送効率を考えるとそれだけじゃないんだよという内容です。

言い訳

  • RGBとかYUVとかは本記事では特に気にしません。簡単のため、本記事では画像は全てYのみのグレースケールとします。そのため、1Pixel = 1Byteとなります。
  • 本記事内のソースコードは記事を書きながら書いているため、コンパイル/動作未確認です。雰囲気を感じ取ってください。
  • 画像データへアクセスする際に、配列を使用しています。添え字の計算がもったいないのでは? と思うのですが、分かりやすさを重視して、本記事では配列方式を使います。また、今どきはコンパイラが頑張ってポインタアクセス同等のコードになってくれると思います。

画像データを表現する (基本)

typedef struct {
    uint8_t* data;
    int width;
    int height;
} IMAGE;

IMAGE image;

図のような画像を扱うには、上記のような情報を管理すればOKです。これを使って各ピクセルに自由にアクセスできます。

フィルタ処理を考える

しかし、画像処理によく出てくる近傍ピクセルを使ったフィルタ処理などで問題が出てきます。例として、近傍3x3を使用する平滑化フィルタを考えます。

シンプルなフィルタ処理
for (int y = 0; y < image.height; y++) {
    for (int x = 0; x < image.width; x++) {
        int val = 0;
        for (int yy = y - 1; yy <= y + 1; yy++) {
            for (int xx = x - 1; xx <= x + 1; xx++) {
                val += image.data[image.width * yy + xx];
            }
        }
        image.data[image.width * y + x] = val / 9;
    }
}

問題点

例えば、一番左上のピクセルに対して計算するとき、必要なピクセルデータが存在しないことになります。上記のコードをそのまま使うと、領域外アクセスが発生してしまいます。

あまりよろしくない解決策

画面外のピクセルにアクセスする場合には、周囲のピクセルデータで置き換えたりすることで対応できます。しかし、これだとforループの中にロジックが増えてしまい計算量が増加してしまいます。

例外処理を追加したフィルタ処理
for (int y = 0; y < image.height; y++) {
    for (int x = 0; x < image.width; x++) {
        int val = 0;
        for (int yy = y - 1; yy <= y + 1; yy++) {
            int yyy = max(0, min(image.height - 1, yy));
            for (int xx = x - 1; xx <= x + 1; xx++) {
                int xxx = max(0, min(image.width - 1, xx));
                val += image.data[image.width * yyy + xxx];
            }
        }
        image.data[image.width * y + x] = val / 9;
    }
}

解決策

こういう時は、「最終的な画像には保存されないけど、画像処理には使用可能な有効領域」を追加してあげることで対処できます。この領域をvalid領域と呼びます。また、もともとの画像領域をtarget領域と呼びます。(これが一般的かどうかは不明)。図にすると以下のようになります。

こうすることで、領域外アクセスを気にせずにループ文を書けます。注意点として、1つ下のラインにアクセスする際に加算すべきサイズはtaregt.widthではなく、valid.widthになります。一般的にstrideとか呼ばれます。また、valid.dataをベースにtarget領域にアクセスするためにはtaregt領域のオフセット(x, y)を考慮する必要があります。

valid領域を含む画像データへのアクセス
typedef struct {
    uint8_t* data;
    int x;
    int y;
    int width;
    int height;
} IMAGE;

IMAGE valid;
IMAGE target;

int stride = valid.width;

for (int y = 0; y < target.height; y++) {
    for (int x = 0; x < target.width; x++) {
        int val = 0
        for (int yy = target.y + y - 1; yy <= target.y + y + 1; yy++) {
            for (int xx = target.x + x - 1; xx <= target.x + xx + 1; xx++) {
                val += valid.data[stride * yy + xx];
            }
        }
        target.data[stride * y + x] = val;
    }
}

valid領域をどうやって用意するか

画像の入力ソースがイメージセンサの場合には、valid領域をがっつり含んだデータが出力されると思います。例えば、イメージセンサは2048x1400ピクセルのデータを出力するが、実際には中心の1920x1080を切り出して使うということがあります。この場合は、既にvalid領域用のデータがあるので問題ありません。

入力ソースが画像ファイルの場合には、サイズぴったりの画像データしかありません。こういう時には、画像処理前に上下左右端のピクセルデータを必要なだけコピーしておくと良いと思います。
とはいえ、この処理も時間がかかるし、面倒です。また、近年はやりのコンピュータビジョン系の画像処理では画面端のデータが欠けたところで影響がないケースもあります。そのため、画面端は処理しないというのも手です。(カメラなど映像作品系だとこの手は使えませんが、その場合には上述のようにvalidデータが手に入ると思います)

画面端を省略したシンプルなフィルタ処理
for (int y = 1; y < image.height - 1; y++) {
    for (int x = 1; x < image.width - 1; x++) {
        int val = 0;
        for (int yy = y - 1; yy <= y + 1; yy++) {
            for (int xx = x - 1; xx <= x + 1; xx++) {
                val += image.data[image.width * yy + xx];
            }
        }
        image.data[image.width * y + x] = val / 9;
    }
}

転送効率やDSP制約を考慮する

画像処理を全部CPUでやる場合には、上記までの内容でOKです。
しかし、画像処理をFPGAやDSPでやる場合には注意が必要です。ハードウェアIPの制約として、「各ラインの先頭アドレスが8Byteアライメントされていること」、などがあります。あるいは、「先頭アドレス及びstrideが8Byteアライメントされていること」という表現になっていることもあります。また、「制約」ほど厳しくなくても、DDR-IP間のデータ転送効率を上げるために4Byteや8Byteアライメントされていることが望ましいケースもあります。

これを満たすために、メモリ配置的にもう一つ大きい領域を定義します。これをmemory領域とします。一般的に書くと各領域の関係は下記になります。memory領域内かつvalid領域外には有効な画像データは入っていません(図の緑色の部分)。

実際には下記のような関係になることがほとんどだと思います。(アラインを取るためにはラインの終わりにパディングを入れればいいため)。これによって、たとえtargetやvalidのサイズが奇数だとしても、memory.widthを適切に設定すれば、各ラインの読出し開始アドレスは綺麗にアラインメントされることになります。

おわりに

少し冗長感はありますが、これくらい考えておけば大体のケースには対応できると思います。