根性を用いてUWPでバーコードスキャナを実装する

この記事はWindows Phone / Windows 10 Mobile Advent Calendar 2015の14日目の記事の(予備と)して書かれました。

余談:

本当は楽に済まそうと思ってLumia950のレビューを書こうと思っていたのですが、Cloveが11月末入荷予定からずるずると出荷を遅らせ、最終的に届いたのが11/12でした。小心者の私はLumiaが届かないだろうと思ってこのエントリをすでにバリバリと書き始めてしまったので、Advent Calendarにはこちらの記事を投稿することにしました。結局ギリギリになって950も届いたがためにレビューの執筆も間に合ってしまいました(つらい日曜日だった)。よろしければあわせてご覧ください。

http://blog.naotaco.com/archives/979

本題

まともなNFCがiPhoneに載る気配がないせいか、現代においてもQRコードはとても広く使われています。自分のWindowsPhone/Windows 10 mobileアプリでもQRコードを読みたくなるのが人情というものでしょう。今年のAdvent calendarのトップバッター@tanaka_733さんのエントリをみる限りWP8.1だと比較的楽に実装ができるそうですが、我らがUWP勢にそのような甘えは許されません。 1

必要なものはこちら:

  • 根性
  • W10M端末, もしくはWindows 10 desktop + WebCam
  • ZXing.Net

どうやっていいかわからなかったので、まずはカメラのプレビュー映像を表示して、そこから一定間隔で画像を抜き取り、それをZxing.Netに投げるという方式で進めることにします。作りが雑すぎて故郷の母にはとても見せられないような感じになりますが、強い気持ちで乗り越えてください。

さて、まずはXAMLに置いたUIElementにカメラのプレビュー画像を表示させます。Androidで言うとSurfaceViewに描画するような感じです 2。とにかくMicrosoft謹製のサンプルがあるので、そこのCameraGetPreviewFrameを全力で参考にします。

作ったサンプルコードのソースはGitHubに上げておきました。

これを動かすためにはZXing.Netを入れる必要がありますが、たぶんVisualStudioが勝手に解決してくれると思います。あと、unsafeなコードを使っているので、プロジェクトのプロパティ→ビルドでunsafeコードを許可しています。自作プロジェクトで動かすときはここにチェックを入れましょう。ビルド設定ごとなので、ARM/x86、Debug/Releaseそれぞれチェックしてあげる必要があって面倒ですね。

MediaCaptureを使ってPreviewが表示されれば、あとは画像を抜き出してZXing.Netに渡すだけです。以下、画像を抜き出してデコードするところの抜粋。


        private async Task FindQrCodeFromVideoFrame()
        {
            if (_mediaCapture == null) { return; }

            // Get information about the preview
            var previewProperties = _mediaCapture.VideoDeviceController.GetMediaStreamProperties(MediaStreamType.VideoPreview) as VideoEncodingProperties;

            // Create the video frame to request a SoftwareBitmap preview frame
            var videoFrame = new VideoFrame(BitmapPixelFormat.Bgra8, (int)previewProperties.Width, (int)previewProperties.Height);

            // Capture the preview frame
            VideoFrame currentFrame = null;

            try
            {
                using (currentFrame = await _mediaCapture.GetPreviewFrameAsync(videoFrame))
                {
                    // Collect the resulting frame
                    var FoundString = Decode(currentFrame.SoftwareBitmap);
                    if (FoundString != null)
                    {
                        ScanResult.Text += Environment.NewLine;
                        ScanResult.Text += FoundString;
                    }
                }
            }
            catch (Exception e)
            {
                Debug.WriteLine("Caught exception during reading current frame. Maybe device is busy or not initialized yet ...");
                Debug.WriteLine(e.Message);
            }

        }

        BarcodeReader barcodeReader = new BarcodeReader();

        public string Decode(SoftwareBitmap image)
        {
            var bitmap = new WriteableBitmap(image.PixelWidth, image.PixelHeight);

            image.CopyToBuffer(bitmap.PixelBuffer);

            var result = barcodeReader.Decode(bitmap);
            if (result != null)
            {
                return result.Text;
            }
            return null;
        }

このような感じで、画像を抜き出してBitmapで得てZXing.Netに渡すとデコードした文字列を得ることができます。サンプルでは上のFindQrCodeFromVideoFrameをTimerで300msごとに呼んで自動化していますが、ボタン押下や画面タップなどのユーザ操作と紐付けてもよいでしょう。同じAPIでデスクトップPCのWebcamとW10Mのカメラが扱えるのはさすがですね。

2015-12-13
Windows 10 (desktop)

ただしVideoPreviewを利用しているので、上のサンプルのpreviewProperties.Width/Heightに入っているサイズの画像しか取得できません。Lumia930では1920×1080、手元のWebcamでは640×480です 3。なので、この画像サイズで読める程度にQRコードが写っている必要があります。どうしてもこの制約を回避したい場合は、静止画撮影をする必要がありそうです 4

説明としてはこれで終わりなんですが、実際やってみたらはまったところがあるのでポイントを書いておきます。

Capability

MediaCaptureのAPIはデフォルトだと映像と音声の両方を流してくるので、WebcamとMicrophone、2つのCapabilityが必要です。しかしQRコードを読むアプリとしては若干ユーザに不安を与えるような気もするので、できればWebcamのみにおさえたいところ。

MediaCaptureの初期化(MediaCapture#InitializeAsync)に渡すMediaCaptureInitializationSettingsのStreamingCaptureModeをStreamingCaptureMode.Videoにすることで、Microphoneが不要になります。以下、抜粋。

                _mediaCapture = new MediaCapture();

                var settings = new MediaCaptureInitializationSettings
                {
                    VideoDeviceId = cameraDevice.Id,
                    StreamingCaptureMode = StreamingCaptureMode.Video, // これ
                };

                try
                {
                    await _mediaCapture.InitializeAsync(settings);
                }
                catch (UnauthorizedAccessException)
                {
                    Debug.WriteLine("Access denied");
                }

画面回転の問題

Desktopでは気にならないんですが、mobileの場合はカメラは端末のPortrait方向に対して正立していない場合が多いです。このまま使うと、いきなり画面が90度傾いている 5ばかりか、端末を回したときの画面回転とあわせると堪え難い動きになります。

IMediaEncodingPropertyに回転角を指定することができる 6ので、これを画面が回転するたびに設定してやるとよいです 7

Rotation指定あり:

Rotation指定なし:

 

表示がばたついてるのはご愛敬ということで。

で、このようなコードを意気揚々と入れたところ、いきなりデスクトップ版でカメラが動かなくなりました。原因はRotation設定のところで、どうもこれを私のWebcamに設定するとカメラが黒画面のままになるようです。なんだよそれ。友人のところでは動いてるらしいので、Webcamのデバイスに依存して設定してよいかどうかがデバイス依存という可能性が。設定可否を判断する方法がいまはわからないので、とりあえずDesktopのWindowsだったらRotation設定はしないというコードにしておきました。まあ回転する必要はないしね?こうなるとタブレットあたりが気になってきますが、手元にデバイスがないので手が出せず。だれか情報ください。もしくはSurface Pro4をください。

        private const string WINDOWS_DESKTOP = "Windows.Desktop";

        private async Task SetPreviewRotationAsync()
        {
            // 省略 ...

            // On Desktop, this setting may stop some of webcams.
            if (AnalyticsInfo.VersionInfo.DeviceFamily != WINDOWS_DESKTOP)
            {
                var props = _mediaCapture.VideoDeviceController.GetMediaStreamProperties(MediaStreamType.VideoPreview);
                props.Properties.Add(RotationKey, rotationDegrees);
                await _mediaCapture.SetEncodingPropertiesAsync(MediaStreamType.VideoPreview, props, null);
            }
        }

オートフォーカス

上でも述べましたが、デバイスによっては認識させるためにはある程度カメラをQRコードに近づける必要があります 8。ただそうするとピントが合わなくて困るので、オートフォーカスを動かしたくなります。これについては当然デバイス依存があるので、オートフォーカス実行の可否をFocusControl.Supportedで判断できます。私の古いLogicoolのWebcamでは案の定unsupportedだったので、これを使うとだいぶ大きいQRコードしか読めません。厳しい。他方Lumia930では近寄ってもピントが合う上に解像度が高いので、だいぶ小さなものまで読めます。いい感じ。

        async void TryToFocus()
        {
            if (_mediaCapture == null) { return; }

            var focusControl = _mediaCapture.VideoDeviceController.FocusControl;
            if (focusControl.Supported)
            {
                await focusControl.FocusAsync();
            }
        }

このメソッドを呼んでやるといい感じにフォーカスが動いて、手前にあるものにピントを合わせてくれます。今回のサンプルでは2000msごとに自動で呼んでいます。画面タップでピントを合わせようとするユーザもいると思うので、タップでも同様に動くようにしてあげるとそれっぽい挙動になります。

追記(2016/04/27)

サンプルにIssue報告をもらった。もしかしたら環境かOSのバージョンに依存するのかもしれないが、たしかに起動すると落ちる。最初に書いたときはこんなことなかったような気がするのだが、確かに下のようなExceptionで落ちる。

System.Exception was unhandled by user code
HResult=-1072875854
Message=The request is invalid in the current state. (Exception from HRESULT: 0xC00D36B2)

 

調べてみると、どうもMediaCapture#StartPreviewAsyncを呼んだあとしばらくGetPreviewFrameAsyncを呼んではいけない区間があるらしい(300-500msくらい)。なんだよそれ。それならStartPreviewAsyncが返らないようにしておいていただきたいものだが……

結局、0.5秒後には問題なくアクセスできるので、GetPreviewFrameAsyncをtry/catchで囲んで終わらせることにした。タイマで何度も呼ばれるような作りだからこれでよいが、処理が1回しかキックされないような作りだったらリトライみたいな実装が必要になりますね。なんだかなあ。

記事中のコードやGitHubのサンプルは修正済みです。ううむ。

まとめ

たいへん泥臭いですが、これでQRコードリーダを自分のアプリ@UWPに実装することが出来るようになりました。誤記・つっこみなどあれば是非コメントやメール、twitterでのリプライ(@naotaco)でお知らせいただけると幸いです。

明日は@jugemsanさんです。お楽しみに。

 

 

Notes:

  1. ていうかZXing.MobileのMobileBarcodeScannerとか今知ったよ……。
  2. 最近は違うのかもしれないですが。。
  3. このあたりは実際のカメラのHW制約とか、OSのセキュリティ的な理由もあるんだとは思いますが、おそらくどこでも動画のサイズ程度の小さい画像しか得られないでしょう。
  4. 画像は大きくなるしまた別の面倒なポイントがいろいろありそう
  5. Lumia930の場合
  6. できるとはいってもGUIDを指定する構造になっているので面倒
  7. ただ実際には回転角と画面のOrientationを紐付ける泥臭いコードを書く必要があってだるい。。
  8. Lumia930だと画像サイズも大きいし映像もはっきりしてるので認識精度も高そうですが