はじめに
2019年3月に行われた種子島ロケットコンテスト CanSat部門に参加してきました。
今回の記事はそのCanSatに用いた画像処理システムについての紹介です。
環境 : STM32F4 + CubeMX
※記事中のソースコードは種子島ロケットコンテストに使用したものからの抜粋です。
記事中では構造体の定義などが省略されているためコピペでは動かないかもしれません。
参考程度にご活用ください。
開発背景
CanSatに要求される画像処理システムは
- とにかく超小型化する必要がある (小さな機体に収まるようにする)
- 求められる処理は赤色の検出ができれば十分 (カラーコーンの位置の検出)
という要求を満たす必要があります。
電子工作で画像処理をする場合、Pixy2 や openMVを使うと簡単に実装できます。
しかし、今回はこのようなモジュールすら大きすぎて使えないような環境を想定しています。
当然、raspberry Pi Zero + openCV も使えない環境です。(物理的大きさの制約、電源の制約などのため)
そこで、1つのマイコン内で全ての処理が完結するようにしました。
ベアメタルで処理するとは
画像処理システムを自前で実装する場合はopenCVがよく使われます。
しかし、openCVは最低でもraspberryPiのような高性能なハードウェアとOSのある環境を前提としています。
そのため、マイコン上でopenCVを使うことができません。
今回はOS無しでも走るような画像処理システムをC言語で直接実装しました。
(これをベアメタルで実装すると言います。)
システムの概要
ハードウェア
+--------+ JPEG DATA +------------+
|C1098-SS| (UART) |STM32F446RE | 計算結果
|(camera)+-------------->+ (MCU) |----->
+--------+ +------------+
C1098-SSというカメラからUARTでJPEGデータをもらい、STM32F446REで受信します。
マイコン内で画像処理をした後、計算結果をPCに送信したり別のシステムに入力したりします。
ソフトウェア
JPEGで画像データを受信するため、画像処理の前にデコードをする必要があります。
大まかに処理の流れを書くと
- JPEGデータを受信
- JPEGのデコード (JPEG→RGB)
- 画像処理
- RGB→HSV
- 赤色の抽出
- 図心の計算
- 計算結果の出力
となります。
実装
JPEGデータの受信
カメラの制御とデータの受信はこのドライバを使いました。
https://github.com/yosihisa/C1098-SS_STM32
1枚の画像を全てRAM上にバッファリングします。
JPEGのデコード
JPEGのデコードにはTJpgDecを用いました。
このライブラリはマイコンのような小型組込みシステム用のJPEGデコーダです。RAM使用量が非常に少ないというメリットがあります。
使い方はこちらです。 http://elm-chan.org/fsw/tjpgd/en/appnote.html
TJpgDecのマニュアルの例とは違い、今回画像データはファイルシステム上ではなくRAM上に存在します。
in_func
内では画像データの参照先をずらします。
out_func
にはRGB888にデコードされたデータが渡されてきます。
これをデコード後の画像としてメモリ上に展開してもいいのですが、それだと大量のメモリが必要になります。 (RGB888 VGA の場合、STM32F446REではメモリ不足になったはず)
そこで、out_func
内でHSVカラーに変換し赤色かどうかの判定まで行ってしまいます。
このようにすることで、1ピクセルあたり1bitしか使わないでデータを保持できます。
(この過程で多くの情報が失われてしまいますが、赤色を検出することが目的であるためそのあたりはあまり気にしないこととします。)
HSVカラーへの変換と赤色検出
まず「赤色」をHSV色空間上で定義します。
#define H_MIN_1 0 //固定
#define H_MAX_1 10
#define H_MIN_2 330
#define H_MAX_2 360 //固定
//彩度の範囲
#define S_MIN 50
#define S_MAX 100
//明度の範囲
#define V_MIN 30
#define V_MAX 100
次にout_func
内にHSVへの変換と赤色の判定を実装します。
//JPEGデコーダ出力関数
UINT out_func(JDEC* jd, void* bitmap, JRECT* rect)
{
IODEV *dev = (IODEV*)jd->device;
uint8_t *src;
uint32_t width, height,bitshift;
uint8_t r, g, b;
double h=0, s, v; //H:色相 S:彩度 V:明度
src = (BYTE*)bitmap;
for (height = 0; height < (rect->bottom - rect->top + 1); height++) {
for (width = 0; width < (rect->right - rect->left + 1); width++) {
//バッファのRGB88から各色を抽出
r = (BYTE)*(src + 3 * (height*(rect->right - rect->left + 1) + width));
g = (BYTE)*(src + 3 * (height*(rect->right - rect->left + 1) + width) + 1);
b = (BYTE)*(src + 3 * (height*(rect->right - rect->left + 1) + width) + 2);
//HSV変換
double MAX = max((max(r, g)), b);
double MIN = min((min(r, g)), b);
v = MAX / 256 * 100;
if (MAX == MIN) {
h = 0;
s = 0;
} else {
if (MAX == r) h = 60.0*(g - b) / (MAX - MIN) + 0;
else if (MAX == g) h = 60.0*(b - r) / (MAX - MIN) + 120.0;
else if (MAX == b) h = 60.0*(r - g) / (MAX - MIN) + 240.0;
if (h > 360.0) h = h - 360.0;
else if (h < 0) h = h + 360.0;
s = (MAX - MIN) / MAX * 100.0;
}
if (h > 360.0)h -= 360;
//赤色の判定
if ((h >= H_MIN_1 && h <= H_MAX_1)|| (h >= H_MIN_2 && h <= H_MAX_2)) {
if ((s >= S_MIN && s <= S_MAX) && (v >= V_MIN && v <= V_MAX)) {
//データを保存
bitshift = (rect->left + width) % 8;
dev->RED_bool[rect->top + height][(rect->left + width) / 8] |= (0b10000000 >> bitshift);
}
}
}
}
return 1;
}
RED_bool
が赤色であるかどうかの情報が記録される配列になります。
RGBカラーで定義された各ピクセルの色をHSVカラーに変換し、赤色と定義した範囲内に入っているかどうかを判定します。
そのピクセルが赤色であった場合には、その座標に対応する配列RED_bool
の要素のビットを1にします。
これによって、赤色を1 そうでない色を0で表現した画像データを得られます。
図心(重心)の計算
最後に赤色の中心となる座標を計算します。
本来なら面積の一番大きな図形を探し、その図形のみの図心を計算するという処理を入れるべきなのですが今回は省略します。
赤色の物体がカメラに写る範囲内には1つしかなく、その大きさが十分に大きいという場合にのみ正常に機能します。
今回の方法では、もし元の写真の左端と右端の2か所に赤色の物体が存在した場合、計算によって得られる中心座標は画像の中央になってしまい正確な位置が求められません。
JPEGデコードから図心の計算までの処理は次のようになります。
void decode(myCansat *data) {
//JPEGデコード
JRESULT jpeg_res;
uint8_t work[3100];
JDEC jdec;
data->jpeg.xc = 0; //図心 x座標
data->jpeg.yc = 0; //図心 y座標
data->jpeg.s = 0; //赤色の面積
data->jpeg.io.jpeg_data_seek = 0;
jpeg_res = jd_prepare(&jdec, in_func, work, 3100, &data->jpeg.io.jpeg_data);
memset(&data->jpeg.io.RED_bool, 0, HEIGHT*WIDTH/8);
if (jpeg_res == JDR_OK) {
//デコード開始
jpeg_res = jd_decomp(&jdec, out_func, 0);
if (jpeg_res == JDR_OK) {
//重心計算
TIM2->CNT = 0;
for (UINT h = 0; h < jdec.height; h++) {
for (UINT w = 0; w < jdec.width; w++) {
if ((data->jpeg.io.RED_bool[h][w / 8] & (0b10000000 >> (w % 8))) != 0) {
data->jpeg.xc += w;
data->jpeg.yc += h;
data->jpeg.s++;
}
}
}
data->jpeg.xc /= data->jpeg.s;
data->jpeg.yc /= data->jpeg.s;
}
}
}
以上で赤色の図心の座標( jpeg.xc
,jpeg.yc
)と面積 jpeg.s
を求めることができました。
あとはこの変数の値を表示したり、別のシステムに入力したりするだけです。
実験結果
これと全く同様の処理をWindows上で実装しカメラで撮影した画像を入力した結果がこちらです。
図心と面積のほかに、元画像と赤色の部分を表示する機能を追加しています。
えだまめの画像処理エンジンの確認 pic.twitter.com/XF7fdO4xPx
— よしひさ (@n_yosihisa) 2018年2月21日
STM32F446@180MHz上にこの処理を実装すると、JPEGデータの容量によっても変化しますが、QSVGサイズでカメラから画像の転送時間を含めて1ループ1200ms程度でした。
STM32F767@216MHzでは1ループ380ms程度まで高速化されました。
STM32F7を使う場合、画像処理ではなく転送で律速になるようです。
まとめ
マイコン単体でも画像処理ができた。
できることは限定的であるが、用途と構成によっては十分な速度と性能を得ることができる。