はじめに
Advent Calendarとしては昨日に引き続いて画像処理の話です。被ると嫌だったので様子を見ていましたが、昨日はImages.jlなので被らないのと、たまたま2日が空いていたのでそこに投稿することにしました。
Juliaで画像処理のデファクトといえば、昨日紹介されていたImages.jlですが、Julia以外の世界で画像処理、画像認識ライブラリのデファクトといえばOpenCVと言っても過言ではないでしょう。
OpenCVはC++で書かれていて、公式のPythonバインディングを初め、様々な言語のバインディングがあります。ただ厄介なのは、かつてのOpenCVバージョン2系列まではC言語のAPIがあったのですが、3系列でC++が標準となりC言語APIはレガシー扱いになり、現在の4系列ではとうとうC言語APIが廃止されてしまいました。そのため、各言語のバインディングを書くには他言語からの扱いが複雑なC++の関数を呼び出す必要があり、ラッパーを書くにもOpenCVの関数が膨大でカバーするのが大変です。
JuliaではC++の関数を直接呼び出せないので、PyCall経由でOpenCV-Pythonを呼ぶこころみや、Cxx.jlを使ってC++-APIを呼び出すパッケージ、CxxWrap.jlを使ってC++-APIを呼び出すこころみなどが行われてきましたが、どれも決め手にかける状態でした。
ところがここにきて、OpenCV 4.5.0から公式が配布している追加モジュール群であるopecv_contribに、Juliaバインディングのモジュールが入ってきて状況が変わってきました。
この記事では、opencv_contribのJuliaバインディングの使い方を紹介します。ビルド&インストールはビルド編に書きましたので、そちらを参照してください。
opencv_contribのjuliaバインディングはCxxWrap.jlを使ってOpenCVのC++-APIにアクセスしますが、OpenCVの画像を扱うC++のオブジェクト(cv::Matクラス)がjulia側からどう見えるか、特にOpenCV画像をjulia配列のように使えるか、またjulia配列を画像としてOpenCV関数に渡せるかを試してみた結果を紹介します。
パッケージ読み込み
まずはOpenCVパッケージを読み込みます。
julia> using OpenCV
julia> const cv = OpenCV
OpenCVパッケージはexportされていないので、毎回パッケージ名をつけて関数を使用する必要があります。2行目のように書いておくと代わりに cvとかけるので短く済みます。pythonパッケージで import cv2 as cvとしたのと同様です。
画像読み込み
ファイルから画像を読み込んでみます。
julia> img = cv.imread("OpenCV_logo_white_600x.png");
julia> cv.imshow("window",img);
julia> cv.waitKey(Int32(0));
OpenCV_logo_white_600x.pngはOpenCVの公式ページにあるロゴ画像で、幅600ピクセル、高さ794ピクセルのカラー画像です。これを実行すると画像ウィンドウが表示されてjuliaが止まります。画像ウィンドウで何かキーを押すと再開します。
画像の型
画像を保持している変数imgの型を調べてみましょう。
julia> typeof(img)
OpenCV.Mat{UInt8}
OpenCV-pythonの場合はnumpy.ndarrayを返すのに対して、juliaの場合は独自のOpenCV.Mat{UInt8}という型で返しています。型引数のUInt8は画素値の型ということでしょう。
さて、このOpenCV.Matがどんな型か調べてみましょう。まずは、実装されているメソッドをみてみます。
julia> methodswith(OpenCV.Mat)
[1] axes(A::OpenCV.Mat) in OpenCV at /Users/tkato/.julia/packages/OpenCV/src/Mat.jl:30
[2] pointer(A::OpenCV.Mat) in OpenCV at /Users/tkato/.julia/packages/OpenCV/src/Mat.jl:35
[3] setindex!(A::OpenCV.Mat, x, I::Vararg{Int64,3}) in OpenCV at /Users/tkato/.julia/packages/OpenCV/src/Mat.jl:45
[4] size(A::OpenCV.Mat) in OpenCV at /Users/tkato/.julia/packages/OpenCV/src/Mat.jl:29
Matの直接のメソッドはこれだけしか出てこないので親クラスを調べてみます。
julia> supertypes(OpenCV.Mat)
(OpenCV.Mat, AbstractArray{T,3} where T<:Union{Float32, Float64, Int16, Int32, Int8, UInt16, UInt8}, Any)
ということで、AbstractArray{T,3}のサブクラスになっています。3次元になっているのはx座標、y座標、色ということですね。Images.jlの場合はArray{RGB{N0f8},2}というように色成分含めた画素値型があって、画像はその2次元配列という構造だったのに対して、OpenCV-Juliaは単に3次元配列として扱うようです。
AbsractArrayとして使えるので例えばこんなこともできます。
julia> sum(Float64.(img))
8.4316016e7
julia> img .|> Float64 |> sum
8.4316016e7
1行目と2行目は同じ意味です。|> はパイプ演算子で、左辺を右辺の関数に適用させ、 . は全要素に関数適用するbroadcastです。つまり要素ごとにFloat64に変換して総和をもとめています。このようにnumpyと同程度のことはできます。
画素値のアクセス
次に、サイズをみてみましょう。
julia> size(img)
(3,600,794)
色、幅、高さの順ですね。pythonの場合は高さ、幅、色の順なので逆順ですね。要素アクセスも確認します。
julia> img[1,300,1]
0x44
julia> img[0,0,0]
ERROR: BoundsError: attempt to access 3×600×794 reinterpret(UInt8, ::Array{UInt8,3}) at index [0, 0, 0]
julia> img[:,300,1]
3-element Array{UInt8,1}:
0x44
0x2a
0xff
添字も色成分、x座標、y座標の順で、0は向こうで1始まり。これは列優先、1オリジンというjuliaの流儀ですね。pythonの行優先0オリジンと対照的です。
:で1画素の色成分をまとめて取得できます。座標(300,1)の画素値はR=0xff,G=0x2a, B=0x44なので、BGRの順に並んでいることがわかります。これはOpenCV自体の仕様ですね。
次は、画素値をセットしてみましょう。
julia> img[:,300,1]=[255,255,255];
julia> img[:,300,1]
3-element Array{UInt8,1}:
0xff
0xff
0xff
色成分をまとめてセットできていますね。もちろん色成分ごとのセットも可能です。
OpenCV関数の引数に画像を指定
次は、OpenCVの関数の引数に画像を指定する場合をみてみます。
例えば、OpenCV.cvtColor()の引数の型は以下の通り。
julia> methods(cv.cvtColor)
# 2 methods for generic function "cvtColor":
[1] cvtColor(src::Union{OpenCV.CxxMat, AbstractArray{T,3} where T<:Union{Float32, Float64, Int16, Int32, Int8, UInt16, UInt8}}, code::Int32; dst, dstCn) in OpenCV at /Users/tkato/.julia/packages/OpenCV/src/cv_cxx_wrap.jl:1363
[2] cvtColor(src::Union{OpenCV.CxxMat, AbstractArray{T,3} where T<:Union{Float32, Float64, Int16, Int32, Int8, UInt16, UInt8}}, code::Int32, dst::Union{OpenCV.CxxMat, AbstractArray{T,3} where T<:Union{Float32, Float64, Int16, Int32, Int8, UInt16, UInt8}}, dstCn::Int32) in OpenCV at /Users/tkato/.julia/packages/OpenCV/src/cv_cxx_wrap.jl:1360
画像の引数(src)の型が OpenCV.CxxMatとAbstractArrayの直和型になっています。imreadで取得したimgのOpenCV.MatもAbstractArrayのサブタイプなのでそのまま渡せますね。
julia> img_gray=cv.cvtColor(img,cv.COLOR_BGR2GRAY);
julia> size(img_gray)
(1, 600, 794)
cv.COLOR_BGR2GRAYなどの列挙型ラベルはそのまま定義されているみたいですね。
また、AbstractArrayで指定されているので、OpenCV.Matだけじゃなくて、Arrayなんかでも渡せるはずです。試してみましょう。
julia> newimg=zeros(UInt8,3,10,10);
julia> typeof(newimg)
Array{UInt8,3}
julia> newimg_gray=cv.cvtColor(newimg,cv.COLOR_BGR2GRAY);
julia> typeof(newimg_gray)
OpenCV.Mat{UInt8}
UInt8型の3次元のArrayを生成してOpenCVの関数に渡してもちゃんと動いていますね。
OpenCVのMat型もAbstractArrayとして引数に渡せるなら、Unionのもう片方のOpenCV.CxxMatという型はなんでしょう? これはどうも調べてみるとc++のcv::Matのオブジェクトのポインタで、OpenCV.Mat構造体のなかに元のオブジェクトへのポインタを保持しているようです。
julia> typeof(img.mat)
OpenCV.CxxMatAllocated
julia> img_gray=cv.cvtColor(img.mat,cv.COLOR_BGR2GRAY);
OpenCV.Matの中にCxxのcv::Matのポインタを保持しているのは、julia側で変数のメモリを開放したときにOpenCV側のオブジェクトを後始末するためみたいですが、この辺りの中身はまた今度調べてみます。
まとめ
というわけで,最初の「OpenCV画像をjulia配列のように使えるか、またjulia配列を画像としてOpenCV関数に渡せるか」という疑問に対しては、「OpenCV画像はjuliaネイティブの配列(AbstractArray)と同じように扱え、juliaで生成したネイティブ配列もOpenCV関数に画像として渡せる」という結論になりました。
ほぼOpenCV-pythonと同等のことができそうですが、違いはpythonではnumpy.ndarrayに変換して返すのに対して、juliaではAbstractArrayのサブクラスの独自型(OpenCV.Mat)として返すという点と、要素アクセスがpythonでは(y座標,x座標、色)の順で0始まりなのに対して juliaでは (色、x座標、y座標)の順で1始まりという点ですね。
まだ細かいところまで見ていませんが、全体的にpythonと同じように使えながら、うまくjuliaの流儀に会うようにすり合わせている印象です。
C++のcv::MatとjuliaのOpenCV.Matの相互の変換でオーバーヘッドがあるか、C++側で確保したメモリがjulia側のgcでどうなるかなど、さらに中身に踏み込んだ話はまた今度調べてみます。