Flash : 画像認識でアナログ時計をデジタル時計に変換する
- Posted at: 2008年9月29日 01:40
- Update: 2008年9月29日 05:05

大阪てら子17 「Flashで時計大会」で発表したやつね。webカメラと画像認識を使ってアナログ時計をデジタル時計に変換してみました。
テーマは時計だったんだけど、画像認識を勉強したかったんで、空気読まずに絡めてみたw。ARだし入力は画像かな。アナログ時計の針って直線だよね→直線が検出できて、角度がわかれば、時間の情報って抽出できるんじゃね?→WEBカメラ使えばスペック次第でリアルタイムでもいけるんじゃあ?→あーなんか使えそうなソースあるじゃん。パクろ参考に。って感じ。ちなみに今回の妄想ストーリーは「アンドロイドはアナログ時計を読めるのか?」です。
今回はFlashだけで解決できそうなんで、入力にwebカメラを使うだけ。 で、おおまかな流れはこんな感じ。
・アナログ時計をwebカメラで撮影
・カメラから静止画を1枚キャプチャ
・画像を2値化
・ハフ変換、逆ハフ変換で、画像から直線を抽出
・認識できた直線の角度と長さを取得
・長さで短針、長針を判別
・それぞれの角度から時間に変換
サンプリングする時計を用意する
キャプチャ元の時計は、2値化したときにノイズがのらないように、盤面はシンプル、黒か白で統一されてて、短針長針は反対色、秒針は色が違うものがいいです。

用意したのはBRAUN Quartz AB1のブラック。かわええなぁ。ホワイトが欲しかったけど廃盤らしいんです。とほ。
Hough変換による画像からの直線や円の検出
えーーと。下の図な感じに、原点と任意の直線上にある点(x,y)とを、直角に結ぶ線ρの角度θが決まると、三角形の角度から直線の角度がわかる。。はず。。

まー直線の検出に関しては、そのままズバりな解説をされている記事があったので、そのままAS3に移植しました。詳しくは元記事を読んで下さい(w。
JavaからAS3への移植
元記事のソースはJavaなんですが、これとかを参考にちょっと変えるだけでAS3に移植できます。移植の注意点はこんな感じ。
・型指定された配列は型を無視してArrayに。
・多次元配列の初期化は、forでぐるぐる回してnew Array()。
・多次元配列へのアクセスは同じ。(例)hoehoe[0][0]
・float型はNumberに。short、int型もNumberかintに。
・描画は、BitmapDataにsetpixelにするとか。
JavaとAS3は文法とか実装されてる関数が似てるんで、わりと簡単に移植できるようです。ASでやってみたいことがあるけど、具体的なサンプル欲しいよ、わかんないよ。。って時はJavaのソースを漁ってみると結構当たりが出てくるとかと。
package
{
/**
* AR2CLOCK.as
*
* ハフ変換で直線を検出して、アナログ時計をデジタル時計に変換。
*
*/
import flash.display.Bitmap;
import flash.display.BitmapData;
import flash.display.Sprite;
import flash.events.MouseEvent;
import flash.filters.ColorMatrixFilter;
import flash.geom.Point;
import flash.geom.Rectangle;
import flash.media.Video;
import mx.core.BitmapAsset;
[SWF(width='640',height='480',backgroundColor='0x000000',frameRate='30')]
public class AR2CLOCK extends Sprite
{
[Embed(source="clock2.gif")]
private var imageClass:Class;
private var _templateImage:BitmapData;
private var XMAX:int = 320;
private var YMAX:int = 240;
private var RMAX:int = 60;
private var THETA_MAX:int = 1024;
private var RHO_MAX:int = (int)(Math.sqrt(YMAX*YMAX+XMAX*XMAX)+0.5);
private var PIK:Number = Math.PI / THETA_MAX;
private var COUNT_MAX:int = 2;
private var CAMERA_WIDTH:int = 320;
private var CAMERA_HEIGHT:int = 240;
private var _clockHands:Array = new Array(2);
private var _canvas:BitmapData;
private var _ct:clockTxt;
private var _video:Video = new Video(CAMERA_WIDTH, CAMERA_HEIGHT);
//三角関数テーブル(サイン)
private var sn:Array = new Array(THETA_MAX);
//三角関数テーブル(コサイン)
private var cs:Array = new Array(THETA_MAX);
//半径計算用斜線長テーブル
private var diagonal:Array = new Array(YMAX);
//二次元化した二値原画像データを格納
private var data:Array = new Array(YMAX);
public function AR2CLOCK()
{
init();
}
// いろいろ初期化してから始めましょう
private function init():void
{
//時計のテーブルを作成
for (var j:int=0; j<COUNT_MAX; j++) {
_clockHands[j] = new Array();
}
//三角関数テーブルを作成
for (var i:int=0; i<THETA_MAX; i++) {
sn[i] = Math.sin(PIK*i);
cs[i] = Math.cos(PIK*i);
}
//斜線長テーブルを作成
for (var y:int=0; y<YMAX; y++) {
diagonal[y] = new Array(XMAX);
for (var x:int=0; x<XMAX; x++) {
diagonal[y][x] = (int)(Math.sqrt(y*y+x*x)+0.5);
}
}
// this._templateImage = BitmapAsset(new imageClass()).bitmapData;
main();
}
//ここからメイン
public function main():void
{
//カメラの準備
/*
var camera:Camera = Camera.getCamera();
if (camera == null) {
trace("カメラがないで¥");
return;
}
_video.attachCamera(camera);
this.addChild(this._video);
// 画面をクリックで検出開始
stage.addEventListener(MouseEvent.CLICK, cameraCaptureStart);
*/
// 画像を配置
this._templateImage = BitmapAsset(new imageClass()).bitmapData;
this.addChild(new Bitmap(this._templateImage));
// 画面をクリックで検出開始
stage.addEventListener(MouseEvent.CLICK, imageCaptureStart);
// 確認用の画像を配置するキャンバスを用意
this._canvas = new BitmapData(320, 240, false, 0x0);
var _canvasRect:Bitmap = new Bitmap(this._canvas);
_canvasRect.x = 320;
this.addChild(_canvasRect);
this._ct = new clockTxt();
this._ct.clockText.text = " ";
this._ct.x = (this.stage.stageWidth/2 ) - (this._ct.width/2);
this._ct.y = 240;
this.addChild(this._ct);
}
// カメラからキャプチャする
private function cameraCaptureStart(event:MouseEvent):void
{
var s:BitmapData = new BitmapData(this.CAMERA_WIDTH, this.CAMERA_HEIGHT);
s.draw(_video);
var r:Rectangle = new Rectangle(0, 0, this.CAMERA_WIDTH, this.CAMERA_HEIGHT);
this._canvas.fillRect(r, 0xFFFFFFFF);
// 画像を2値化する
// s = grayscale_filter(s);
// this._canvas.threshold(s, r, new Point(0, 0), "<=", 90, 0xFF000000, 40, false);
this._canvas.threshold(s, r, new Point(0, 0), "<", 180, 0xFF000000, 200, false);
// ソースとなる画像を二次元配列data[y][x]に変換する
changeTo2DDataArray(this._canvas, data);
}
// 画像からキャプチャする
private function imageCaptureStart(event:MouseEvent):void
{
//var s:BitmapData = BitmapAsset(new imageClass()).bitmapData;
var r:Rectangle = new Rectangle(0, 0, this.CAMERA_WIDTH, this.CAMERA_HEIGHT);
this._canvas.fillRect(r, 0xFFFFFFFF);
// 画像を2値化する
this._canvas.threshold(this._templateImage, r, new Point(0, 0), "<", 180, 0xFF000000, 200, false);
// ソースとなる画像を二次元配列data[y][x]に変換する
changeTo2DDataArray(this._canvas, data);
}
// 画像の黒色のみ検出して、結果を二次元配列data[][]に入力
public function changeTo2DDataArray(img:BitmapData, _data:Array):void
{
var width:int = img.width;
var height:int = img.height;
for (var ey:int=0; ey<height; ey++) {
_data[ey] = new Array(width);
for (var ex:int=0; ex<width; ex++) {
if (img.getPixel(ex, ey) > 0xFAFAFA) {
_data[ey][ex] = 1;
} else {
_data[ey][ex] = 0;
}
}
}
houghConvert();
}
// ハフ変換で直線を検出
private function houghConvert():void
{
// Hough変換
// 直線の場合
var theta:int;
var rho:int;
// 直線検出用頻度カウンタ
var counter:Array = new Array(THETA_MAX);
for (var k:int=0; k<THETA_MAX; k++) {
counter[k] = new Array(2*RHO_MAX);
for (var l:int=0; l<(2*RHO_MAX); l++) {
counter[k][l] = 0;
}
}
for (var y:int=0; y<YMAX; y++) {
for (var x:int=0; x<XMAX; x++) {
if (data[y][x]==1){
for (theta=0; theta<THETA_MAX; theta++) {
rho = (int)(x*cs[theta]+y*sn[theta]+0.5);
counter[theta][rho+RHO_MAX]++;
}
}
}
}
// 円の場合
/*
今回はないよ
*/
// Hough逆変換
var end_flag:int; // 繰り返しを終了させるフラグ
var count:int; // 検出された直線または円の個数カウンタ
// 直線の場合
var counter_max:int;
var theta_max:int=0;
var rho_max:int=-RHO_MAX;
end_flag = 0;
count = 0;
do {
count++;
counter_max = 0;
// counterが最大になるtheta_maxとrho_maxを求める
for (theta=0; theta<THETA_MAX; theta++) {
for (rho=-RHO_MAX; rho<RHO_MAX; rho++) {
if (counter[theta][rho+RHO_MAX] > counter_max) {
counter_max = counter[theta][rho+RHO_MAX];
// 60ピクセル以下の直線になれば検出を終了
if (counter_max <= 60) {
end_flag = 1;
} else {
end_flag = 0;
}
theta_max = theta;
rho_max = rho;
// 時計テーブルに検出した線の長さと角度を入れる
this._clockHands[count-1][0] = counter_max;
this._clockHands[count-1][1] = (int)(180/this.THETA_MAX*theta_max+0.4);
//trace("theta_max : " + theta_max + " rho_max : " + rho_max + " Angle : " + (int)(180/this.THETA_MAX*theta_max+0.4) + " counter_max : " + counter_max);
}
}
}
// 検出した直線の描画
// xを変化させてyを描く(垂直の線を除く)
if (theta_max != 0) {
for (x=0; x<XMAX; x++){
y=(int)((rho_max-x*cs[theta_max])/sn[theta_max]);
if(y>=YMAX || y<0) continue;
this._canvas.setPixel(x, y, 0xff0000);
}
}
// yを変化させてxを描く(水平の線を除く)
if (theta_max != THETA_MAX/2) {
for (y=0; y<YMAX; y++) {
x=(int)((rho_max-y*sn[theta_max])/cs[theta_max]);
if (x>=XMAX || x<0) continue;
this._canvas.setPixel(x, y, 0xff0000);
}
}
// 近傍の直線を消す
for (var j:int=-10; j<=10; j++) {
for (var i:int=-30; i<=30; i++) {
if (theta_max+i < 0) {
theta_max+=THETA_MAX;
rho_max=-rho_max;
}
if (theta_max+i >= THETA_MAX) {
theta_max-=THETA_MAX;
rho_max=-rho_max;
}
if (rho_max+j<-RHO_MAX || rho_max+j>=RHO_MAX) continue;
counter[theta_max+i][rho_max+RHO_MAX+j] = 0;
}
}
} while (end_flag == 0 && count < COUNT_MAX);
convetTime(this._clockHands);
}
// サンプリングしたデータを時間に変換
private function convetTime(_clockHands:Array):void
{
var _hour:int;
var _minutes:int;
if (this._clockHands[0][0] < this._clockHands[1][0]) {
_hour = (int)(this._clockHands[0][1]);
_minutes = (int)(this._clockHands[1][1]);
} else {
_minutes = (int)(this._clockHands[0][1]);
_hour = (int)(this._clockHands[1][1]);
}
// 今の時間を表示
trace("NOW " + (int)(_hour/30) + ":" + (int)(_minutes/6));
var string2:String = ((int)(_hour/30)).toString() + ":" + ((int)(_minutes/6)).toString();
this._ct.clockText.text = string2;
}
// グレースケールにするよ
public function grayscale_filter(s:BitmapData):BitmapData
{
var d:BitmapData = new BitmapData(s.width, s.height);
d.applyFilter(s, new Rectangle(0, 0, s.width, s.height), new Point(0, 0),
new ColorMatrixFilter([1/3, 1/3, 1/3, 0, 0,
1/3, 1/3, 1/3, 0, 0,
1/3, 1/3, 1/3, 0, 0,
0, 0, 0, 255, 0]));
return d;
}
}
}
動かしてみる。
(気が向いたら置いとく。)
WEBカメラ版はうまく検出ができないんで、上の時計は使わず画像から入力させてます(w。クリックすると、検出を開始します。赤い線は検出できた直線です。んが、未完成なもんで、いろいろ対応できてません。
・半周分しか時間がわからない。
・6時、12時とか線が重なっていると読めない。
・針が太いと一つの針で複数検出されて誤差が出る。
・カメラが傾いてると正確に読めない。
・解析の速度が遅くて秒針とかむり。
とかとか。時計として機能しないじゃん(w
未完成でデモするもんじゃない。。
「なんとなく動く版」で発表したけど、ちゃんと検出できずに失敗。スライドを用意して、説明するのにもっと時間を割いた方がよかったなぁ。と反省。。
ま、画像認識も動くと楽しい。AR、CVはいろんな認識方法があるんで、いろいろ試していきたいです。なるべくFlashで(w
参考資料: