JavaCVのOpenCV機能を利用した動画の透過合成・クロマキー合成

## **はじめに**

前回の予告通り、具体的なクロマキー技術についてです。自分への振り返りがてら記事にします。
クロマキー合成の説明をするには欠かせない基本的な動画・画像の知識についても簡単に紹介していきます。

## **その前に:動画、画像に関するポイントの紹介**

本題の前に動画・画像に関するポイントを紹介したいと思います。

### **動画の合成とは？動画合成は画像合成の繰り返し**

動画のクロマキー合成について考える前に、まず動画の合成とは何なのかを一歩踏み込んでみます。

動画とは？⇒フレームと呼ばれる1枚絵情報の組み合わせで作られた連続画と言い換えることが出来ます。(厳密にはコーデックによってフレームの性質が変わるけどここでは省略)

ということは、**動画の合成⇒各フレームの画像を複数種類の画像を合成して作ったもの**と言い換えることが出来ます。
なので、**動画での透過合成やクロマキー合成を考える=1枚画の透過合成やクロマキー合成を考える**ことが出来れば、後は[前回紹介した](https://qiita.com/developer-kikikaikai/items/3a2552b70d1e7fd108ed#javacv%E3%81%A7%E3%81%AEffmpeg%E6%A9%9F%E8%83%BD%E5%8B%95%E7%94%BB%E3%81%AE%E5%85%A5%E5%87%BA%E5%8A%9B)FFmpeg機能の動画出力を使えば、動画合成の完成！ということになります。

以降は画像の合成方法について考えていきます。また、JavaCVのAPIはOpenCVの機能を使っているので、今回紹介する方法はOpenCVでも同等のことが出来ます。

### **画像と色空間について**

生の画像データは、単純に言うと画像データの1点1点に対してここはこの色！と決めていくことで作られます。そのデータをJPEG等の形式に合わせて圧縮したりすることでJPEG等の画像ファイルが出来ます。
じゃあこの生の画像データって、どうやって表現されるのでしょうか？
この色の表現方法、実はこれ！と1つの表現方法が決められているわけではなく、**色空間**と呼ばれる色の表現方法に依存して変わります。大体は1マスが3～4次元配列の形で表現されています。

色空間には例えば以下があります。(参考:Wikipedia)

|色空間名|表現方法|
|:---|:---|
|[RGB](https://ja.wikipedia.org/wiki/RGB)|光の3原色であるRed, Green, Blueの3色の濃度を数値で指定することで色を表現する|
|[HSV](https://ja.wikipedia.org/wiki/HSV%E8%89%B2%E7%A9%BA%E9%96%93)|色相(Hue)、彩度(Saturation・Chroma)、明度(Value・Lightness・Brightness)の3つの成分を数値で指定することで色を表現する|
|[YUV](https://ja.wikipedia.org/wiki/YUV)|輝度信号Yと、2つの色差信号を使って表現される色空間。意味不明だが動画でよく出てくるので紹介|
|RGBA|RGBにαチャンネルと呼ばれる透過度（どれくらい透き通っているか）の数値の合計4種類の数値で色を表現する|

それぞれの色空間の違いは、リンク先の画を見てもらえるとなんとなくならわかると思います(というか難しくてなんとなくしかわからないです:sweat_smile:)。

ここで伝えたいことは、**「画像は多次元配列で表現されること」**、**「色空間が大事なこと」**(同じデータ配列でも、色空間が違えば全く違う意味になる)という2点です。

## **OpenCVの画像合成・基本**

前置きが長くなりましたが、ここからが本題です。まずは画像合成方法を紹介しましょう。

### **OpenCVでは画像をMatという配列で扱う**

[ゴリラになる知識](http://gori-naru.blogspot.com/2012/11/opencvmat.html)から抜粋

>OpenCVにおいて、画像はMat型。
Matは『マトリックス・リローデッド』のMat。
つまり行列。

先ほど画像は多次元配列で表現されると記載しました。その性質を利用して、OpenCVではMatという配列データ+色空間情報をもったデータ型によって画像データを扱っています。

### **OpenCVで画像合成を行う際は、同じサイズのMatを抜き出す必要がある**

恐らく配列のサイズが変わるからか、**OpenCVのAPIではサイズが違うと合成が出来ません。**(確か色空間も合わせる必要があった気がします)
その代わり、OpenCVにはの一部を抜き出すAPIが存在します。なので、こんな感じにOpenCVでは画像の一部を抜き出す⇒抜き出した画像を合成するという順番で合成を行います。

```java:merge.java
//1. RectというeffectMatと同じサイズの型を作る
Rect roi = new Rect(x, y, effectMat.cols(), effectMat.rows());
//2. Rect部分を抜き出したMatを作成する。
Mat settingMat = new Mat(srcMat, roi);

//3. 抜き出した画像を合成する。
settingMat.copyTo(effectMat);

//作ったMatはcloseしとかないとメモリリークするけど、APIの動作がshallow copyだったらcloseしちゃうと死ぬので注意です。
settingMat.close();
roi.close();
```

画にするとこんな感じ。ちょっとはイメージ付くかな？うーん、難しい。

<img width="800" alt="chromaky.png" src="https://qiita-image-store.s3.amazonaws.com/0/252277/b031dc3d-9cd1-2a11-e574-d06d39fd2aae.png">

この3番目の手順の際に一工夫を加えることで、透過合成やクロマキー合成を行うことが出来ます。

## **OpenCVでの透過合成～αブレンドの利用**

αブレンドとは、2つの画像の透過度（どれくらい透き通っているか）をそれぞれ設定した上で画像を合成する方法です。
この機能を利用出来る`addWeighted`というAPIを利用すると、動画の透過度を変更して合成をすることが出来ます。

```java:alpha_blend
/*effectMatが合成したい画像、srcMatが元の画像。
  mSrcWeight, mEffectWeight, mAllWeightがそれぞれとトータルの透過度を指定して、
  指定に合わせた合成MatデータがmDistMatに挿入される。*/
addWeighted(effectMat, mSrcWeight, srcMat, mEffectWeight, mAllWeight, mDistMat);

//合成Matデータを元のMatデータにコピーする。
mDistMat.copyTo(srcMat);
```

こちらを使うと、以下の2つの画像がを組み合わせて、

![000001.jpg](https://qiita-image-store.s3.amazonaws.com/0/252277/aeb0df61-fc2f-32ee-37ee-7dd9621dd6c5.jpeg) ![000061.jpg](https://qiita-image-store.s3.amazonaws.com/0/252277/95725b4c-a99f-c713-7da8-12c6d1083205.jpeg)

こんな感じに黒が透過されて黒が消えた合成画像が出来ます。
![000061.jpg](https://qiita-image-store.s3.amazonaws.com/0/252277/17f6eb5e-47f6-0b3e-f85c-261ef419dbfb.jpeg)

アップロード用にエンコードした画像なのでちょっと汚く見えますが、動くと自然です。

![transfer.gif](https://qiita-image-store.s3.amazonaws.com/0/252277/8a4313a8-ce81-a8dd-d60e-26ea0eba574a.gif)

## **JavaCVでのクロマキー合成**

今回の本題です。特定の色だけを黒色に変更した**マスク画像**と呼ばれる画像を用意し、重ねたい画像からマスク画像の黒部分抜いた上で画像を重ねる方法です。こんなイメージ。

![frame30.jpg](https://qiita-image-store.s3.amazonaws.com/0/252277/7e07f577-4ea2-c063-d80e-651e3dedb9e4.jpeg)**⇒マスク画像⇒**![000030.jpg](https://qiita-image-store.s3.amazonaws.com/0/252277/9bdcfff3-728a-1493-47f3-d240b046048b.jpeg)
**⇒合成⇒**![000090.jpg](https://qiita-image-store.s3.amazonaws.com/0/252277/0bbfbaec-1258-8c85-1de1-c054d5763c60.jpeg)





[お天気コーナーに登場したガチャピンが透明になった放送事故](https://rocketnews24.com/2016/02/05/704964/)をご存知の方はいらっしゃいますか？あれも緑をマスクしたクロマキー合成による珍事です:laughing:

![ガチャピン](https://sociorocketnews.files.wordpress.com/2016/02/gacha4.jpg?w=1024)

JavaCVで色々試した結果、一番綺麗に実現できたクロマキー合成方法は以下となります。(もっとスマートなやり方もありそうですが、一番出力が安定したのはこのやり方でした。)

1. 抜きたい色以外を全て白にする。
2. 画像を白と黒だけの2値(2値化)に変換する(これがマスク画像になる)。
3. 2のマスク画像と元画像を合成する。

ここからはそれぞれの手段を記載していきます

### **1. 抜きたい色以外を全て白にする。**

処理としては単純です。**画像のMat生データを1つづつ参照し、対象の色でなければ白で上書きする。**
色が完全一致したものだけを白抜きすると色の境目がくっきりとしてしまい違和感が出るので、閾値も設定します。
(色空間の扱いを気にしていないのは改善しないといけませんね)

```java:Discoloration.java
    //白。このアプリを作成した際の入力動画色空間はなぜかBGRだった
    private final int[] BGR_WHITE = {255, 255, 255};

    private Mat getDiscoloration(Mat srcMat, int [] colors, int threshold) {
        //BGRなので3色分のデータを用意。alphaブレンドによる透過が使えるならさらに境目をいい感じに出来る
        int[] values = new int[3];

        //元画像を弄りたくないのでcloneでコピーを作る
        Mat retMat = srcMat.clone();

        //Matの生データをなめてWHITEで埋める。サイズ分なめるので処理重め
        UByteIndexer srcIndexer =retMat.createIndexer();
        for (int x = 0; x < srcIndexer.rows(); x++) {
    	    for (int y = 0; y < srcIndexer.cols(); y++) {
                 //getでx, y位置の色を取得することが出来る。
    	         srcIndexer.get(x, y, values);

                 //閾値範囲外？
                 if ( !is_this_in_area(values, colors, threshold) ){
    	            //白抜き
                    srcIndexer.put(x, y, BGR_WHITE);
    	        }
    	    }
        }

    	return retMat;
    }

    //閾値範囲内？
    private boolean is_this_in_area(int [] thiscolors, int [] areacolor, int threshold) {
		if ( (areacolor[0]-threshold < thiscolors[0] &&  thiscolors[0] < areacolor[0] + threshold)
			&& (areacolor[1]-threshold < thiscolors[1] &&  thiscolors[1] < areacolor[1] + threshold)
			&& (areacolor[2]-threshold < thiscolors[2] &&  thiscolors[2] < areacolor[2] + threshold) ){
			return true;
		}
		return false;
    }
```

### **2. 画像を2値化する。**

2値化には`threshold`というAPIを使います。このAPIは色空間がグレースケール(白黒2色の色空間)でないと使えないので、まず1のMatデータ色空間を変換しておきます。

```java:mask.java
    //指定した色以外の範囲を白抜き
    Mat discolorMat = getDiscoloration(effectMat, colors, thresholdValue);

    //グレースケール用Matを作成
    Mat gray=new Mat();
    cvtColor(discolorMat, gray, COLOR_BGRA2GRAY);
    discolorMat.close();
```

その後thresholdで2値化します。

```java:copy.java
    //output処理
    Mat retMat = new Mat();
    //元のMat, 変換後のMat, 閾値, 最大値, タイプ
    threshold(gray, retMat, thresholdValue, maxValue, thresholdType );
```

thresholdTypeの種類はTHRESH_BINARY, THRESH_BINARY_INV, THRESH_TRUNC, THRESH_TOZERO, THRESH_TOZERO_INV, THRESH_OTSU等があり、このtypeによってthresholdValue, maxValueの意味が変わります。
詳細は[こちら](http://pynote.hatenablog.com/entry/opencv_binarization)。式を見るより[こちらのサイト](http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_thresholding/py_thresholding.html)に乗っている画像で実際に比較をしてもらうのがわかりやすいですね。

![threshold](http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/_images/threshold.jpg)

今回の方法の場合、抜きたい対象の色以外は既に白抜きしちゃってるので、THRESH_BINARYとTHRESH_TOZEROくらいしか効果がないです。(ほんとは大津メソッドを使いたかった)
最初にイメージで出したマスク画像は、THRESH_BINARY指定のthreshold 125で作成した画像になります。

### **3. 2のマスク画像と元画像を合成する。**

こちらはcopyToを使います。第2引数でマスク画像を指定すると、例に出した画のようにマスク箇所を抜いた状態の画像をコピーしてくれます。

```java:copy.java
    threshold(gray, retMat, thresholdValue, maxValue, thresholdType );
    effectMat.copyTo(srcMat, retMat);
```

## **透過合成とクロマキー合成、どっちがいいの？**

試した結果、元動画によって合う方法が違いました。例えば透過合成の例で出したキラキラしたやつは透明な部分が多いので、クロマキーを使うと透明な部分が黒抜きされて、ちょっと汚い丸粒画像になってしまいます。

<img width="400" alt="chromaky.png" src="https://qiita-image-store.s3.amazonaws.com/0/252277/3a9ca533-9df9-f162-8389-f6082d1ee76d.png">

逆に燃えてる動画はくっきりした画像じゃないと面白くないのに、透過合成を使うとなんのこっちゃわからない画像になっちゃいます。
<img width="400" alt="chromaky.png" src="https://qiita-image-store.s3.amazonaws.com/0/252277/0503a70f-f007-6e46-6c11-1698aead5961.png">

処理自体にも改善点が沢山あると思いますが、合成したい動画の特性によって合成の仕方が選択できるようになっていると面白い気がします。

## **おまけ 何故JavaCVのFFmpeg機能で合成しなかったのか？**

FFmpegのfilter機能でサイズの変更などは出来るのですが、画像エンコードを伴う処理を行うと、途端に滅茶苦茶な動画になってしまいます(恐らく色空間の問題)。
例えば重ねたいものがこんな動画だとします(filter機能を使ったデータが手元にこれしかなかった)

<img width="320" alt="normal.gif" src="https://qiita-image-store.s3.amazonaws.com/0/252277/394ba68b-6cd5-ee84-412d-14365d5c6ef4.gif">

こちらを[以前に紹介したfilter](https://qiita.com/developer-kikikaikai/items/47a13cbcb6fdb535345a#%E3%82%AF%E3%83%AD%E3%83%9E%E3%82%AD%E3%83%BC%E5%90%88%E6%88%90)を使って黒抜きしたところ、こんな感じで謎の変化を遂げてしまいました。

![normal_overlay.gif](https://qiita-image-store.s3.amazonaws.com/0/252277/7cad4c6e-7b63-bf43-4c34-724bc98b37a5.gif)

これはまだ形を保っているからいい方で、元動画の色空間にあたりをつけて色々変更してみても、数個に分割された動画になったり動く黒枠がピンク・緑・灰色に分割されたり、何をどう試してもまともな動画にすらなりませんでした。(Stack Overflowなんかでは成功例を見かけたんですけどね…)
トライ＆エラーをいくら繰り返しても前に進まなかったので、思い切って自作することにしました。

## **JavaCVバージョン**

JavaCV: 1.3.3
opencv in JavaCPP: 3.2.0-1.3
ffmpeg in JavaCPP: 3.2.0-1.3

## **参考**
[ OpenCVでまずやること『Mat』](http://gori-naru.blogspot.com/2012/11/opencvmat.html)
[【放送事故】生番組のクロマキー合成でガチャピンが消える珍事発生!!](https://rocketnews24.com/2016/02/05/704964/)
[OpenCV - 画像の2値化について](http://pynote.hatenablog.com/entry/opencv_binarization)
[画像のしきい値処理](http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_thresholding/py_thresholding.html)
Wikipedia各種

ベース知識の参考[デジタル画像処理(書籍)](https://www.amazon.co.jp/%E3%83%87%E3%82%A3%E3%82%B8%E3%82%BF%E3%83%AB%E7%94%BB%E5%83%8F%E5%87%A6%E7%90%86/dp/4903474011)