Help us understand the problem. What is going on with this article?

Androidのカメラ映像をMotionJPEGで配信する

More than 1 year has passed since last update.

はじめに

こんにちは。Daddy's Officeの市川です。

長いこと監視カメラソフトの開発を続けていますが、未だによく利用するのがMotionJPEG形式での動画配信です。
その理由は、実装が楽で処理負荷も低く、監視カメラ用途では十分な性能が出るためです。

特に、監視カメラでは、各フレーム映像に対して何かしらの処理を行う場合が多いため、任意のフレームを自由に切り出すことができるMotionJPEG形式は、現在でも各社の監視カメラのネットワーク配信フォーマットとして採用されています。

今回は、このMotionJPEGを使用して、Androidのカメラ映像をネットワーク上に動画として配信する方法を説明します。

ソースコード

Androidサンプルの全ソースコードはこちらのGitHubに置いてあります。

サンプルを実行すると、背面カメラの映像を10080ポートから、MotionJPEG形式で配信します。

ChromeかFirefoxで配信しているAndroid端末に接続すると、カメラ映像が確認できます。
(http://xxx.xxx.xxx.xxx:10080という感じで)

MotionJPEGとは

細かい解説はWiKiペディア Motion JPEGを参照してください。

簡単に言うと、JPEG画像の連続フレームで構成されたものがMotionJPEGです。

ただ、そのまとめ方(コンテナ)に関しては、定型フォーマットが存在していません。
例えばビデオカメラなどではAVIコンテナにJPEGとLPCMを入れて、MotionJPEGと表記している物もありますし、MOV形式のMotionJPEGもあります。

ネットワークカメラなどのHTTP上のストリーミング形式のものは、Content-Type: multipart/x-mixed-replaceを使用して複数のJPEGを連続して送るフォーマットをMotionJPEGと表記しています。

つまり、JPEG画像の連続フレームで構成されたものは、すべてMotionJPEGと名乗っている状態です。

Panasonicなど、主要メーカ製のネットワークカメラは、ほとんどがHTTP上のmultipartストリーミング形式になっており、私が開発しているLiveCapture3でもこのmultipart方式で配信しています。

Multipart方式のMotionJPEGフォーマット

HTTP上でMotionJPEGを配信するのに使用される、multipart/x-mixed-replaceというのは、Netscape社が提唱した、サーバからのプッシュ配信の為のもので、クライアント側は、新たなpartが送られてきたら、表示中のものを破棄して新しいPartを表示する、という仕様になっています。

MotionJPEGを配信する場合、配信サーバはこのmultipart/mixed-replaceを使用して、クライアントがコネクションを切断するまでJPEG画像を流し続けます。

各JPEG画像の区切りの判断は、通常のmultipart同様、Content-Typeに記載したboundary文字列で判断します。

サーバからのレスポンスを図にすると、こんな感じです。
image.png

ただ、このmultipart/mixed-replaceは、あまり広まりませんでした。
その為、すべてのブラウザでこのContentTypeが処理できるとは限りません。

私の手元のPC(Windows10)では、ChromeとFireFoxでは再生できましたが、IEとEdgeでは再生できませんでした。
※AndroidのChromeでも再生できました。

ということで、この形式のMotionJPEGを表示させるためには、サーバが一方的に送りつけてくるコマ送りJPEGを遅延なく受信⇒デコード⇒表示を繰り返し処理可能な能力をもったクライアントアプリケーションを作る必要があります。

ただ、今回は配信側の説明のみを行いますので、動作確認は上記のMotionJPEG再生可能なブラウザを使ってください。

サンプルソースコード説明

Androidサンプルの全ソースコードはこちらのGitHubに置いてあります。

以下、各クラスの説明です。

クラス名 説明
CameraPreview カメラの画像を取得するSurfaceを保持したクラス
MJpegServer MotionJPEGを配信する簡易HTTPサーバクラス
MainActivity CameraPreviewが保持するプレビュー用Surfaceを張り付けるActivity
PermissionConfirmActivity カメラ権限確認用Activity

カメラ映像の取得

古い端末でも動くように、カメラ映像の取得にはCamera APIを使用しています。
カメラ映像の取得は他にもサンプルがたくさんありますので、詳しくはそちらを参照してください。

ポイントは、カメラ映像フレームの取得処理です。
android.hardware.cameraのsetPreviewCallbackWithBufferを使用して、カメラのフレーム映像を取得していますが、取得できるフレーム画像をJPEGに変換する必要があります。
取得できるカメラ映像の色空間はYUV系になるので、それをandroid.graphics.YuvImageを使用してJPEGに変換しています。

CameraPreview.java
    private byte[] convertYuv2Jpeg(byte[] yuvData, int format, int w, int h) {
        byte[] jpegData = null;

        if (yuvData != null) {
            try {
                YuvImage yuvimage = new YuvImage(yuvData, format, w, h, null);
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                yuvimage.compressToJpeg(new Rect(0, 0, w, h), 70, baos);
                jpegData = baos.toByteArray();
                baos.close();
            }
            catch (Exception e) {
                e.printStackTrace();
            }
        }
        return jpegData;
    }

MotionJPEG配信サーバ起動

MotionJPEGの配信はHTTPで行いますので、簡易的なHTTPサーバを作っています。

MJpegServerのstartをコールすると、クライアント接続用の待ち受けスレッドが起動します。
そのスレッド処理の頭で、サーバソケットを作成し、指定されたポート番号にバインドして、クライアントからの接続を待ちます。

JavaのSocketでは、クライアントの待ち受けを行うacceptを強制停止することができません。
そのため、acceptを行うサーバソケットにsetSoTimeoutでタイムアウト値を設定して、定期的にSocketTimeoutExceptionを発生させることで、中断処理を可能にします。

MJpegServer.java
        //
        // サーバソケットを作成し、指定したポートにバインド
        socket = new ServerSocket();
        socket.setReuseAddress(true);
        socket.setSoTimeout(500); // 500msecでタイムアウト
        socket.bind(new InetSocketAddress(mPort));

        //
        // クライアントの接続を待つ
        while (isRunning()) {
            Socket clientSock = null;

            try {
                clientSock = socket.accept();
                talkToClient(clientSock);
            }
            catch (SocketTimeoutException e) {
                // ソケットタイムアウト
            }
            catch (Exception e) {
                e.printStackTrace();
            }
        }

クライアント接続

接続してきた各クライアントとの処理は、別のスレッドを起動して行います。

今回のサンプルでは、HTTP GETリクエストであればすべてOKとし、それ以外はエラーとしています。

MJpegServer.java
    private void talkToClient(final Socket socket){
        new Thread(new Runnable() {
            @Override
            public void run() {
                try{
                    //
                    // リクエストヘッダの解析
                    BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"));

                    String line;
                    if ((line = in.readLine()) == null){
                        return;
                    }

                    String [] commands = line.split(" ");
                    if (commands.length < 2){
                        return;
                    }

                    //
                    // GETコマンド以外はエラーにする
                    if(commands[0].compareToIgnoreCase("GET") == 0) {
                        responseForVideo(socket);
                    }
                    else{
                        BufferedOutputStream out 
                            = new BufferedOutputStream(socket.getOutputStream());
                        String response = "HTTP/1.1 400 Bad Request" + CRLF;
                        out.write(response.getBytes("US-ASCII"));
                        out.close();
                    }
                }
                catch(Exception e){
                    e.printStackTrace();
                }
                finally{
                    try{
                        socket.close();
                    }
                    catch(Exception e){

                    }
                }
            }
        }).start();
    }

MotionJPEG送信

クライアントソケットから、すべてのリクエスト情報を読みだした後に、レスポンスとして、MotionJPEGを返却します。

MJpegServer
    private void responseForVideo(Socket socket){

        BufferedOutputStream out = null;

        try{
            socket.setTcpNoDelay(true);
            socket.setSoTimeout(3000);

            //
            // レスポンスヘッダーを返却
            out = new BufferedOutputStream(socket.getOutputStream());
            String header = "HTTP/1.1 200 OK" + CRLF;
            header += "Content-Type: multipart/x-mixed-replace; boundary=--myboundary" + CRLF;
            header += CRLF;

            out.write(header.getBytes("US-ASCII"));
            //
            // multipartの各パートを生成&返却
            while(isRunning()){
                //
                // パートヘッダー
                header = "--myboundary" + CRLF;
                header += "Content-Length: " + String.valueOf(mFrame.length) + CRLF;
                header += "Content-type: image/jpeg" + CRLF;
                header += CRLF;
                out.write(header.getBytes("US-ASCII"));
                //
                // JPEG画像
                out.write(mFrame, 0, mFrame.length);
                //
                // パート終了(\r\n)
                out.write(CRLF.getBytes("US-ASCII"));
                //
                // 少しお休み
                Thread.sleep(100);
            }
        }
        catch(Exception e){
            e.printStackTrace();
        }
        finally {
            if (out != null){
                try{
                    out.close();
                }
                catch(Exception e){

                }
            }
        }
    }

接続が切れるまで、繰り返しMotioJPEGを送信し続けます。

ブラウザでの動作確認

Windows10上のChromeで、サンプルプログラムを起動したAndroidに接続すると、以下のようにカメラのライブ映像が表示されます。

image.png

最後に

ソースコードを見てもらえればわかりますが、MotionJPEGであれば、非常に単純なプログラムで動画配信ができます。

若干のカクツキなどが生じますが、表示するクライアント側のプレイヤーの開発もJPEGを表示するだけでOKなので、用途によってはとても有用なフォーマットだと思います。

また、最近はクラウドを介したサービスが多くなる中、サーバ側の運用費を抑えた動画配信サービスの開発も可能です。

MPEGライセンスなどの問題もなく、編集がやりやすいなど、MotionJPEGはまだまだ使える動画フォーマットだと思いますので、いろいろと試してみてください!

Yukio-Ichikawa
Daddy's Office代表 Windowsアプリケーション、クラウドアプリケーション、スマホアプリケーション開発を行っています。
https://lc3.daddysoffice.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした