はじめに
3年ほど前に、現実空間のオセロ盤をOpenCVで認識してiPhoneのARKitで評価値等を重畳表示するという機能を開発しました。画面は以下のような感じです(動画もあります)。
この画像で言うと、盤の枠線(a-h,1-8が描かれた黒枠)や、黒石・白石を表す円、空きマスを表す緑の四角、評価値を表す黄色い数字がARKitで重畳表示したものです。
開発した当時はARKitが出て間もない頃で、ネット上にもあまり情報がなく、かなりの試行錯誤をしながら実装しました。特に座標周りの関係性が訳がわからず苦労したものでした。その知見を当時記事に書いておけば良かったのですが、3年が経った今となってはARKitのための3D数学というような良記事も見つかるようになり、改めて書く意義は薄れてきたように感じます。
しかしながら、当時苦労した内容でまだあまりネット上にも情報がなさそうな部分もありそうなので、アドベントカレンダーを契機に書いてみることにしました(本当は去年も書こうとしたのですが挫折しました…)。
全体フロー
全体の大まかな流れは以下のような感じです。
- ARKitから現在の画面キャプチャとカメラ関連情報を取得
- 取得した画像を
cv::Mat
(OpenCV内の形式)に変換 - OpenCVで画像認識を行い、カメラ情報と合わせて、3次元空間内での盤の位置、石の色と位置(盤面内での)を算出
- 一方で、ARKit,SceneKit,SpriteKitを使用して盤を描画
- 盤の位置情報からAR上での盤の位置を変更
- 石の色・位置情報を元にAR内に石を表示
- 着手が完了したと判断したら形勢評価を行い評価値を取得・表示
実装のポイント
以下、それぞれのステップについて簡単に説明していくことにします。
1. ARKitで画像キャプチャとカメラ情報を取得する
OpenCVで画像認識をしようとしているので、ARKit側から画像情報を取得する必要があります。ARSessionDelegate
のsession(ARSession, didUpdate: [ARAnchor])
を契機に以下のような処理で取得しています。引用したソースコードでは割愛していますが、OpenCVの処理がやや重いので、前回実行時から一定期間内(今回のアプリの場合は500ms)は実行せずにスキップするようにしています。
if let frame = sceneView.session.currentFrame {
// 画面キャプチャを取得し、UIImage形式に変換
let cvImage = frame.capturedImage
// 焦点距離を取得
let focal = CGFloat(frame.camera.intrinsics.columns.0.x)
// 中心点のオフセットを取得
let centerOffsetX = CGFloat(frame.camera.intrinsics.columns.2.x)
let centerOffsetY = CGFloat(frame.camera.intrinsics.columns.2.y)
// 以下略
}
画像はデバイスの向きによらず、ホームボタン方向がx軸となるので注意してください。
また、画像情報と合わせてあとで必要になるカメラ情報(焦点距離と中心点のオフセット)を取得しています。意味としては、の画像の左上を原点として、座標(centerOffsetX, centerOffsetY)の点がカメラの正面で距離focalの位置にあるということになります。これらの値の意味は以下参考URLを参照してください。
2. ARKitから得られたCVPixelBuffer
をOpenCVのcv::Mat
形式に変換する
この記事を書くために色々検索していたら、最近はSwiftでOpenCVを使えるようになっているようですが、当時はObjective-C++からしか使えなかったので、Swift内でCVPixelBuffer
をUIImage
に変換し、Objective-C++に渡してからOpenCVのUIImageToMat()
でcv::Mat
に変換しました。
CVPixelBuffer
をUIImage
に変換する方法は以下のURLを参照してください。
多分このあたりのやり方はググると色々出てくるように思います。
3. OpenCVを使って盤面認識を行う
これについては、昨年のアドベントカレンダーで記事を書きましたのでそちらを参照してください。
ただ、昨年の記事であまり説明していなかった部分がありますので、その辺りを中心に補足しておきます。
先ほどカメラ情報を取得しましたが、画像の左上を原点として座標(centerOffsetX, centerOffsetY)から画像に対して垂直な向きに距離focalの位置にカメラが存在することを意味しています。値は全てpx単位です。なお、ホームボタン方向がX座標なので本当は画像は横長になるはずですが、以下の図では便宜上縦長で示していますのでご注意ください。
前回の記事の盤面認識の手法により、盤の4隅について画像上の座標が求まります。この状況から、実際の盤の位置についてもう少し詳しい情報を得ることができます。上の図で、C,P,Qの3点を含む平面を切り出してみます。
画像の投影面上の盤の隅がPとQだったわけですが、3次元空間上での実際の盤の隅は直線CP,CQ上のどこかにあることになります。また4隅の対角線の交点であるAは画像上の盤の中心です。実際の盤の中心も直線CA上のどこかにあることになります。いったんAを実際の盤の中心だと仮定して話を進めます。
我々は盤が正方形であるということを知っています。この知識を使うと、実際の盤の隅P',Q'はP'A=Q'Aを満たすはずです。C,P,Q,Aの3次元空間での座標は全てわかっていますから、P',Q'も求めることができます。
細かい計算は省略しますが、P'A=Q'Aより三角形CP'Aと三角形CQ'Aの面積は等しく、ベクトル演算でAから直線CP,CQへの距離も求まるので、CP',CQ'の長さも求まり、P',Q'の座標も得られます。
先ほどAを実際の盤面の中心だと仮定しましたが、本当は直線CA上のどこかなので、この座標は定数倍だけずれる可能性があります。最終的にはAR空間上での盤の位置を出すのに使いたいのですが、今求めている空間の座標系の単位はpxであり、実際の空間の単位ではないので、その換算時に定数倍することになりますから、元が定数倍ずれていてもあまり支障はありません。
これをもう一方の対角線についても行えば、4隅の3次元のpx単位系での座標が求まります。
さて、ここまでの話で盤が正方形であるという知識を使いましたが、よく考えると実際に使ったのは対角線の交点が互いに中点で交わることだけしか使っていないので、知識としては盤が平行四辺形であることしか使っていないことになります。
これで得られた空間上の盤の4隅の座標を使って、辺の長さや角度も求めることができます。隣り合う辺の長さが等しいかどうか、角度が90度かどうかを検証することで、盤が正しく抽出できているかどうかの検証として使うことができます。
数学的な話が続きましたが、結局のところ画像認識とカメラ情報と盤が平行四辺形であるという知識だけで、3次元px単位系での盤の4隅の座標が求まったことになります。
4. ARKitで2次元の盤面を表示する
これを実装した当時、あまり情報がなくて苦労したことの1つが、2次元の盤面情報をARKit内で表示する方法です。これは以下のような感じで、マテリアルをSKScene
にしたSCNPlane
のノードをARSCNView
に追加することによって、あとはSpriteKitの描画でできるようになります。
ソースで書くと、以下のようなARBoardNode
を生成しておいて、ARSCNView
のscene.rootNode.addChild()
で追加すればAR空間内に2次元の盤面を描画できます。
// 盤面をSpriteKitで描画するためのSKScene
class ARBoardScene: SKScene {
override init() {
super.init()
self.size = CGSize(width: 768, height: 768) // 盤の大きさ
// 以下略
}
}
}
// 盤面のSCNNode
class ARBoardNode: SCNNode {
// SCNPlane情報
let plane = SCNPlane()
// BoardScene情報
var boardScene: ARBoardScene?
override init() {
super.init()
let material = SCNMaterial()
self.boardScene = ARBoardScene()
material.diffuse.contents = self.boardScene!
self.geometry = plane
self.geometry?.materials = [material]
}
}
さて、SKScene
のサイズを固定値768x768で指定していますが、これはSpriteKitの描画サイズをこのサイズに決めたということです。ARKit内に埋め込まれる時には、SCNNode
内で定義しているplane
(SCNPlane
)のwidth
とheight
を指定することでARKit内での大きさが決まり、768x768のSpriteKitの画面がその大きさで埋め込まれます。
5. ARKit内の盤の位置を変更する
先ほど3つ目のステップで3次元px単位系での盤の座標が求まりました。これを使ってAR空間に描画しているSCNNode
の位置も随時補正していきます。その補正に当たって、まだ課題が2つ残っています。
- 3次元px単位系とARKitのメートル単位系の換算
- 盤の向きの補正
それぞれについて見ていきましょう。
3次元px単位系とARKitのメートル単位系の換算
3ステップ目のところで、盤の3次元px単位系での座標を求めました。これをARKitで言えばカメラのローカル座標系相当ですが、ARKitでは単位はメートルなので1pxが何メートルに相当するのかを求める必要があります。このアプローチは大きく3つ考えられます。
アプローチ1. ARKitのAPIを使用して画面上の盤の4隅の2次元座標から距離を求める
1つ目はARKitのARSCNView
のhitTest(_:types:)
を使用して求めるというアプローチです。これについては、ここまでの実装でARKitからは画像とカメラ情報しか使っておらず、距離検知についての初期化が終わっているかどうかわからないことや、盤の4隅に本当にヒットするかどうかが不安なこと、精度もよくわからないことから採用しませんでした。
なお、このAPIは現在非推奨となっており、raycastQuery(from:allowing:alignment:)
が推奨されているようです。(実装した当時には多分なかったと思います)
アプローチ2. 盤の大きさを仮定して距離を求める
2つ目のアプローチは、撮影対象となっている盤が例えばメガハウス社の公式オセロ盤であることを仮定するものです。既にpx単位系で盤の4隅の座標がわかっているので、盤の1辺の長さをpx単位系で求めることができます。対象のオセロ盤のサイズがわかれば、px単位系をメートル単位系に換算する係数がわかり、盤のメートル単位系での座標が求まることになります。
ただ、対象が必ずしもメガハウス社の公式オセロ盤であるとは限らないので、この方法は不採用としました。対象が限られているなど、前提条件によってはこの方法が一番精度が高いかもしれません。
アプローチ3. カメラの移動距離から逆算する
そこで採用したのが3つ目のアプローチです。今カメラから見たローカル座標のpx単位系で盤の位置がわかっています。そして次の瞬間(約500ms後の次のフレーム)には、またその瞬間の盤の位置がわかります。盤は固定されているはずですから(動かさない限りは)、逆に盤から見たときのカメラ位置の移動距離をpx単位系で求めることができます。
一方で、ARCamera
のtransform
で、world coordinate(世界座標系)で見たカメラの位置がわかります。これも1つ前のときの座標を覚えておいて、次の瞬間の座標と差分を取ることでカメラ位置の移動距離が求まります。しかもこれはメートル単位系です。
同じカメラの移動距離がpx単位系とメートル単位系で求まりました。これらの比率を取ることで、px単位系とメートル単位系の係数が求まります。もっとも、transform
の精度も心配なので、直近の値に比重を置いた移動平均を使用することで精度の向上をはかりました。
以上の手法により、メートル単位系で盤の位置がわかることになります。
盤の向きの補正
盤の位置の反映に当たって、もう1つの課題は盤の向きの問題です。盤と石だけであれば特にここまでの情報で問題はないのですが、冒頭の画像でもあるように1〜8やa〜hの軸も描画しているため、ある瞬間は正面から見ていても、しばらく後には横から見ている可能性があり、そうなった場合も軸の向きは維持したいところです。
そのためには、画像認識で求めた4隅の座標が、前のコマで求めたAR上の4隅のどれと対応しているかを判断して、その結果に応じて画像認識で求めた石の座標の情報を90度なり180度なり回転して使用すれば良いわけです。
これについては、約500msごとにキャプチャしているので、その短期間でそこまで劇的にカメラ位置が変わることはないだろうという想定で、考えられる4パターンの回転対応のうち、どれが一番移動距離が短いかで判定しました。
6. 石や評価値を描画する
ステップ4で盤面描画用にSKScene
を用意しました。なので描画処理についてはSpriteKitの世界になっています。SpriteKitの各種ノードを使って石の円や評価値の数字を配置すれば描画が行われます。
ただし、ここでちょっとしたトラップがあります。ステップ4でSKScene
がARSCNView
に埋め込まれる図を見て気づいた方もいらっしゃるかと思いますが、SpriteKitは通常左下が原点なのに対して、ここでは左上が原点になっています。何故こうなるのかは今ひとつよくわからないのですが、この結果として通常のSpriteKitとはY軸方向が裏返った形で描画されます。石の円の表示などは座標を正しく指定すればあまり問題はないですが、SKLabelNode
で表示する数字など上下対称ではないものを描画する際は注意が必要で、普通に表示すると上下が裏返った形で表示されてしまいます。これについては、以下のようにY方向の倍率を-1にして描画することで対処可能です。
label.yScale = -1.0
7. 評価値を求める
あとは評価値を求めれば、ステップ6の方法で表示することができます。評価値自体はedaxというオープンソースを使用して算出しているのですが、どのタイミングで評価値を求めるかというのが問題です。何を言っているのかというと、盤面認識は約500ms毎に連続的に行っているため、石を置いて挟んだ石を返している途中の状態というのがキャプチャされる可能性が出てきます。手の途中の状況で評価値を表示しても意味はないので、どこで1手が終わったかを判断して、1手が終わったタイミングで評価値を表示するのが望ましいです。冒頭で紹介した動画にもあるように、返し忘れのマークも表示していますので、これも手の途中では出さずに、1手が終わったタイミングで判断して表示したいところです。
これについては、実はステップ3の時に盤上の緑でも黒でも白でもないマスを「不明」マスにしていて、「不明」マスがある時は手が映り込んでいる、つまり返している途中であるとみなして評価値算出をスキップするという手法を使いました。「不明」マスがなくなり、かつ前に不明マスがなかった盤面と比べて石が増えていたら一手進んだとみなし、返し忘れのチェックや評価値の算出を行うという形です。
実際には返している途中で一度手が盤の外に行くかもしれないので、完全な方法ではありませんが、まぁ何となくそれらしい動きはするようになっています。
ついでですが「不明」マスはもう1つ役割を果たしていて、「不明」マスがある≒手が映っているときに盤や石を描画してしまうと手の手前に描画されてしまって気持ちが悪いので、そのマスに石は描画しないのはもちろん、盤も薄くするようにしました(薄くても手前にあるようには見えてしまうのですが、印象の軽減として)。当時オクルージョンとかがサポートされていなかったので(のはず)、自前でそれっぽいことをしてみた感じですね。
あと余談ではありますが、評価値を求める際に使用しているedaxの評価関数については昨年記事を書いておりますので、興味のある方はぜひどうぞ。
おわりに
3年くらい前から記事を書きたかったのですがなかなかまとまらず、ようやく書き上げることができました。雑多な内容の詰め合わせみたいな感じになってしまいましたが、ARKitとOpenCVを組み合わせて使おうとしていたり、AR空間内に2次元の描画を行おうとしていたりする人の参考になると幸いです。