OpenCV
Julia
julialang
OpenCV3

CxxWrap.jlを使ったC++のライブラリのラッパー(CxxWrap.jl版OpenCV.jlの作り方)

内容

CxxWrap.jlを使ってJuliaからOpenCVをC++API経由で呼び出す話.Juliaのバージョンは0.7/1.0, 呼び出すOpenCVのバージョンは3.4.3です.

成果物としては,OpenCV.jl(CxxWrap版)というのを作ってみました.まだコンパイルとusingが通ったくらいでろくにテストもしていませんが,誰かの参考になれば幸いです.

JuliaからOpenCVを呼び出す

JuliaでOpenCVを使いたいと思って,探してみると OpenCV.jlというのはあるんですが,これはCxx.jlを使っていて,Cxx.jl自体のインストールの難易度が高く現状0/7/1.0では無理そう.他にというとPyCall経由で呼び出す方法があって,これが楽そうなんですがJuliaで使いたいのにPythonを経由するとなると,じゃぁPythonでいいじゃんってなって悔しいので,ないなら作ろうと思ったのがきっかけ.

JuliaからのC言語の関数呼び出し

Juliaから外部のライブラリを呼び出す方法はccallというのがあって,ひじょーに簡単にCで書いた関数が呼び出せるんですが,これがCにしか対応していない.C++で書いた関数を呼び出そうとおもうと,一度 extern "C" なラッパを書いて,それをJuliaから呼び出す必要があります.

OpenCVのAPI

OpenCVのAPIは,C/C++/Python/JavaScriptがあって,最初はCAPI経由で呼び出していたんですが,cvSaveImageがランタイムエラーになって解決方法がわからなくて行き詰まりました.そもそもOpenCV的にはCAPIは一応まだ消していないけどサポートしないよという扱いらしく,仮にこの問題が解決したとしても将来的には使えなくなる可能性が大きいということで断念して,あらためてC++API経由の呼び出しを考えました.

JuliaのC++対応

JuliaからC++の関数を呼び出す方法は現在のところ3つあります.
1. 自分でextern "C" なcppファイルのラッパを書いて呼び出す.
2. Cxx.jlを使って,JuliaのソースコードにC++の断片を書いて呼び出す.
3. CxxWrap.jlを使う.

このうち,1.の方法はいちいちC側の呼び出し関数を作るという大量かつ単純作業がまっていて,さらにC++のクラスをどうやってJuliaのクラスにマップするかという問題がある.2. の方法は,前にも書いたようにCxx.jl自体がコンパイル/インストールの難易度が高いライブラリで現状0.7/1.0には対応していないし,しばらく対応しそうにもない.

というわけで,3.のCxxWrap.jlを使う方法なんですが,これはC++側でラッパを作ってそれをJuliaから呼び出す方式です.というと1.の方法と変わりないようですが,ラッパ関数を書くための関数やテンプレートがいろいろと用意されていて,またC++のクラスやメソッドをJuliaのクラスにマップする方法が用意されているというのが利点です.さらにある程度の標準的な型はJulia-C++間で自動でコンバートされます.

CxxWrap.jlでできることは,配布元のREADMEによれば以下の通り.
- Support for C++ functions, member functions and lambdas
- Classes with single inheritance, using abstract base classes on the Julia side
- Trivial C++ classes can be converted to a Julia isbits immutable
- Template classes map to parametric types, for the instantiations listed in the wrapper
- Automatic wrapping of default and copy constructor (mapped to deepcopy) if defined on the wrapped C++ class
- Facilitate calling Julia functions from C++

それぞれの機能をどうやって使うかはREADMEの中でサンプルコード付きで解説していて,またC++側のコードを書くときに使うJlCxxのexamplesをたどれば,いろいろサンプルがあるのでそちらを参照すればいいですが,一例だけREADMEから抜き出してみます.

呼び出したいC++のコードが以下のようなクラスだったとします.

struct World
{
  World(const std::string& message = "default hello") : msg(message){}
  void set(const std::string& msg) { this->msg = msg; }
  std::string greet() { return msg; }
  std::string msg;
  ~World() { std::cout << "Destroying World with message " << msg << std::endl; }
};

これをJuliaで呼び出すためのC++側のWrapperコードは次のような感じ(READMEの例は断片だけなので一部補っています)

#include "jlcxx/jlcxx.hpp"

JLCXX_MODULE define_julia_module(jlcxx::Module& types)
{
types.add_type<World>("World")
  .constructor<const std::string&>()
  .method("set", &World::set)
  .method("greet", &World::greet);
}

これをコンパイルして共有ライブラリ(mylib.so)にコンパイルしておくと,Juliaからは以下のように呼び出せます.

module CppTypes
  using CxxWrap
  @wrapmodule("mylib.so",:define_julia_module)
end

w = CppTypes.World()
@test CppTypes.greet(w) == "default hello"
CppTypes.set(w, "hello")
@test CppTypes.greet(w) == "hello"

まぁ雰囲気はわかると思いますが,add_typeでJuliaから呼び出したいクラスを登録して,Julia側からは@wrapmodule()マクロで登録したクラスを呼び出せるようにして,Juliaのコンストラクタやメソッドとして使えるようになるといった感じです.int, floatなどの基本的な型や文字列(std::string, const char*)はJuliaとの間で自動変換してくれるので,C++クラスの内部データへのアクセスは最終的に基本的な型になればJuliaから扱うことができます.他にもREADMEやJlCxxのexamplesを見れば,テンプレートクラス,Enumの登録方法や関数の登録方法などが見られます.

JuliaとC++のクラスの自動変換

と,ここまでは単にCxxWrap.jlの紹介で,公式のドキュメントを見れば分かる話なんですが,ここからは公式ドキュメントに載っていない話.

上に書いたように,int, floatや文字列のような基本的な型はJuliaの型との間で相互に自動変換してくれるわけですが,それ以外のクラスをメソッドや関数の引数/戻り値で使っていると,add_typeで登録していない型の場合usingの段階でwrapperがないよというようなエラーがでます.また,登録したクラスも参照だけをマップしているようでCAPIのようにunsafe_loadでJulia側から内部構造にアクセスすることはできません.C++のラッパー側で自動変換に対応している型まで変換する関数を自分で書く必要があります.

ここで,できればpythonのcv2のように,OpenCVの関数を使いながら画像そのものはJuliaのクラスとして自由に使いたいと思いました.つまり次のように使いたいわけです.

using OpenCV
const cv2=OpenCV
im=cv2.imread("test.png")
# Array{UInt8,1}[...]
# imはArray型なのでJuliaで好きなようにさわれる.
# なにか処理を行う
cv2.imwrite("test2.png",im)

これは実際にOpenCV.jlで動くプログラムです.(2行目のconst cv2=OpenCVはpythonの import ... as ...の代わり)

これを実現するためには,C++側のcv::MatクラスとJulia側のArrayクラスの間で相互に変換ができればいいわけです.pythonではnumpyのクラスとして画像を扱えます.python-opencvでは各関数呼び出しのラッパ関数で相互に変換するコードを埋め込んでいるようで,Juliaでも同じようにラッパ関数の登録部分で変換コードを埋め込んでいけばいいわけですが,いちいち全部の関数やメソッドに変換関数を埋め込むのは大変なので,なんとか自動でできないかと調べてみました(結局ラッパ関数を自動生成するようにしたのでpythonと同じ方法でも良かったですが).

CxxWrap.jlで基本型を自動的に相互変換している仕組みがどうなっているかを調べてみると,JlCxxのヘッダファイルだけで実現しているようなので,それならJlCxxを再コンパイルすることもなく,自分で拡張できるんじゃないかと試行錯誤してみるとできました.

例として,OpenCVのcv::String (std::stringとほぼ同じ機能だけど別途用意されている文字列クラス)を自動変換するコードを上げておきます.(他にも自動変換するクラスはOpenCV.jlのdeps/src/cvwrap/modules.hppに書いてあります.)

#include <opencv2/opencv.hpp>
#include "jlcxx/jlcxx.hpp"

namespace jlcxx {

template <>
struct IsValueType<cv::String> : std::true_type {};

template <>
struct static_type_mapping<cv::String> {
  typedef jl_value_t* type;
  static jl_datatype_t* julia_type() {
    return (jl_datatype_t*)jl_get_global(jl_base_module,
                                         jl_symbol("AbstractString"));
  }
};

template <>
struct JLCXX_API ConvertToJulia<cv::String, false, false, false> {
  jl_value_t* operator()(const cv::String& str) const {
    return jl_cstr_to_string(str.c_str());
  }
};

template <>
struct ConvertToCpp<cv::String, false, false, false> {
  cv::String operator()(jl_value_t* jstr) const {
    return cv::String(ConvertToCpp<const char*, false, false, false>()(jstr));
  }
};
}  // namespace jlcxx

全部で4個のテンプレート特殊化(IsValueType, static_type_mapping, ConvertToCpp, ConvertToJulia)で実現しています.

template <>
struct IsValueType<cv::String> : std::true_type {};

最初のこの部分は,cv::Stringが自動変換の対象になっていることを知らせるマーカみたいなものです,この特殊化があれば自動変換対象として扱うようです.

template <>
struct static_type_mapping<cv::String> {
  typedef jl_value_t* type;
  static jl_datatype_t* julia_type() {
    return (jl_datatype_t*)jl_get_global(jl_base_module,
                                         jl_symbol("AbstractString"));
  }
};

次のこの部分で,Julia側でどういうクラスに変換するかを決めているようです.この場合はAbstractStringとなっています.

template <>
struct JLCXX_API ConvertToJulia<cv::String, false, false, false> {
  jl_value_t* operator()(const cv::String& str) const {
    return jl_cstr_to_string(str.c_str());
  }
};

ここからが本番.このテンプレートクラスでC++のクラスからJuliaの型に変換する方法を書きます.なかで使っているjl_cstr_to_string関数はjulia本体のライブラリにあるC文字列をjuliaの文字列型に変換する関数です.

template <>
struct ConvertToCpp<cv::String, false, false, false> {
  cv::String operator()(jl_value_t* jstr) const {
    return cv::String(ConvertToCpp<const char*, false, false, false>()(jstr));
  }
};

最後のこの部分は,逆にjuliaのクラスからC++のクラスへの変換を行う方法を書きます.ConvertToCpp<const char*...の部分はJlCxxで用意されているconst char*をC++に変換するためのテンプレートです.

この4個のテンプレート特殊化を実装すれば,add_typeで登録していない型でも,自動的に相互変換のコードが挿入されてJulia側ではJuliaのクラスとして扱うことができます.また,この変換を楽にするために,JuliaのArrayクラスやTupleクラスをラッパーないで扱うためのユーティリティがいろいろと用意されています.

例えば,次のようなコードでcv::MatクラスをJuliaのArrayクラスに変換できます.

    cv::Mat mat(...);
    int depth = mat.depth();
    int channels = mat.channels();
    jl_array_t *julia_array = wrap_array(true, (uint8_t*)mat.data, mat.size[0], mat.size[1], channels);

ポイントはwrap_arrayの部分で,この関数でc++のポインタをラップしたArrayクラスを作成してくれます.その他の例はOpenCV.jlのmodules.hを見てください.

ただし,注意はConvertToCppが呼び出されるタイミングで,直接C++の関数/メソッドを呼び出す式の実引数として埋め込まれるために呼び出す関数へは右辺値として渡されるようです.そのため,引数がconstでない参照へ渡すとnon const lvalueへ渡されたというエラーがでてコンパイルできません.この解決方法はまだわかっていません.C++の仕様はそこまで詳しくありませんが,そもそも本質的にConvertToCppのoperator()メソッドの中で宣言している変数の寿命は,埋め込まれた関数が呼び出される前に終わってしまうはずなので,この部分だけでは解決のしようがないように思います.

ちなみにOpenCV.jlでは,とりあえずコンパイルを通すために,関数呼び出しの部分で,一旦constで受けておいてconst_castでnon constにするという禁じ手をつかっているので,おそらく現状では non const referenceの部分はまともに動かないかと思います.

最後に

CxxWrap.jlはCxx.jlに比べても更にマイナーみたいで,英語含めて言及している人がごくわずかなので,どれくらい要望があるかありませんが,参考になる人がいれば幸いです.

また,成果物のOpenCV.jlも,現状パッケージのコンパイルとusingが通ることを確認したくらいで,ほかはimreadとimwriteといくつかの関数を使ってみたくらいですが,試してみたいという方は使ってみてください.このあとも自分が使う関数から順番に確認,対応していきますが,コメントなどがあればどれくらい対応できるかわかりませんが,参考にいたします.

OpenCV.jlを作るにあたっては,大量にあるOpenCVのクラスや関数のラッパを書く必要があるため,opencv2/opencv.hpp をClang.jl(libclangのjuliaインタフェース)を使って解析して,ラッパを自動生成しておいて,そこからエラーが出るところを一つ一つ潰していくという手法をとりました.理想は完全に自動生成ですが,OpenCVの中には複雑なクラスも数多くあり,まだそこまではいたっていません.自動生成要のスクリプトもgenerateWrapper.jlというファイルでOpenCV.jlの中においてあるので,気になる人がいれば参考にしてください.