Unity ARKit Plugin のカメラ映像を使って何かするには

Unity ARKit Plugin を使いながらカメラの映像を使って何かする場合の手順を書きます。この記事ではQRコードの検出を行いますが、他にもOpenCVを使ったり映像の一部の拡大表示をしたりなども同様の手順でできます。

なお、以前に[ARKitで空間共有](https://qiita.com/rakusan/items/3a856714bce3aba0bfa9)という記事を書いているのですが、そこでもQRコードの検出を行なっていて、その部分を詳しく書いたものとなります。また、その記事を書いた当時はカメラ映像のテクスチャから読み取ったQRコードの位置を雑に座標変換していて、画面のアスペクト比が16:9ではないiPhoneXやiPadなどで動かすと適切に動作しない状態でした（現在は修正してあり正常に動作します）。

#使用する Unity ARKit Plugin のバージョン

Unity ARKit Plugin は 1.0.12 以降を使用してください。私が出した[プルリクエスト](https://bitbucket.org/Unity-Technologies/unity-arkit-plugin/pull-requests/19/the-value-of-displaytransform-of/diff)で、この記事に関連する部分のバグが修正されています。1.0.12より古いバージョンでも当該部分を修正して使えば大丈夫です。

#サンプルプロジェクト

ソースコードは次の場所にあります。
https://github.com/rakusan/UnityARKit-displayTransform-example
このサンプルプロジェクトでは、ARKitを使いながらQRコードを検出してQRコードの場所を緑色に塗ります。

![IMG_2992.PNG](https://qiita-image-store.s3.amazonaws.com/0/90023/964f67f9-1b41-8b1e-34f8-a8aa35036e86.png)

#カメラ映像のテクスチャを取得する

```csharp:QRCodeReader.cs
void Update () {
	ARTextureHandles handles = UnityARSessionNativeInterface.GetARSessionNativeInterface ().GetARVideoTextureHandles ();
	if (handles.textureY != System.IntPtr.Zero) {
		ReadQRCode (handles.textureY.ToInt64 ());
	}
}
```

`ARTextureHandles`を使用するときは`GetARVideoTextureHandles()`を毎回かならず呼び出して取得してください。`GetARVideoTextureHandles()`の実装を追っていくとわかりますが、フレームが更新されるたびに異なるテクスチャが返されるので、`ARTextureHandles`を一度だけ取得しておいて使いまわすとハマります。

`ARTextureHandles`のメンバには`textureY`と`textureCbCr`があります。このサンプルプロジェクトではQRコードの検出をするので、輝度の`textureY`だけ使います。

#カメラ映像のテクスチャを使う（QRコードの検出）

上のコードで`ReadQRCode`はネイティブコードで、実装は次のようになっています。

```objc:QRCodeReader.m
static float qrcodeCorners[8];
static volatile BOOL reading = false;

void ReadQRCode(long long mtlTexPtr)
{
    if (reading) return;
    reading = YES;
    
    MTLTextureRef mtlTex = (__bridge MTLTextureRef) (void*) mtlTexPtr;
    CIImage *ciImage = [CIImage imageWithMTLTexture:mtlTex options:nil];
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        CGFloat iw = ciImage.extent.size.width;
        CGFloat ih = ciImage.extent.size.height;
        
        CIDetector *detector = [CIDetector detectorOfType:CIDetectorTypeQRCode context:nil options:nil];
        NSArray<CIFeature *> *features = [detector featuresInImage:ciImage];
        
        if (features.count > 0) {
            CIQRCodeFeature *feature = (CIQRCodeFeature*) [features objectAtIndex:0];
            
            qrcodeCorners[0] = feature.topLeft.x / iw;
            qrcodeCorners[1] = feature.topLeft.y / ih;
            qrcodeCorners[2] = feature.topRight.x / iw;
            qrcodeCorners[3] = feature.topRight.y / ih;
            qrcodeCorners[4] = feature.bottomLeft.x / iw;
            qrcodeCorners[5] = feature.bottomLeft.y / ih;
            qrcodeCorners[6] = feature.bottomRight.x / iw;
            qrcodeCorners[7] = feature.bottomRight.y / ih;
            
            UnitySendMessage("QRCodeReader", "OnReadQRCode", "");
        }
        
        reading = NO;
    });
}

void GetQRCodeCorners(int32_t **cornersPtr)
{
    float *floatArray = malloc(sizeof(float) * 8);
    memcpy(floatArray, qrcodeCorners, sizeof(qrcodeCorners));
    *cornersPtr = (int32_t*) floatArray;
}
```

先ほどの`ARTextureHandles`から取得したテクスチャはMetalのテクスチャなので、`MTLTextureRef`にキャストして使います。CIDetectorを使ってQRコードを検出した結果はピクセル値で返ってくるので、テクスチャの幅、高さで割ってテクスチャ座標に変換します。これは、後述する`displayTransform`を使うにはテクスチャ座標が必要だからです。

なお、Vision.frameworkの`VNDetectBarcodesRequest`でQRコードを検出した場合はテクスチャ座標で結果が得られるので、その値をそのまま使えます。

#座標変換する

```csharp:QRCodeReader.cs
void Start () {
	UnityARSessionNativeInterface.ARFrameUpdatedEvent += ARFrameUpdated;
}

private void ARFrameUpdated(UnityARCamera camera) {
	Matrix4x4 tmp = new Matrix4x4 (
		camera.displayTransform.column0,
		camera.displayTransform.column1,
		camera.displayTransform.column2,
		camera.displayTransform.column3
	);
	displayTransformInverse = tmp.inverse;
}

private Vector3 VideoTextureToViewportPoint(Vector2 videoTexturePoint) {
	Vector4 column0 = displayTransformInverse.GetColumn(0);
	Vector4 column1 = displayTransformInverse.GetColumn(1);
	float x = column0.x * videoTexturePoint.x + column0.y * videoTexturePoint.y + column0.z;
	float y = column1.x * videoTexturePoint.x + column1.y * videoTexturePoint.y + column1.z;
	return new Vector3 (x, y);
}

void OnReadQRCode(string arg) {
	float[] videoTexCorners = GetQRCodeCorners ();

	corners[0] = VideoTextureToViewportPoint(new Vector2 (videoTexCorners [0], videoTexCorners [1]));
	corners[1] = VideoTextureToViewportPoint(new Vector2 (videoTexCorners [2], videoTexCorners [3]));
	corners[2] = VideoTextureToViewportPoint(new Vector2 (videoTexCorners [4], videoTexCorners [5]));
	corners[3] = VideoTextureToViewportPoint(new Vector2 (videoTexCorners [6], videoTexCorners [7]));
}
```

`ARFrameUpdated`が随時呼ばれるので、`displayTransform`を保存しておきます。`displayTransform` は **ビューポート座標からカメラ映像のテクスチャ座標への変換行列** です。そのためここでは逆行列にして保存しています。

`VideoTextureToViewportPoint`が、カメラ映像のテクスチャ座標からビューポート座標へ変換する関数です。ここで`displayTransform`の逆行列を使います。

ネイティブコード側のQRコード検出が終わると`UnitySendMessage`を介して`OnReadQRCode`が呼ばれるので、座標変換をして`corners`にビューポート座標で保存しています。

#おわりに

サンプルプロジェクトでは`OnRenderObject`でビューポートにそのまま描画していますが、得られた座標を元に`UnityARSessionNativeInterface.HitTest`や`Camera.ViewportPointToRay` をしたりすれば、3次元上で扱うこともできます。

また、冒頭でも書いたようにOpenCVを使ったり映像の一部の拡大表示をしたりなどもできます。これは[measuAR](https://itunes.apple.com/jp/app/measuar/id1281850421?l=ja&ls=1&mt=8)というアプリで実際にやっていて、拡大表示はアプリを試して頂くと動作の様子を見ることができます。OpenCVについてはどのように使っているかわかりにくいですが、手ぶれ補正と測定点の自動補正に使っています。