JCodecでh.264ファイルからAndroidのBitmapを取得(任意のフレーム)

JCodecを使って任意のフレームを取得してみます。勘ですぐに気づくと思いますが、nextFrame()をひたすら読みだせばできそうです。 nextFrame() が返すオブジェクトのメンバーに frameNoというメンバもあります。

    //ダメな例
    fun getFrame(pos : Long) : Bitmap{
        val file = File("/sdcard/000.h264")
        val buf = NIOUtils.fetchFromFile(file)
        val es = BufferH264ES(buf)
        var nextFrame  =  es.nextFrame()
        val decoder = H264Decoder()
        val pic = Picture.create(1280, 1024, ColorSpace.YUV420)
        while(nextFrame != null && nextFrame.frameNo != pos){
            nextFrame  =  es.nextFrame()
        }
        val op = decoder.decodeFrame(nextFrame.data, pic.data).cropped()
        return AndroidUtil.toBitmap(op)
    }

これで指定したフレームのビットマップが得られそうに思うじゃないですか。ダメです。h.264は前フレームとの (正確な言い方じゃないけど) 差分を使います。なので、フレームごとにデコードしなければだめです。

    fun getFrame(pos : Long) : Bitmap{
        val file = File("/sdcard/000.h264")
        val buf = NIOUtils.fetchFromFile(file)
        val es = BufferH264ES(buf)
        var nextFrame  =  es.nextFrame()
        val decoder = H264Decoder()
        val pic = Picture.create(1280, 1024, ColorSpace.YUV420)
        while(nextFrame != null && nextFrame.frameNo != pos){
            val op = decoder.decodeFrame(nextFrame.data, pic.data)
            nextFrame  =  es.nextFrame()
        }
        val op = decoder.decodeFrame(nextFrame.data, pic.data).cropped()
        return AndroidUtil.toBitmap(op)
    }

これで取得できるのですが、終わりのほうのフレームが欲しい時に、これでは効率が悪いですよね?ということで、次に続く。

JCodecでh.264ファイルからAndroidのBitmapを取得(最初のフレーム)


DSGViewer を作るためにh.264を扱う必要がありました。h.264なんてMediaCodecで簡単!と思っていたら全然うまくいかず挫折しました。h.264でもmp4なら簡単にできたのですが、h264のrawデータ(というのか?最初が00 00 00 01で始まるやつ)がうまくいかない。

悩んでいても仕方がないので、javaだけでデコードできるライブラリを探したところ、JCodecを見つけて試行錯誤しながら使うことができました。使って分かったことを記録します。JCodecはjavaですが、kotlinでいきます。エラー処理やnullチェックは抜かしています。

まず、使うためにここのGetting startedを参照し、build.gradleを編集します。

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
    //追加 compileをimplementationに置き換え
    implementation 'org.jcodec:jcodec:0.2.3'
    implementation 'org.jcodec:jcodec-android:0.2.3'
}

追加の部分は、そのままコピーするとcompileの部分で警告が出るので implementation に置き換えました。
あと、Bitmapの確認用にMainActivityにはImageViewを置いておきます。

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

    <ImageView
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:srcCompat="@android:color/holo_blue_dark"
            android:id="@+id/imageView"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>
    
</android.support.constraint.ConstraintLayout>

h264から最初のフレームを取り出して表示してみます。

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        imageView.setImageBitmap(getFirstFrame())
    }
    fun getFirstFrame() : Bitmap{
        val file = File("/sdcard/000.h264")
        val buf = NIOUtils.fetchFromFile(file)
        val es = BufferH264ES(buf)
        val nextFrame  =  es!!.nextFrame()
        val decoder = H264Decoder()
        val pic = Picture.create(1280, 1024, ColorSpace.YUV420)
        val op = decoder.decodeFrame(nextFrame.data, pic.data).cropped()
        return AndroidUtil.toBitmap(op)
    }
}

pic = Picture.create(1280, 1024, ColorSpace.YUV420)は、デコードする画像のバッファーが必要ということで、画面サイズとかは仮です。 ColorSpace.YUV420 は、RGBにしたらダメでした。デコードした後にcropped()とやると画像サイズをちゃんと合わせてくれるみたいです。

ひとまずこんな感じ。ディスクから読むのでManifestのパーミッションの設定は忘れずに。次は任意のフレーム取得へ続く。

DSGViewer

またAndroidのアプリを作りました。 DSGViewerです。日立の監視用デジタルレコーダーDS-Gシリーズ及びDS-JHシリーズの録画ファイルを見るためのアプリです。
Windows向けのビューワはあるのですが、Androidでも見れたら便利かなと。

たまたま(?)録画ファイルが手元にあり、調べたらフォーマットはそれほど難しい構造ではないので作りました。jpeg2000とかh.264とかは扱いに困りましたが。

ただ、万人向けでないので、需要はないと思います。

JPEG2000読み込み

AndroidでJPEG2000を読み込む必要があったので調べた結果、jj2000を使うことにしました。しかし、JJ2000ライブラリは、いろいろな派生物があってどれを使っていいか悩みました。

自分は、Androidで使えるBitmapが取り出せるもので、
NFC_DriversLicenseReader のJJ2000を使うことにしました。jj2000自体はjj2000ライセンス(?)ですが、NFC_DriverLicenseReaderはMIT License です。

まず、 ダウンロードしてjj2000フォルダをプロジェクトにコピーします。私は、以下の場所にコピーしました。

ただ、このまま使うとBitmapが白黒になるので、一部修正します。
jj2000/j2k/decoder/Decoder.javaの378行のコメントを外し、次の行をコメントアウトします

      ImgWriterBitmap imwriter = new ImgWriterBitmapPPM(decodedImage,0,1,2);
//      ImgWriterBitmap imwriter = new ImgWriterBitmapPPM(decodedImage,0,0,0);	//モノクロ対応 2012.08.06 TK

あとは、

    val buffer = File("/sdcard/test.jp2").readBytes()
    val bmp = JJ2000Frontend.decode(buffer)

これでBitmapが得られます。参考に、MainActivityを次のように作って確認しました。

import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import jj2000.JJ2000Frontend
import kotlinx.android.synthetic.main.activity_main.*
import java.io.File
import java.io.RandomAccessFile

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val buffer = File("/sdcard/test.jp2").readBytes()
        val bmp = JJ2000Frontend.decode(buffer)
        imageView.setImageBitmap(bmp)
    }
}

以上です。

Android:Handlerで正確に周期的な実行

Handlerを使って 500ms間隔で 処理を実行する場合、以下のようにすると思います。ちなみにkotlinです。

    val handler = Handler()
    val runnable = object : Runnable {
        override fun run() {
            doHeavyWork() //重い処理
            handler.postDelayed(this, 500)
        }
    }

しかし、doHeavyWork()に処理時間が多くかかるばあい、その分の時間を差し引かなくてはいけません。例えば、 doHeavyWork() に300msかかるならpostDelayed(this, 200)にします。いや、そもそも doHeavyWork() にかかる時間は機種ごとに違うし、始めからわかってる事なんてほとんどないでしょう。

そこで、以下のようにします。

    val handler = Handler()
    val runnable = object : Runnable {
        override fun run() {
            val t = SystemClock.uptimeMillis()
            doHeavyWork() //重い処理
            handler.postAtTime(this, t + 500)
        }
    }

こんな感じにすると、いい感じになります。しかし、doHeavyWork()が500ms以上かかる場合はそもそも無理。video再生的なものならフレームをスキップするとか工夫が必要です。

ListViewの中のEditTextで編集できない

ListViewの中に編集可能なTextViewを入れると、IMEが表示されるタイミングでListViewのサイズが変わり、編集中のTextViewからフォーカスが外れる。そして、1文字しか入力されない。

ひとまず、AndroidManifest.xmlのActivityの属性に

android:windowSoftInputMode = "adjustPan"

を加えると、解決しているっぽい。

以前作った同じようなListViewはたまたまサイズが小さくて、IMEが表示されてもリサイズされていないだけだった。

NotificationChannelで通知音が消えない

        val channel = NotificationChannel(channelID, title, NotificationManager.IMPORTANCE_DEFAULT)
        channel.setSound(null, null)

このように、setSound(null, null)で通知音が消えるとあったので試したが、どうしても消えない。通知の優先度を落としても消えない。かなり悩んだが、いったんアプリをアンインストールしたらうまくいった。いったん作ったチャンネルを削除しなければいけないのかな?