📽️

【Java】Androidのカメラ映像をMotionJPEGで配信する方法

2023/04/16に公開

はじめに

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

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

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

今回は、このMotionJPEGを使用して、Androidのカメラ映像をネットワーク上に動画として配信するアプリケーションの書き方(Java)を説明します。

ソースコード

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

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

ChromeかFirefoxで配信しているAndroid端末に接続すると、カメラ映像が確認できます。

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文字列で判断します。

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

multipart/mixed-replaceのMotionJPEG配信イメージ

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

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

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

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

サンプルソースコード(Java)説明

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

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

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

カメラ映像の取得

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

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

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を発生させることで、中断処理を可能にします。


        //
        // サーバソケットを作成し、指定したポートにバインド
        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とし、それ以外はエラーとしています。


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を返却します。

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){

                }
            }
        }
    }

ブラウザでの動作確認

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

最後に

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

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

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

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

Discussion