Xamarin.Formsでリアルタイム処理が可能なカメラプレビューのCustomRendererのサンプル

  • 22
    いいね
  • 0
    コメント

Xamarin.Formsでリアルタイム処理が可能なカメラプレビューのCustomRendererを作成しました。いろいろはまりどころ等がありましたのでサンプルソースを使って紹介します。

サンプルソース

CameraPreviewSample

原型
https://github.com/xamarin/xamarin-forms-samples/tree/master/CustomRenderers/View

サンプルの概要

カメラプレビューを表示して毎フレーム処理を行い、その結果をその都度Forms側の画面に返します(サンプルではフレーム数のカウントを画面に表示します)。ページ遷移・タブ移動で画面が非表示になるとプレビューを停止し表示されるとプレビューを再開します。また電源ボタンやホームボタンでアプリがスリープ状態になった場合はカメラのリソースを解放し、復帰した際にリソースを再取得します。プレビュー上でピンチ操作を行うとズームの変更が可能です。
なお、画面は諸事情によりPortrait(縦)固定です。

名称未設定 2.jpg

リアルタイム処理を行うには

iOSもAndroidも基本的に似たような方法です。フレーム毎のイベントを発生させるための何かしらのクラスを継承するなりインターフェースを適用するなりして用意して、それをカメラのしかるべきメソッドでセットして使います。あとは毎フレームのイベントでフレームの画像情報が取れるのでそれを加工するなりなんなりすると良いようです。サンプルではしていませんが、加工した画像をプレビューに反映させたりもできるみたいです。

iOS

CameraPreviewRenderer.cs
private void Initialize ()
{
    //略

    //フレーム処理用
    Queue= new DispatchQueue ("myQueue");
    Output.AlwaysDiscardsLateVideoFrames = true;
    Recorder = new OutputRecorder() { Camera = Camera};
    Output.SetSampleBufferDelegate(Recorder,Queue);
    var vSettings = new AVVideoSettingsUncompressed ();
    vSettings.PixelFormatType = CVPixelFormatType.CV32BGRA;
    Output.WeakVideoSettings = vSettings.Dictionary;

    CaptureSession.AddOutput (Output);

}

上記のようにCaptureSessionのOutputにAVCaptureVideoDataOutputSampleBufferDelegateを継承したクラスをセットすると以下のようにDidOutputSampleBufferメソッドで毎フレームの処理を受ける事ができるようになります。
ここで少しはまったのですがAVCaptureVideoDataOutputSampleBufferDelegateを継承したクラスをprivate変数で利用しているとDidOutputSampleBufferがうまく動きませんでした。publicに変更するとうまく動作したのですが何故なのかは分かりません。
あとここで注意しないといけないのがGC.Collectを入れないとメモリーの警告が出て落ちてしまうことです。環境によるものなのかわかりませんがiPod Touch6では確実に落ちるのでこれで対応しています。

OutputRecorder.cs
public class OutputRecorder : AVCaptureVideoDataOutputSampleBufferDelegate
{
    public CameraPreview Camera { get; set; }
    private long FrameCount = 1;

    public override void DidOutputSampleBuffer (
        AVCaptureOutput captureOutput,
        CMSampleBuffer sampleBuffer,
        AVCaptureConnection connection)
    {

        try {
            // ここでフレーム画像を取得していろいろしたり
            //var image = GetImageFromSampleBuffer (sampleBuffer);

            //PCLプロジェクトとのやりとりやら
            Camera.Hoge = (object)(FrameCount++.ToString());

            //変更した画像をプレビューに反映させたりする

            //これがないと"Received memory warning." で落ちたり、画面の更新が止まったりする
            GC.Collect();  //  "Received memory warning." 回避

        } catch (Exception e) {
            Console.WriteLine ("Error sampling buffer: {0}", e.Message);
        }
    }
}

android

CameraPreviewRenderer.cs
public void Initialize ()
{
    //略

    //フレーム処理用バッファの作成
    int size = previewSize.Width * previewSize.Height * Android.Graphics.ImageFormat.GetBitsPerPixel (Android.Graphics.ImageFormat.Nv21) / 8;
    Buff = new byte [size];
    //フレーム処理用のコールバック生成
    PreviewCallback = new CameraPreviewCallback { CameraPreview = FormsCameraPreview, Buff = Buff };

    Preview.SetPreviewCallbackWithBuffer (PreviewCallback);
    Preview.AddCallbackBuffer (Buff);
}

上記のようにJava.Lang.Objectを継承しつつIPreviewCallbackを実装したクラスをカメラにセットするとOnPreviewFrameメソッドで毎フレームの処理を受ける事ができるようになります。

CameraPreviewCallback.cs
public class CameraPreviewCallback : Java.Lang.Object, IPreviewCallback
{
    private long FrameCount = 1;
    public CameraPreview CameraPreview { get; set;}
    public byte[] Buff { get; set;}

    public void OnPreviewFrame(byte[] data, Android.Hardware.Camera camera) {

        //ここでフレーム画像データを加工したり情報を取得したり

        //PCLプロジェクトとのやりとりやら
        CameraPreview.Hoge = (object)(this.FrameCount++.ToString());

        //変更した画像をプレビューに反映させたりする

        //次のバッファをセット
        camera.AddCallbackBuffer(Buff);
    }

}

SleepやResumeをどう処理するか

ホームボタンでアプリを切り替えたり電源ボタンでスリープした時なんかはカメラのリソースを解放しないと他のカメラアプリが使えなかったりします(特にandroidでは。iOSはカメラがロックされたりはしなかったけどそれでも解放しといたほうが良いかと)
Xamarin.Formsでは電源ボタンでスリープしたり復帰した時の処理がAppクラスのOnSleepとOnResumeで行われるので、ここに解放と復帰処理を書けば良いんですが実際に処理が必要なのはカスタムレンダラー側で…どうしたもんかと。いろいろ考えた結果MessagingCenterを使ってAppクラスからイベントを飛ばすことにしました。

App.xaml.cs
protected override void OnSleep() {
    //CustomRendererのリソース解放処理を発行
    MessagingCenter.Send<LifeCyclePayload>(
        new LifeCyclePayload { Status = LifeCycle.OnSleep }, "");

}

protected override void OnResume() {
    //CustomRendererのリソース初期化処理を発行
    MessagingCenter.Send<LifeCyclePayload>(
        new LifeCyclePayload { Status = LifeCycle.OnResume }, "");
}

Send<T>のTは本当は送信者自身のクラスを指定するのが正しいっぽいですけどAppクラスを送っても仕方ないので代わりに適当なクラスを作ってコンテナ代わりにして使用しました。識別文字列を指定できるんですが文字列で管理は大変なので無視します。型と識別文字列の一致でイベントを識別しているみたいなんですが、そんなに多用する予定はないので型のみで識別してもらうことにしました。

iOS側

InitializeとReleaseメソッドにそれぞれの処理を集めて、メッセージ受信時にしかるべき処理を行わせます。

CameraPreviewRenderer.cs
public UICameraPreview (CameraPreview camera)
{
    MessagingCenter.Subscribe<LifeCyclePayload> (this, "", (p) => {
        switch (p.Status) {
        case LifeCycle.OnSleep:
            //Sleep状態になるときにリソース解放
            Release ();
            break;
        case LifeCycle.OnResume:
            //Resume状態になるときに初期化
            Initialize ();
            break;
        }
    });
}

android側はほぼ同じなので省略します。

画面遷移やタブ切り替えで非表示なった時は

普通は別に放置で問題ないと思いますが、毎フレーム処理を行っている場合、非表示になっただけでは裏で元気に動作してしまいます。この状態だと激重のままで処理を継続することになるので、非表示になった場合はプレビューを停止します。プレビューを停止すればとりあえず毎フレームの処理は止まります。

this.CameraPreviewにはカスタムレンダラーが入っています。CameraPreviewには更新通知プロパティとしてIsPreviewingを用意しているのでこれ経由で各プラットフォーム側の操作を行います。

CameraPreviewSamplePage.xaml.cs
public CameraPreviewSamplePage() {
    InitializeComponent();

    this.Disappearing += (sender, e) => {
        //画面が非表示の時はプレビューを止める
        this.CameraPreview.IsPreviewing = false;
    };

    this.Appearing += (sender, e) => {
        //画面が表示されたらプレビューを開始する
        this.CameraPreview.IsPreviewing = true;
    };
}

iOS側(androidは省略)

IsPreviewingに変更があった場合、それをプラットフォーム側に伝える処理をOnElementPropertyChangedに書きます。

public class CameraPreviewRenderer :ViewRenderer<CameraPreview,UICameraPreview>
    {
        protected override void OnElementPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) {
            base.OnElementPropertyChanged(sender, e);
            if (this.Element == null || this.Control == null)
                return;

            // PCL側の変更をプラットフォームに反映
            if (e.PropertyName == nameof(Element.IsPreviewing)) {
                Control.IsPreviewing = Element.IsPreviewing;
            }
        }
    }
}

IsPreviewingプロパティはセッターでvalueに合わせてプレビューの停止・再開を行う処理を入れています。これでPCL側でプロパティを変更するだけでプレビューの動きを制御できるようにしています。

public class UICameraPreview : UIView
{
    private bool _IsPreviewing;
    public bool IsPreviewing {
        get { return _IsPreviewing;}
        set {
            if (value) {
                CaptureSession.StartRunning();
            }
            else {
                CaptureSession.StopRunning();
            }
            _IsPreviewing = value;
        }
    }
}

プラットフォームからPCLへの通信手段

カスタムレンダラーにBindablePropertyを設定してそれ経由で行うのが良さそうです。
PCL→プラットフォームの場合は上で紹介したIsPreviewingを使ってPCL側からプラットフォーム側のカメラを制御しましたが、同様にBindablePropertyを使って逆方向への通信も可能です。

CameraPreview.cs
public class CameraPreview : View
{
    //とりあえず何かやりとりするプロパティ
    public static readonly BindableProperty HogeProperty = BindableProperty.Create(
        propertyName: "Hoge",
        returnType: typeof(object),
        declaringType: typeof(CameraPreview),
        defaultValue: null);

    public object Hoge {
        get { return (object)GetValue(HogeProperty); }
        set { SetValue(HogeProperty, value); }
    }
}

CameraPreviewのHogeをLabelのTextにバインドします。

CameraPreviewSamplePage.xaml
<AbsoluteLayout HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand">
    <my:CameraPreview IsPreviewing="true" x:Name="CameraPreview"
        Camera="Rear" AbsoluteLayout.LayoutFlags="All" AbsoluteLayout.LayoutBounds="0,0,1,1" />

    <Label  AbsoluteLayout.LayoutFlags="PositionProportional"
            AbsoluteLayout.LayoutBounds="0,0,AutoSize,AutoSize"
            BindingContext="{x:Reference CameraPreview}"
            Text="{Binding Hoge,StringFormat='FrameCount:{0:G}'}" TextColor="#70FFFFFF" />

</AbsoluteLayout>

e.NewElementでForms側のCameraPreviewが取れるのでそれを引き回し

CameraPreviewRenderer.cs
public class CameraPreviewRenderer :ViewRenderer<CameraPreview,UICameraPreview>
 {
     UICameraPreview uiCameraPreview;

     protected override void OnElementChanged (ElementChangedEventArgs<CameraPreview> e)
     {
         base.OnElementChanged (e);

         if (Control == null) {
             uiCameraPreview = new UICameraPreview (e.NewElement);
             SetNativeControl (uiCameraPreview);
         }
     }
}

こんな感じで該当プロパティを更新すると画面のLabelのTextも連動して更新されるようになります。

OutputRecorder.cs
public CameraPreview Camera { get; set; }
private long FrameCount = 1;
public override void DidOutputSampleBuffer (
            AVCaptureOutput captureOutput,
            CMSampleBuffer sampleBuffer,
            AVCaptureConnection connection)
{

    try {
        // ここでフレーム画像を取得していろいろしたり
        //var image = GetImageFromSampleBuffer (sampleBuffer);

        //PCLプロジェクトとのやりとりやら
        Camera.Hoge = (object)(FrameCount++.ToString());

        //変更した画像をプレビューに反映させたりする

        //これがないと"Received memory warning." で落ちたり、画面の更新が止まったりする
        GC.Collect();  //  "Received memory warning." 回避

    } catch (Exception e) {
        Console.WriteLine ("Error sampling buffer: {0}", e.Message);
    }
}

終わりに

なんというかライフサイクルに合わせたカメラの停止・復帰がとにかく大変でした。これでもまだ穴があるかもしれません。androidのライフサイクルでホームボタンを押した場合はSurfaceViewが破棄されるのに電源ボタンでのスリープはSurfaceViewは保持したままという微妙な動きも困りました。無理やりフラグ使ってあれしましたが。これに画面回転も考慮に入れたら胃に穴があきそうなので縦固定にしました。
androidはCamera2使えと怒られまくりますがCamera2はまだ使いこなせそうもないのでCamera1でしばらく耐える予定です。
まぁカメラに関してはページ丸ごとNativeで書いたほうが絶対良いだろうなと思いましたw

参考

https://github.com/xamarin/xamarin-forms-samples/tree/master/CustomRenderers/View
http://blog.studio-taiha.net/archives/128
http://dev.classmethod.jp/smartphone/ios-camera-intro/
http://techbooster.jpn.org/andriod/device/9632/