opencv_contrib の Julia bindingの内部動作を追いかけてみた

#はじめに

OpenCV 4.5.0からopencv_contribに入った Julia bindingについての第3弾です。いままで

 - 第1弾：[ビルド＆インストール方法](https://qiita.com/TakekazuKATO/items/4d80aed69741bfdd2168)
 - 第2弾：[基本的な使い方](https://qiita.com/TakekazuKATO/items/332563d51391c0cce8e2)（Advent Calendar 2日目）

と紹介してきましたが、今回はラッパーの内部動作について調べてみました。特に、OpenCVのcv::Mat型と、それをJuliaで扱うための OpenCV.Mat型の自動変換にメモリコピーなどのオーバーヘッドがないかなどを確認していきます。

今回の話は、JuliaのCインタフェースとCxxWrap.jlの仕様に基づいて、C++やJuliaで書かれた（自動生成された）ラッパー関数のソースを追いかけていくというかなりマニアックな話なので、結局どうなのという結論だけ知りたい人は、この記事の一番最後までスキップしてください。

下記は、[opencv_contrib](https://github.com/opencv/opencv_contrib)の4.5.0に従って書いています。julia bindingのソースは、上記のmodules/julia以下にありますが、基本的にgen/gen3_*.pyのpythonスクリプトで自動生成しているためソースフォルダにはコードの断片が散在していて読みにくいので、ビルド時に自動生成されたC++やJuliaのソースファイルのほうを追いかけました。

以下で説明しているフォルダやファイルはビルドディレクトリのmodules/julia/gen以下を指します。この下のautogen_cppやautogen_jl以下のファイルが自動生成されたソースです。なお、ビルドディレクトリを作る方法は[ビルド＆インストール方法](https://qiita.com/TakekazuKATO/items/4d80aed69741bfdd2168)を参照してください。

# OpenCV-Juliaで導入される型

OpenCV-Juliaには、Juliaで定義した型と、CxxWrap.jlの機能によりC++で定義した（Juliaの）型があります。まずJuliaで定義した型はautogen_jl/以下にあります。structで検索をかけてみると以下の型を定義しているようです。

```
$ grep struct autogen_jl/*
autogen_jl/Mat.jl:struct Mat{T <: dtypes} <: AbstractArray{T,3}
autogen_jl/Vec.jl:struct Vec{T, N} <: AbstractArray{T,1}
autogen_jl/cv_cxx.jl:include("typestructs.jl")
autogen_jl/typestructs.jl:struct Point{T}
autogen_jl/typestructs.jl:struct Point3{T}
autogen_jl/typestructs.jl:struct Size{T}
autogen_jl/typestructs.jl:struct Rect{T}
autogen_jl/typestructs.jl:struct RotatedRect
autogen_jl/typestructs.jl:struct Range
autogen_jl/typestructs.jl:struct TermCriteria
autogen_jl/typestructs.jl:struct cvComplex{T}
```

typestructs.jlでは、比較的単純な型が定義されています。

```julia:autogen_jl/typesctucts.jl
struct Point{T}
  x::T
  y::T
end

struct Point3{T}
  x::T
  y::T
  z::T
end
...
```
だいたい同名のC++のクラスと同じものと考えていいです。autogen_cpp/jlcv_types.hppの中でC++の型と自動で対応づけられるように設定しています（これはCxxWrap.jlの機能）。
Matは画像を扱うための型でこれは詳細を後で述べます。Vecは１次元配列のための型です。

あと、いくつか直和型(Union)に別名をつけています。

```
grep Union autogen_jl/*
autogen_jl/cv_cxx.jl:const dtypes = Union{UInt8, Int8, UInt16, Int16, Int32, Float32, Float64}
autogen_jl/cv_cxx.jl:const Scalar = Union{Tuple{}, Tuple{Number}, Tuple{Number, Number}, Tuple{Number, Number, Number}, NTuple{4, Number}}
autogen_jl/cv_cxx.jl:const InputArray = Union{AbstractArray{T, 3} where {T <: dtypes}, CxxMat}
```

dtypeは画素値の型、ScalarはC++だとcv::Scalarで色成分を扱うためのクラスに対応する型のようで、これはタプルで表現されています。
InputArrayはOpenCVの関数の引数に使われるcv::InputArrayに対応する型で、AbstractArrayとCxxMatの直和型になっています。[前回確認](https://qiita.com/TakekazuKATO/items/332563d51391c0cce8e2)したように、画像を扱うOpenCV.MatもAbstractArrayのサブタイプなのでInputArrayとして指定できます。


一方、CxxWrap.jlの機能によりC++でJuliaの型を定義するには、Module::add_typeというメソッドを使います。このような型はたくさんあります。

```
$ grep add_type autogen_cpp/*
autogen_cpp/cv_core.cpp:    mod.add_type<Parametric<TypeVar<1>, TypeVar<2>>>("CxxVec")
autogen_cpp/cv_core.cpp:    mod.add_type<Mat>("CxxMat").constructor<int, const int *, int, void *, const size_t *>();
autogen_cpp/cv_core.cpp:    mod.add_type<Parametric<TypeVar<1>>>("CxxScalar")
autogen_cpp/cv_core.cpp:    mod.add_type<cv::CascadeClassifier>("CascadeClassifier");
autogen_cpp/cv_core.cpp:    mod.add_type<cv::Feature2D>("Feature2D");
autogen_cpp/cv_core.cpp:    mod.add_type<cv::SimpleBlobDetector>("SimpleBlobDetector", jlcxx::julia_base_type<cv::Feature2D>());
...
```

これらの型は、自動でC++の型とJuliaの型が対応づけられます。例えば上のCxxMatなら、add_typeのテンプレート型引数のMat (cv::Mat)クラスはJuliaからはCxxMatとして扱えるように自動で対応づけられます。CxxMatの部分だけ定義をみてみると、次のようになっていました。

```julia:autogen_cpp/cv_core.cpp
    mod.add_type<Mat>("CxxMat").constructor<int, const int *, int, void *, const size_t *>();

    mod.method("jlopencv_core_get_sizet", [](){return sizeof(size_t);});
    jlcxx::add_smart_pointer<cv::Ptr>(mod, "cv_Ptr");
    mod.method("jlopencv_core_Mat_mutable_data", [](Mat m) {
        return make_tuple(m.data, m.type(), m.channels(), m.size[1], m.size[0], m.step[1], m.step[0]);
    });
```

これは、CxxMatというcv::Matに対応づけられるJuliaの型を定義し、jlopencv_core_get_sizetとjlopencv_core_Mat_mutable_dataというJuliaのメソッドを定義しています。

# cv::MatからOpenCV.Matへの変換

ラッパ関数の中で、cv::Matクラスを返すOpenCV(C++)の関数からJuliaのOpenCV.Matにどう変換しているかソースファイルを追跡していきます。例として、画像ファイルを読み込む cv::imread(Julia側ではOpenCV.imread)を追いかけてみます。

autogen_cpp/以下にラッパ関数が生成されていて、cv::imreadを呼び出す部分は cv_core.cppにあります。

```c++:autogen_cpp/cv_core.cpp
    mod.method("jlopencv_cv_cv_imread",  [](string& filename, int& flags) { auto retval = cv::imread(filename, flags); return retval;});
```

このmod.methodはCxxWrap.jlの書き方で、第一引数で指定した名前のJulia関数を、第二引数のLambdaで指定した処理を行うように定義します。つまりここではcv::imreadを呼び出すJuliaのjlopencv_cv_cv_imread関数を定義しています。この段階では cv::Matのポインタ(JuliaからはOpenCV.CxxMatに見える)をそのまま返していますし、jlopencv_cv_cv_imreadという関数名になっています。この関数はJulia側のラッパ関数から呼び出されます。Julia側のラッパ関数はautogen_jl/以下に生成され、問題の関数はcv_cxx_wrap.jlにありました。

```julia:autogen_jl/cv_cxx_wrap.jl
function imread(filename::String, flags::Int32)
        return cpp_to_julia(jlopencv_cv_cv_imread(julia_to_cpp(filename),julia_to_cpp(flags)))
end
```

jlopencv_cv_imread関数の戻り値をcpp_to_julia関数に渡しています。関数名からしてもこの関数でOpenCV.CxxMatからOpenCV.Matに変換しているようです。cpp_to_juliaの定義はautogen_jl/mat_conversion.jlにありました。

```julia:autogen_jl/mat_conversion.jl
function cpp_to_julia(mat::CxxMat)
    rets = jlopencv_core_Mat_mutable_data(mat)
    if rets[2] == CV_MAKE_TYPE(CV_8U, rets[3])
        dtype = UInt8
    elseif rets[2]==CV_MAKE_TYPE(CV_8S, rets[3])
        dtype = Int8
    elseif rets[2]==CV_MAKE_TYPE(CV_16U, rets[3])
        dtype = UInt16
    elseif rets[2]==CV_MAKE_TYPE(CV_16S, rets[3])
        dtype = Int16
    elseif rets[2]==CV_MAKE_TYPE(CV_32S, rets[3])
        dtype = Int32
    elseif rets[2]==CV_MAKE_TYPE(CV_32F, rets[3])
        dtype = Float32
    elseif rets[2]==CV_MAKE_TYPE(CV_64F, rets[3])
        dtype = Float64
    else
        error("Bad type returned from OpenCV")
    end
    steps = [rets[6]/sizeof(dtype), rets[7]/sizeof(dtype)]
    # println(steps[1]/rets[3], steps[2]/rets[3]/rets[4])
    #TODO: Implement views when steps do not result in continous memory
    arr = Base.unsafe_wrap(Array{dtype, 3}, Ptr{dtype}(rets[1].cpp_object), (rets[3], rets[4], rets[5]))

    #Preserve Mat so that array allocated by C++ isn't deallocated
    return Mat{dtype}(mat, arr)
end
```

関数の前半は画素値の型の判別みたいなので、ポイントは

```julia
    arr = Base.unsafe_wrap(Array{dtype, 3}, Ptr{dtype}(rets[1].cpp_object), (rets[3], rets[4], rets[5]))
```
の部分です。最後の行のreturn Mat{dtype}(mat,arr)で、unsafe_wrapの戻り値（arr）と元の引数（mat）(CxxMat型)を属性として、OpenCV.Mat型を生成しています。Base.unsafe_wrapは、Juliaの標準のCインタフェースで、第二引数のポインタで示したメモリをJuliaのArrayとして使えるようにラップする関数です。Juliaの[マニュアル](https://docs.julialang.org/en/v1/base/c/)を読むとunsafe_wrapではメモリコピーを行わずに指定したメモリをそのまま使用します。また、デフォルトではJulia側で所有権を持たない、つまりarrがJulia側でGCされても指定したメモリをfreeしないみたいです。これはJulia側で勝手にメモリを開放してC++API側で不整合が発生するのを回避するためです。
OpenCV.CxxMat型は、前節であげたmod.add_typeで定義していて、

```julia:autogen_cpp/cv_core.cpp
    mod.add_type<Mat>("CxxMat").constructor<int, const int *, int, void *, const size_t *>();
```

CxxWrap.jlの[README.md](https://github.com/JuliaInterop/CxxWrap.jl/blob/master/README.md)によれば、JuliaでCxxMat型の変数がGCで開放されたときによばれるfinalizerでcv::Matのデストラクタを呼び出し（add_typeのデフォルトの挙動、開放しない設定もできる）、画像メモリを開放します。

OpenCV.Mat型には、cv::Matクラスの画像メモリをJuliaのArrayにラップした変数と、もとのcv::Mat型と対応づいたOpenCV.CxxMat型の変数を属性として保持していて、OpenCV.Mat型が開放されたときにはCxxMat型の属性のfinalizerからcv::Matクラスのデストラクタが呼ばれてメモリが開放されるという仕組みで、メモリリークも二重開放もおきないようにしているようです。画像の実態はcv::MatクラスのデータメモリをJuliaからArrayに見えるようにラップしているだけなので、コピーは生じないし、画素値アクセスなどはJuliaの標準機能（Array）としてアクセスするため高速にアクセスできると考えられます。

# Arrayからcv::Matへの変換

今度は逆にJuliaのArrayをOpenCVの関数の引数に与えた場合について追いかけてみます。例として cv::cvtColor関数を追いかけます。前節と同様に関係する関数定義を探すと、autogen_cpp/cv_core.cppとautogen_jl/cv_cxx_wrap.jlにありました。

```c++:autogen/cv_core.cpp
    mod.method("jlopencv_cv_cv_cvtColor",  [](Mat& src, int& code, Mat& dst, int& dstCn) { cv::cvtColor(src, dst, code, dstCn); return dst;});
```

```julia:autogen_jl/cv_cxx_wrap.jl
function cvtColor(src::InputArray, code::Int32, dst::InputArray, dstCn::Int32)
        return cpp_to_julia(jlopencv_cv_cv_cvtColor(julia_to_cpp(src),julia_to_cpp(code),julia_to_cpp(dst),julia_to_cpp(dstCn)))
end
```

今度は引数を指定する前にjulia_to_cppを適用して変換を行なっているようです。これも autogen_jl/mat_conversion.jlにあります。

```julia:autogen_jl/mat_conversion.jl
function julia_to_cpp(img::InputArray)
    if typeof(img) <: CxxMat
        return img
    end
    steps = 0
    try
        steps = strides(img)
    catch
        # Copy array since array is not strided
        img = img[:, :, :]
        steps = strides(img)
    end

    if steps[1] <= steps[2] <= steps[3] && steps[1]==1
        steps_a = Array{size_t, 1}()
        ndims_a = Array{Int32, 1}()
        sz = sizeof(eltype(img))
        push!(steps_a, UInt64(steps[3]*sz))
        push!(steps_a, UInt64(steps[2]*sz))
        push!(steps_a, UInt64(steps[1]*sz))

        push!(ndims_a, Int32(size(img)[3]))
        push!(ndims_a, Int32(size(img)[2]))
        if eltype(img) == UInt8
            return CxxMat(2, pointer(ndims_a), CV_MAKE_TYPE(CV_8U, size(img)[1]), Ptr{Nothing}(pointer(img)), pointer(steps_a))
        elseif eltype(img) == UInt16
            return CxxMat(2, pointer(ndims_a), CV_MAKE_TYPE(CV_16U, size(img)[1]), Ptr{Nothing}(pointer(img)), pointer(steps_a))
        elseif eltype(img) == Int8
            return CxxMat(2, pointer(ndims_a), CV_MAKE_TYPE(CV_8S, size(img)[1]), Ptr{Nothing}(pointer(img)), pointer(steps_a))
        elseif eltype(img) == Int16
            return CxxMat(2, pointer(ndims_a), CV_MAKE_TYPE(CV_16S, size(img)[1]), Ptr{Nothing}(pointer(img)), pointer(steps_a))
        elseif eltype(img) == Int32
            return CxxMat(2, pointer(ndims_a), CV_MAKE_TYPE(CV_32S, size(img)[1]), Ptr{Nothing}(pointer(img)), pointer(steps_a))
        elseif eltype(img) == Float32
            return CxxMat(2, pointer(ndims_a), CV_MAKE_TYPE(CV_32F, size(img)[1]), Ptr{Nothing}(pointer(img)), pointer(steps_a))
        elseif eltype(img) == Float64
            return CxxMat(2, pointer(ndims_a), CV_MAKE_TYPE(CV_64F, size(img)[1]), Ptr{Nothing}(pointer(img)), pointer(steps_a))
        end
    else
        # Copy array, invalid config
        return julia_to_cpp(img[:, :, :])
    end
end
```

関数冒頭は引数がCxxMatの時の処理で、この場合はそのまま返す。次のtry catchの部分で、画像一行分のメモリアドレスの移動量を調べています。stridesは[Julia標準のBase.strides](https://docs.julialang.org/en/v1/base/arrays/#Base.strides)関数で、一行分の移動量を返します。このとき、imgがstridesに対応しない(ArrayのようなDenseArrayでない)場合は、メモリコピーをしています。

後半の画素タイプごとの条件分岐の部分でCxxMat型を生成しています。例えばUInt8のときの

```julia:autogen_jl/mat_conversion.jl
            return CxxMat(2, pointer(ndims_a), CV_MAKE_TYPE(CV_8U, size(img)[1]), Ptr{Nothing}(pointer(img)), pointer(steps_a))
```

の部分です。ここでpointerというのは[Julia標準のCインタフェースのBase.pointer](https://docs.julialang.org/en/v1/base/c/#Base.pointer)で、Arrayのデータメモリのアドレス（ポインタ）を返します。

CxxMatのコンストラクタは先にあげたC++からJuliaの型を定義する部分で次のように定義されています。

```julia:autogen_cpp/cv_core.cpp
    mod.add_type<Mat>("CxxMat").constructor<int, const int *, int, void *, const size_t *>();
```

これはそのまま同じシグネーチャのC++のcv::Matのコンストラクタを呼び出しますが、[OpenCVのリファレンス](https://docs.opencv.org/4.5.0/d3/d63/classcv_1_1Mat.html#a5fafc033e089143062fd31015b5d0f40)をみてみると、次のようになっています。

```
cv::Mat::Mat	(	int 	ndims,
const int * 	sizes,
int 	type,
void * 	data,
const size_t * 	steps = 0 
)		
This is an overloaded member function, provided for convenience. It differs from the above function only in what argument(s) it accepts.

Parameters
ndims	Array dimensionality.
sizes	Array of integers specifying an n-dimensional array shape.
type	Array type. Use CV_8UC1, ..., CV_64FC4 to create 1-4 channel matrices, or CV_8UC(n), ..., CV_64FC(n) to create multi-channel (up to CV_CN_MAX channels) matrices.
data	Pointer to the user data. Matrix constructors that take data and step parameters do not allocate matrix data. Instead, they just initialize the matrix header that points to the specified data, which means that no data is copied. This operation is very efficient and can be used to process external data using OpenCV functions. The external data is not automatically deallocated, so you should take care of it.
steps	Array of ndims-1 steps in case of a multi-dimensional array (the last step is always set to the element size). If not specified, the matrix is assumed to be continuous.
```

引数dataのところの説明をみると、画像メモリのアロケートはせずに、与えられたアドレスのメモリをそのまま使用するようです。つまり、JuliaのArrayを引数として渡す場合も、画像メモリのコピーは行われないということになります。

引数の型指定はAbstractArrayとなっているのでArray以外の配列も渡せますが、連続的な（少なくとも画像の位置ライン分は）メモリ領域を持つ配列でない場合は最初のtry catchのところや最後のelse以下のreturn julia_to_cpp(img[:, :, :])のところで画像メモリをコピーして返すようになっています。

# まとめ

OpenCVの画像を扱う変数が、C++側とJulia側でどう変換されているかを確かめるために、opencv_contribのソースを追いかけてきました。結論としては、OpenCV（C++）が返す画像クラス(cv::Mat)から、Julia側のOpenCV.Matへの変換でも、JuliaのArrayを引数としてOpenCV(C++)の関数に渡す場合でも、どちらの場合も画像メモリのコピーを行うことなく相互に変換できていることがわかりました。また、変換時にメモリリークや二重開放を起こさないようになっていることもわかりました。

次回は、OpenCV-PythonやImages.jl、Juliaの標準のArrayなどと速さを比較してみようかと思います。