見出し画像

Go+GoCVでクロスプロセス写真風の加工をしてみる


こんにちは、株式会社SHIFT、自動化エンジニアの水谷です。

前回はOpenCVをGo言語から使うためのパッケージ、GoCVを導入してみました。OpenCVは、C++やPythonから使うことが多いのですが、Goからでも十分に使えることがわかりましたので、せっかくなのでもう少し遊んでみようと思います。

私は趣味でよく写真を撮るのですが、フィルム時代のひとつの技法(どちらかというと遊びのひとつ?)として、クロスプロセス現像というものがありました。

これは、ネガフィルムを現像する時に、あえてポジフィルムの現像液で現像処理したり、逆にポジフィルムを現像する時にネガフィルム用の現像液で現像することで、本来の色合いとは違った、不思議な色合いの写真ができあがる、というものです。空の色が緑っぽくなったり、黄色っぽくなったり、あるいは全体的に赤や紫色を帯びた現実離れした世界が見れて楽しいのですが、フィルムの種類によっても、現像液の種類によっても、仕上がりは変わるので、かなりのギャンブルだったりもします。私も、現像キットを使ってやってみたはいいものの、思ったような面白い写真ができずにガッカリした思い出があったりもします。

最近ではデジカメでクロスプロセスっぽい写真をカメラ内で作ってくれる機能がついていたりします。とても手軽で良いのですが、これは特定のアルゴリズムで加工されたものであり、これもまた当たり外れがあるように思います。

ということで、今回はかなり個人的な趣味に走ってしまうのですが、クロスプロセス現像したかのような感じで、かつ自分好みな色合いの画像を生成するプログラムをGoとGoCVで作ってみようと思います。

クロスプロセスのアルゴリズムは・・・

クロスプロセスっぽい画像を作成するプログラムを作る、と言ってみたはいいものの、どのように色が変わるかはフィルムや現像液によるので、決まった式で計算できるものではありません。そこで、適当に色をいじってみて、それっぽい写真になるような計算式を模索していくことになります。

思いつくのは、RGBの各チャンネルのうち、特定の色を大胆に減らすこと、それから、HSV色空間上で色相をずらしてみることでしょうか。

※HSVは、RGBとは異なり、色相(H)、彩度(S)、明度(V)の3パラメーターで色を表す色空間で、このうちの色相は赤を0度、シアンを180度のように環上に各色並べ、その角度で表すことがよくあります。

画像5

よく見るクロスプロセス写真の例では、青空が緑になっていることも多いので、まずはこれを実現する方法を考えてみます。まず考えられるのが、HSV色空間で-60度くらい回してしまえば青が緑になりますので、これがひとつの方法かと思います。もちろん、それ以外の色も変わるわけで、赤は紫に、黄色は赤になってしまいます。これが良い結果をもたらすかどうかはわかりません。

もうひとつは、思い切って(RGBにおける)青チャンネルを下げてしまうことです。青空と言っても、実際には水色に近いものなので、青チャネルが下がれば空も緑になると考えられます。ただし、白色のものは黄色になってしまいます。

とりあえず、HSV空間をどれだけ回すか、RGB各チャネルをどれだけ下げるのかはパラメーターで指定できるようにしておき、いろいろな値を試せるようにしてみることにします。

あと、おまけですが、トイカメラで撮ったフィルムをクロスプロセス現像すると、周辺光量が落ちて(四隅が暗くなって)より味のある(?)写真になるので、これも併せて実装してみたいと思います。

コマンドラインパラメーターの受け取り

まずはいつものように、ディレクトリの準備からはじめます。今回はgoCrossProcessというアプリにしますので、その名前のディレクトリを作り、"go mod init"を実行します。

> mkdir goCrossProcess
> cd goCrossProcess
> go mod init example.com/goCrossProcess

それから、GoCVを"go get"します。

> go get gocv.io/x/gocv

※はじめてGoCVを使用する際にはOpenCVのビルド作業が必要ですので、前回の記事を参照して実施してください。

それでは、"goCrossProcess.go"というファイルを作って、コードを書いていきます。

まずは、パラメーターの受け取りの部分ですが、これには"flag"パッケージを使うのが便利そうです。flagパッケージ(と、gocvを一緒に)をimportします。

package main

import (
	"flag"

	"gocv.io/x/gocv"
)

そして、以下のコマンドラインパラメーターを定義します。

・image: 加工する写真のファイル名
・rrate: 赤チャネルの倍率(0~1の範囲の浮動小数点)
・grate: 緑チャネルの倍率(0~1の範囲の浮動小数点)
・brate: 青チャネルの倍率(0~1の範囲の浮動小数点)
・hshift: 色相の移動角度(度数)
・vignette: 周辺光量落ち(none: なし、low: 小、high: 大)

	originalImageName := flag.String("image", "", "Original Image File")
	rRate := flag.Float64("rrate", 1.0, "Red multiply rate")
	gRate := flag.Float64("grate", 0.9, "Green multiply rate")
	bRate := flag.Float64("brate", 0.5, "Blue multiply rate")
	hShift := flag.Int("hshift", 0.0, "H Shift (degree)")
	vignette := flag.String("vignette", "none", "Vignette (none/low/high)")

このように定義しておき、下のように"flag.Parse()"を実行すれば、パラメーターが取り込まれます。

	flag.Parse()

コマンドラインパラメーターの書き方の例は下のようになります。

> goCrossProcess --image abc.jpg --rrate 1.0 --brate 0.5 --hshift 30 --vignette low

なお、指定しなかったパラメーターについては、コードで指定したデフォルト値が適用されます(例えばgrateであれば0.9)。

色相の変更処理

では、画像を読み込んで色相の回転を行うコードから作っていきましょう。

まずは画像の読み込みですが、こちらのコードでできます。

	img := gocv.IMRead(*originalImageName, gocv.IMReadColor)

imgはBGR形式のgocv.Mat型になります(OpenCVは基本的にRGBではなくBGRの並びになります)。

後で必要となりますので、画像の幅、高さ、それからチャンネル数(これはカラー画像を想定しているので3で決め打ちしてもよいのですが)を取得しておきます。

	width := img.Cols()
	height := img.Rows()
	channels := img.Channels()

そして、BGR画像をHSVに変換します。これもOpenCVなら一発ですね。

	imgHSV := gocv.NewMat()
	gocv.CvtColor(img, &imgHSV, gocv.ColorBGRToHSVFull)

そして、この中のH要素だけを指定された角度(hShift)だけ回します。

	for y := 0; y < height; y++ {
		for x := 0; x < width; x++ {
			imgHSV.SetUCharAt3(y, x, 0, imgHSV.GetUCharAt3(y, x, 0)+uint8(*hShift)
		}
	}

for分でY座標、X座標についてループしながら、すべてのピクセルに対して処理していくのですが、imgHSVのSetUCharAt3というメソッドでHの値を更新しています。

元の値である imgHSV.GetUCharAt3(y, x, 0) は8bitの値です。そこにhShiftの値(これは元々0~360の度数を持つ変数ですが、事前に0~255の範囲に補正してあります)を足します。足した結果は8bitの最大値255を超える可能性がありますが、エラーにはならず自動的に下位8bitの値がセットされることになるので、気にする必要はありません。

ところで、これは後で実行時間を測定してわかったことなのですが、このSetUCharAt3メソッドはとてもコストの高いメソッドでした。このメソッドをすべてのピクセルに対して繰り返し実行すると、実行時間が長くなります。このような場合は、直接メモリを書き換えるような実装に変えていくことで高速化ができるのですが、GoCVではMatのデータ部分を配列として取得するメソッドがありますので、これを使ってみます。

	ptrHSV, _ := imgHSV.DataPtrUint8()

上のコードを実行すると、ptrHSV[0]のようにしてHSVデータに直接アクセスできます。これを使って書き換えると下のようになります。

	for y := 0; y < height; y++ {
		for x := 0; x < width; x++ {
			ptrHSV[(y*width+x)*channels] += uint8(*hShift)
		}
	}

この書き換えの効果は劇的で、ざっと7~8倍高速化できます。

なお、このコードでも(元の値に)hShiftを足すことで8bitの最大値255を超えることも当然ありますが、その結果の下位8bitがptrHSV[]に格納されることになりますので問題ありません(if文で場合分け処理する必要なし!)。

なお、もちろんループ内の掛け算の削除や1次元ループに落とすこともでき、下のように書くことができます。

	for i := 0; i < width * height * channels; i += channels {
		ptrHSV[i] += uint8(*hShift)
	}

コードが短くなって良いものの、結果的にはほぼ誤差の範囲でしか高速化はできませんでしたので、何を行っているかがよりわかりやすい前者のコードを採用することにします。

さて、色相を変化させて画像をRGBデータ(正確にはBGRデータ)に戻します。なお、変数名はimgCorssProcessとしています。

	imgCrossProcess := gocv.NewMat()
	gocv.CvtColor(imgHSV, &imgCrossProcess, gocv.ColorHSVToBGRFull)

RGBデータの加工

続いて、RGBの各チャネルの値を変更します。これは、以下のようにHSVで色相を変更したのと同じようなコードでできますね。

	ptrBGR, _ := imgCrossProcess.DataPtrUint8()
	for y := 0; y < height; y++ {
		for x := 0; x < width; x++ {
			ptrBGR[(y*width+x)*channels+0] = 
        uint8(float64(ptrBGR[(y*width+x)*channels+0]) * *bRate)
			ptrBGR[(y*width+x)*channels+1] = 
        uint8(float64(ptrBGR[(y*width+x)*channels+1]) * *gRate)
			ptrBGR[(y*width+x)*channels+2] = 
        uint8(float64(ptrBGR[(y*width+x)*channels+2]) * *rRate)
		}
	}

ここでbRate、gRate、rRateはfloat64型なので、型をそろえるためにptrBGR[]の値もfloat64にしています。

なお、bRateなどは0~1の間の値であることを想定しています。1を超えると8bitの範囲を超えますので、計算後の下位8bitがptrBGR[]に書き込まれます。

※bRate等の範囲チェックはあえて行っていません。その理由は、これによって偶然にでも面白い絵がでたら楽しいな、という理由からですw

簡易周辺光量落ち処理

最後は周辺光量落ち(vignette)の処理です。一般的にカメラのレンズは絞りを開いて撮れば四隅が暗くなるものですが、特にトイカメラなどではそれが顕著に現れます。

簡単に調べただけでも、この処理をちゃんと(光学的に)やろうとすれば、かなり面倒なようです。元画像と同じサイズで、球面の一部になるようなマップを作ってしまえば、それを画像のMatと掛け合わせればよい(行列の掛け算という意味ではなく、各点ごと値同志を掛けると表現すればよいでしょうか)のでしょう。しかし、マップを計算して作るのはちょっと難しそうだな、と思いましたので、簡易的に中心からの距離に比例して光量落ちしていくようにすることで、それっぽい効果が得られるようにしようと思います。

そして、その効果は2段階用意して、四隅が真っ黒になる方を"High"とし、四隅が7割くらい落ちるのを"Low"とします。

まず、中心の座標を(centerX, centerY)とし、各点からの距離を計算して、distという変数に入れます。この際、一番遠い点までの距離が1になるように補正しています。この値を1から引くわけですが、その際にLowの場合は0.7を掛けておくことで、小さくなりすぎないように調整します。そして、その値を元のRGB値に掛けることで、中心から周辺に向かって、その距離に比例して暗くなっていくようになります。

	if *vignette != "none" {
		vFactor := 0.7
		if *vignette == "high" {
			vFactor = 1.0
		}
		centerX := float64(width / 2)
		centerY := float64(height / 2)
		for y := 0; y < height; y++ {
			for x := 0; x < width; x++ {
				nx := (float64(x) - centerX) / centerX
				ny := (float64(y) - centerY) / centerY
				dist := math.Sqrt(nx*nx+ny*ny) / math.Sqrt(2)
				ptrBGR[(y*width+x)*channels+0] = 
                  uint8(float64(ptrBGR[(y*width+x)*channels+0]) * (1 - dist*vFactor))
				ptrBGR[(y*width+x)*channels+1] = 
                  uint8(float64(ptrBGR[(y*width+x)*channels+1]) * (1 - dist*vFactor))
				ptrBGR[(y*width+x)*channels+2] = 
                  uint8(float64(ptrBGR[(y*width+x)*channels+2]) * (1 - dist*vFactor))
			}
		}
	}

ということで、ここまでで画像の加工は終了。あとは画面に元画像と加工後の画像を表示して、このプログラムのコードは終了します。

	windowOrginal := gocv.NewWindow("Orginal")
	windowCrossProcess := gocv.NewWindow("Cross Process")
	windowOrginal.IMShow(img)
	windowCrossProcess.IMShow(imgCrossProcess)

	windowOrginal.WaitKey(0)

なお、最後のWaitKey(0)をコールして、キー入力待ちにしておかないと、一瞬画面が表示されて、すぐプログラムが終了(つまり画像も消えます)となってしまいますので、注意が必要です。

では、"go build"でビルドして、実際に実行してみましょう。

元画像はこちらです。

画像2

これを、下のように実行すると・・・

> goCrossProcess --image DSCF7427.jpg --vignette low --hshift -10

こんな画像が得られました。

画像3

なんとなくクロスプロセス現像のサンプルにありそうな絵になったかと思いますが、いかがでしょうか? 元画像とはかなり印象は違いますね。

別の例で、ガクアジサイの写真を加工してみます。元画像はこちら。

画像4

これを、こんなパラメーターで実行してみます。

goCrossProcess.exe --image DSCF5534.jpg --vignette high --hshift -60 --brate 1.0 --grate 0.7

すると、こんな感じの赤/紫系になるタイプのクロスプロセス写真っぽくなりました。

画像5

――――――――――――――――――――――――――――――――――

クロスプロセス現像をたくさん経験している方からしたら、「ぜんぜん違うよ」と怒られてしまうかもしれませんが、自分としては結構満足しています。それと同時に、またフィルムカメラを持って出かけたくなりましたw

最後にソースコード全体を載せておきます(ちょうど100行くらいですね)。

package main

import (
	"flag"
	"fmt"
	"math"
	"time"

	"gocv.io/x/gocv"
)

func main() {
	originalImageName := flag.String("image", "", "Original Image File")
	rRate := flag.Float64("rrate", 1.0, "Red multiply rate")
	gRate := flag.Float64("grate", 0.9, "Green multiply rate")
	bRate := flag.Float64("brate", 0.5, "Blue multiply rate")
	hShift := flag.Int("hshift", 0.0, "H Shift (degree)")
	vignette := flag.String("vignette", "none", "Vignette (none/low/high)")
	flag.Parse()

	// adjust for 360 degree
	*hShift = *hShift * 256 / 360

	img := gocv.IMRead(*originalImageName, gocv.IMReadColor)
	width := img.Cols()
	height := img.Rows()
	channels := img.Channels()
	fmt.Printf("image %s was loaded. widht = %d, height = %d\n", *originalImageName, width, height)

	startTime := time.Now()

	fmt.Println("Converting to HSV")
	imgHSV := gocv.NewMat()
	gocv.CvtColor(img, &imgHSV, gocv.ColorBGRToHSVFull)

	// Shift HUE
	ptrHSV, _ := imgHSV.DataPtrUint8()
	fmt.Println("Shifting Hue")
	for y := 0; y < height; y++ {
		for x := 0; x < width; x++ {
			ptrHSV[(y*width+x)*channels] = ptrHSV[(y*width+x)*channels] + uint8(*hShift)
		}
	}

	fmt.Println("Converting back to BGR")
	imgCrossProcess := gocv.NewMat()
	gocv.CvtColor(imgHSV, &imgCrossProcess, gocv.ColorHSVToBGRFull)

	// Apply RGB factor
	ptrBGR, _ := imgCrossProcess.DataPtrUint8()
	fmt.Println("Applying RGB factors")
	for y := 0; y < height; y++ {
		for x := 0; x < width; x++ {
			ptrBGR[(y*width+x)*channels+0] = uint8(float64(ptrBGR[(y*width+x)*channels+0]) * *bRate)
			ptrBGR[(y*width+x)*channels+1] = uint8(float64(ptrBGR[(y*width+x)*channels+1]) * *gRate)
			ptrBGR[(y*width+x)*channels+2] = uint8(float64(ptrBGR[(y*width+x)*channels+2]) * *rRate)
		}
	}

	// vignette
	if *vignette != "none" {
		vFactor := 0.7
		if *vignette == "high" {
			vFactor = 1.0
		}
		centerX := float64(width / 2)
		centerY := float64(height / 2)
		fmt.Println("Applying vignette map")
		for y := 0; y < height; y++ {
			for x := 0; x < width; x++ {
				nx := (float64(x) - centerX) / centerX
				ny := (float64(y) - centerY) / centerY
				dist := math.Sqrt(nx*nx+ny*ny) / math.Sqrt(2)
				ptrBGR[(y*width+x)*channels+0] = uint8(float64(ptrBGR[(y*width+x)*channels+0]) * (1 - dist*vFactor))
				ptrBGR[(y*width+x)*channels+1] = uint8(float64(ptrBGR[(y*width+x)*channels+1]) * (1 - dist*vFactor))
				ptrBGR[(y*width+x)*channels+2] = uint8(float64(ptrBGR[(y*width+x)*channels+2]) * (1 - dist*vFactor))
			}
		}
	}

	endTime := time.Now()
	fmt.Printf("Process time = %s", endTime.Sub(startTime).String())

	windowOrginal := gocv.NewWindow("Orginal")
	windowCrossProcess := gocv.NewWindow("Cross Process")
	windowOrginal.IMShow(img)
	windowCrossProcess.IMShow(imgCrossProcess)

	windowOrginal.WaitKey(0)
}

――――――――――――――――――――――――――――――――――

執筆者プロフィール:水谷 裕一
大手外資系IT企業で15年間テストエンジニアとして、多数のプロジェクトでテストの自動化作業を経験。その後画像処理系ベンチャーを経てSHIFTに自動化エンジニアとして入社。
SHIFTでは、テストの自動化案件を2件こなした後、株式会社リアルグローブ・オートメーティッド(RGA)に出向中。RGAでは副社長という立場でありながら、エンジニアとしてAnsibleやOpenshiftに関する案件も担当。また、Ansibleの社内教育や、外部セミナー講師も行っている。
最近の趣味は電動キックボードでの散歩。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/