opencvr: OpenCV Rubyバインディング

OpenCVの新しいRubyバインディングであるopencvr-0.1をリリースしました。とは言っても現状はまだ足りない機能が多く実用的とは言い難い状態なので、どちらかと言うと「OpenCVのRubyバインディングの開発を始めました」というアナウンスという位置づけです。

https://github.com/wagavulin/opencvr

gemはまだ作っていないのでコマンド一発でインストールとはいかないですが、apt/brewでインストールしたOpenCVを使ってビルドできるようにはなっています。Ubuntu-20.04かmacOSの環境なら試すのはそんなに難しくないはずなので、気が向いたら試してもらえればと思います。方法はREADME.mdのHow to installを参照してください。

サンプルとスクリーンショット

元画像

f:id:wagavulin:20150315101002j:plain

Drawing

画像に矩形や直線などの図形や文字を書き込みます。

f:id:wagavulin:20210707190930j:plain

#!/usr/bin/env ruby
require 'cv2'
img = CV2::imread("input.jpg")
CV2::putText(img, "Hello OpenCV", [50, 50], CV2::FONT_HERSHEY_DUPLEX, 1.0, [0, 0, 0], lineType: CV2::LINE_AA)
CV2::imwrite(__dir__ + "/out-drawing.jpg", img)

油絵風

f:id:wagavulin:20210707190936j:plain

#!/usr/bin/env ruby
require 'cv2'
img = CV2::imread("input.jpg")
out = CV2::Xphoto.oilPainting(img, 2, 5, CV2::COLOR_BGR2Lab)
CV2.imwrite(__dir__ + "/out-oil.jpg", out)

鉛筆画風

白黒

f:id:wagavulin:20210707190946j:plain

色付き

f:id:wagavulin:20210707190954j:plain

#!/usr/bin/env ruby
require 'cv2'
img = CV2::imread("input.jpg")
out1, out2 = CV2.pencilSketch(img, sigma_s: 60, sigma_r: 0.07, shade_factor: 0.05)
CV2.imwrite(__dir__ + "/out-pencil1.jpg", out1)
CV2.imwrite(__dir__ + "/out-pencil2.jpg", out2)

使い方

Python版とだいたい同じです。

// C++
cv::Mat img = cv::imread("input.jpg", cv::IMREAD_COLOR);
# Python
img = cv2.imead("input.jpg", cv2.IMREAD_COLOR)
# Ruby
img = CV2::imread("input.jpg", CV2::IMREAD_COLOR)
  • C++ APIでオプショナルになっている引数はRubyでも省略可能です。
  • オプショナルな引数はキーワード引数も使えます。キーワード名はC++ APIの仮引数名と同じです。
  • 必須引数はキーワード引数にはできません。
  • CV2以外の名前空間名は、Rubyでは最初の文字のみ大文字にしたものになっています。
    • C++のcv.xphoto.oilPainting()CV2::Xphoto::oilPainting()です。
  • cv::Size, cv::Point, cv::RectなどはArrayになります。
    • 例えばcv::Sizeは数値2つを持つArrayです。
  • 引数が出力に使われる場合(引数が非const参照など)、結果は戻り値として帰ります。

OpenCVのAPI ReferenceにはPython APIも載っているで詳しくはそちらを参考にしてください。例えば以下のcv::clipLine()は、C++では3つの引数 imgSize, pt1, pt2を受け取りboolを返しますが、見ての通りpt1, pt2は非const参照で出力にも使われます。従ってPython/Ruby APIでは戻り値が3つになります。

f:id:wagavulin:20210708192137p:plain

なぜ作ったか

OpenCVは非常に有名な画像処理・コンピュータビジョンのライブラリで、C++で書かれてます。Pythonバインディングは公式に提供されていますがRubyバインディングについてはgemがいくつかありますが、決定版と言えるものはない感じです。古いバージョンについてはruby-opencvがよく使われていましたが、最新のOpenCV-4系では使えません。OpenCV-2.XまではC++/C両方のAPIが用意されており、ruby-opencvはC APIを使ってバインドされていましたが、4.0からはC APIが廃止されてしまったためです。

red-opencv, opencv-glib

新しいOpenCVに対応したRubyバインディングとして、red-opencvとopencv-glibがあります(2つを組み合わせて使います)。まずopencv-glibはOpenCVのC++ APIのGObjectバインディングを提供します。GObjectの詳細はここでは省きますが、GObjectからはRubyだけでなくPerl, Javaなどといった様々なプログラミング言語のバインディングを自動生成することができます。これを使ってRubyバインディングを提供するのがred-opencvです。

このopencv-glibについては私も以前に開発に参加して私のコードも少し入っていますが、手が止まってしまいました。理由は大きく2つあります。

  • OpenCVのAPIにglibバインディングではうまくできないところがあった。
  • OpenCVには大量のクラス・関数があり、手作業でのバインディングでは終わりが見えなかった。

前者の問題についてはglibに対する私の知識不足もあるので、実はたいした問題ではないかもしれません。しかし後者は由々しき問題です。試しにPython APIでバインドされている関数・enumをざっと数えたところ4000近くありました。

  • クラスに属さないグローバルな関数: 892
  • クラスに属する関数: 2925
  • enum型: 195

手作業でやると毎日休まず1日10個作っても10年以上かかるわけで、この数字を見たときに心が折れました。

ちなみに別の方法としてPyCallを使うというのがあります。私は試してはいませんが、Pythonバインディングが既に存在する以上、PyCallを使えばRubyからも呼べるはずです。ただそれを言ってしまうと話が終わってしまうのでそこはスルーして進めます。とにかくRubyから直接呼びたいのです(あんまり深い理由はないですが)。

Pythonバインディングとopencvr

では公式でサポートされているPythonバインディングはどうしているかというと、バインディングコードを自動生成しています。OpenCVのC++ APIのヘッダファイルからインターフェース情報を読み取り、それを基にバインディングコードを生成します。具体的にはhdr_parser.pyというスクリプトでヘッダファイルの情報を読み取り、それを基にgen2.pyがPythonのバインディングコードを生成します(もっと細かく言うと、自動生成コード以外ににいくつかの.cpp, .hppを使っています)。

f:id:wagavulin:20210708022558p:plain

この仕組みを真似すればRubyバインディングも自動生成できるはず、という発想で作ったのがopencvrです。hdr_parser.pyはそのまま流用し、gen2.pyを独自のものに置き換えています。Python用のgen2.pyやその他.cppファイルなど合わせて4000行くらいあり、それのRuby版を作るわけですが、4000個の関数のバインディングコードを書くよりはかなり作業量は減るはずです。

実装状況

こうして始めたopencvrですが、実装状況はまだ未熟です。ざっくり言うとバインドできているのは以下の条件を満たすもののみです。

  • クラスに属さないグローバル関数である
  • 引数・戻り値ともint, floatなどの基本型もしくはcv::Size, cv::Rectなど、Ruby側でArrayにバインドされているものである。
    • std::vector<int>など、これらを要素に持つstd::vectorはサポート。
  • クラスは現状未サポートだが、cv::Matのみは使用可能(というかこれがないと始まらない)。ただし使えるメソッドはcols(), rows(), channels(), at()のみ。

グローバル関数のみ、と聞くとほとんど何もできないじゃないかと思われそうですが、OpenCVのAPIはグローバル関数になっているものが結構あるのでそれなりに使えます。詳しいバインド状況はWikiページにあります。

またOpenCVの中心的なクラスであるcv::Matのメソッドがほとんど対応していないのは理由があって、次に書きます。

cv::Matと数値計算ライブラリ

OpenCVでは画像データなど多くのデータを表すのにcv::Matクラスを使います(APIリファレンス上ではcv::InputArray, cv::OutputArray, cv::InputOutputArrayになっているところ)。Python APIではこのcv::Matクラスに対応するPythonのクラスを作るのではなく、Numpyのndarrayクラスを使っています。例えば画像ファイルを読み込むcv::imread()関数は、C++ APIではcv::Matインスタンスを返しますが、Python APIではndarrayを返します。Pythonの画像処理・機械学習・データサイエンス系のライブラリの多くはndarrayを使っているので、それらのライブラリと容易に連携させることができるわけです。

import cv2
img = cv2.imread("input.jpg")
print(img.__class__) # => <class 'numpy.ndarray'>

これをどうやって実現しているかというと、cv::MatのAllocatorという機能を使っています。これはcv::Matが内部で使うメモリを確保するときに使用する関数を指定するもので、これとNumpyのC APIを組み合わせて実現します。例えばcv::imread()を読んだとき、以下のことをやります。

  • cv::imread()の戻り値 (m) とは別に空のcv::Matインスタンス (temp) を作る。
  • tempのAllocatorに独自のAllocatorをセットする。
  • mtempにコピーする。
    • このとき独自のAllocatorが使われる。その中ではNumpyのPyArray_SimpleNew()を使ってメモリを確保する。
    • 確保した領域へのポインタがcv::Matインスタンス内にセットされる。

出来上がったtempとPython側に返すポインタは以下のようになっています。

f:id:wagavulin:20210707192520p:plain

現状のopencvrはこのようなことはやってはおらず、Ruby側にもMatクラスを定義するようになっています。Pythonと同様のことをするならNumo::NArrayが候補になると思いますが、そもそも技術的に可能かどうかの検討もしていないので今後どうするかは未定です。cv::Matクラスのバインディングが手抜きなのはこれが理由です。

なお検討していない理由は時間の都合もさることながら、Numo::NArrayのC APIの使い方がさっぱり分からないというのが原因なので、分かりやすいドキュメントなどあったら教えてほしいです。

まとめ

  • OpenCVのRubyバインディング作ってます
  • 現状は未対応の部分がたくさんあります
  • Python版の仕組みを真似しているので、Python版の同等のことが現実的な工数できるはずです
  • Numpyみたいなものを使うかは未定です

と、ここまで書いたところで改めてgemを探すとropencvというgemがC++ヘッダから自動的にバインドするRubyインターフェースを提供しているっぽいのを見つけました。opencvr-0.1.0を作るまでにそれなりに時間使ったのですが、無駄だったんですかねぇ。