HaskellからFFIでOpenCVを使ってみよう

この記事は Haskell Advent Calendar 2013 の二日目のために慌てて書いたものです.

FFIを使って,画像ライブラリOpenCVを使ってみましょう.OpenCVはBSDライセンスで公開されているフリーの画像処理ライブラリで,物体認識などの比較的高度な機能が実装されてます.今回はそのような高度な機能は使わず,画像の読み込みと書き出しと表示,さらに線を引いたり円を描いたりする初歩的な描画関数だけを使います.

Haskellでは,Foreign Function Interface(FFI,外部関数インタフェース)を通じて,Haskell以外の言語とやりとりが出来ます.FFIには二つの方向があります.

  • 他の言語が持つ機能をHaskellから利用するためのインタフェースを記述する.
  • Haskellで書かれた関数を他の言語から利用する.

本記事では一番目について,とくにC/C++コンパイラで作成されたライブラリの使用方法に限定して解説します.HaskellのFFIの概要は Haskell 2010 Language Reportの Chapter 8 で知ることが出来ますが,FFI と深い関わりのあるライブラリの章(Chapter 25 – Chapter 37)まで含めれば,Haskell 2010 Language Report のページの20%を超える部分がFFIの解説に割かれています.私自身が Chapter 8 をざっと読んだ程度の知識なので,以下の解説でも深いことは触れられません.ご了承ください.

実のところ,一番目の事項,つまりC/C++コンパイラで作成されたライブラリを使う話に限ったとしても,わざわざFFIを学ぶ必要はないかもしれません.というのも,多くの有用なライブラリに対してFFIを用いたHaskellバインディングがすでに作られているからです.しかしながら,自作のライブラリや世間であまり注目されていないライブラリをHaskellから使いたくなる可能性があるならばFFIを学ぶ理由はあるでしょう.もったいぶった前置きをしましたが,本記事で例として取り上げるOpenCVについても,実のところすでにある程度Haskellバインディングが用意されているので,この記事を読まなければHaskellからOpenCVを使えない訳じゃないですよということをかっこよく言いたかっただけです.

さて,ライブラリの実体は,ABIに従うように生成され,そのABIにしたがって解釈したり実行できるバイト列でした.Haskellからライブラリを使うためにはどのようなABIに従うのかを指定する必要があります.現状では ccall, stdcall, cplusplus ぐらいしか選択肢はないようです.以下では ccall を前提とします.

たとえば,Cの標準ライブラリ math.h で宣言されている
double erfc(double x);
をHaskellからFFIで使うコードの例は次のようになります:

import Foreign.C.Types

foreign import ccall "math.h erfc" c_erfc :: CDouble -> CDouble
erfc :: Double -> Double
erfc x = realToFrac $ c_erfc $ realToFrac x
main = putStrLn ("erfc(1.0)= " ++ show (erfc 1.0) )

上のコードはコマンドプロンプトから $ runghc erfc_from_libc.hs としてやれば実行できます.
上のコードの三行目の「ccall "math.h erfc"」の部分は,「C呼び出し規約に従って,math.h で宣言されている erfc を頼む」と読みましょう.さて,その行の残りのc_erfc :: CDouble -> CDouble に出てくる「CDouble」という型は,最初の行のimport Foregn.C.Typesでインポートされています.一般に,Cの double と Haskellの Double は同じ範囲を表せる保証がありません.そこで,Cにおける基本型のほとんどに対して大文字のCで始まる対応物がForegn.C.Typesで定義されています.Doubleの場合は殆どのコンパイラではCのdoubleと同じ範囲を表すんじゃないかと思いますが,とにかく違う型という扱いなので,これをHaskellの関数にラップしたerfc :: Double -> Double の定義では,引数の変形と返り値の変形の合計2回にわたってrealToFracを使っています.

実数や整数の変換については

fromIntegral :: (Integral a, Num b) => a -> b
truncate :: (Integral b, RealFrac a) => a -> b
realToFrac :: (Fractional b, Real a) => a -> b

が便利だったことをこの辺で思い出しましょう.

さて,Cライブラリの関数が副作用を伴わない場合は,話が比較的簡単です.では副作用を持つ関数を考えてみましょう.副作用というのはやや曖昧な言葉ですが,参照透明な関数とは異なった挙動をするもの,例えばI/O関係の関数は副作用を持つと言えます.Haskellでは副作用をアクションとして切り出してモナドとして扱うことによって,参照透明性を保ったままIOを行う処理が(ずばりIOという名前のモナドで)書けるのでした.

ではここでOpenCVを使って副作用を行うC++関数を書いてみましょう.(ちょっと流れが強引ですかね).

extern "C"
{
	void show_madoka();
}

extern "C"を指定しておくことで,生成されたオブジェクトコード中のシンボルがC呼び出し規約で読み込めることが保証されるのでした.)

#include "imshow.h"
#include  //opencv
#include //opencv
#include //opencv
#include 

void show_madoka(){
  using namespace std;
  ::cvNamedWindow("madoka");
  IplImage* pImg = cvLoadImage("madoka.jpg");
  if (!pImg) {
    cerr << "You need madoka.jpg" << endl;
    return;
  }
  cout << "#### 'ESC' to exit" << endl;
  ::cvShowImage("madoka", pImg);
  // ユーザが ESC キーを入力するまで待つ
  enum { Wait_msec = 100, ESC_key = 27,};
  while (true) {
    if (cvWaitKey(Wait_msec) == ESC_key) {break;}
  }
  ::cvDestroyWindow("madoka");
  ::cvReleaseImage(&pImg);
  return;
}

関数void show_madoka() はHaskellからインポートするときにどうなるでしょうか. () -> ()でしょうか?いいえ,()の値などありませんから,型シグネチャが縮退して()になる気がします.ではやってみましょう:

import Foreign.C.Types
foreign import ccall "imshow.h show_madoka" c'show_madoka_bozo :: ()

main = do
  return c'show_madoka_bozo
  putStrLn "madoka"

do式の中で c'show_madoka を置きたかったけれどIOモナドの分だけずれてるのでreturnしてやって型を合わせています.これでばっちり動くはず…なのに何も起きません!これが現実…!

待ってください.画像を表示するためにウィンドウを開いたりするのは副作用だからHaskellの側で受けるときの型シグネチャをIO () にしたら上手くいくのでは? やってみましょう:

import Foreign.C.Types
foreign import ccall "imshow.h show_madoka" c'show_madoka :: IO()

main = do
  c'show_madoka
  putStrLn "madoka"

今度はちゃんと上手く表示されました.(なお,画像にマウスカーソルを合わせてESCを押さないと正常に終了できません.ご了承ください.)

(ちなみに,imshow-bozo.hs や imshow-right.hs を動かすためにはリンクの指定などをする必要がありますが,この辺はいちいちコマンドラインで入力するのが面倒なのでMakefileで処理しています.記事の末尾にMakefileを載せることにします.)

いまとりあげた例は非常に小さいものですが,HaskellのFFIを使う上で気をつけなければならない事の例示となっています.とにかくヤバイということが伝われば幸いです.

さて,もうすこし丁寧に諸々のことを書いておきたい気持ちもありますが,表紙だとかスライドだとか年賀状だとか色々大変なので,今回カージオイド(という曲線)をHaskellからOpenCVの関数をFFI呼び出しして描いたコードをご紹介して記事を終えることにします.

次のヘッダファイルは,今回カージオイドを描くために使った関数をHaskellから import するために,必要に応じてラップしなおした関数の宣言を載せたものです.元々のOpenCVの関数では基本型の直積タイプの構造体を引数とする関数があり,Haskellで import すると扱いが面倒なので「全部基本型に展開して」しまっている箇所があります.

#include 

extern "C" {
	IplImage* load_image ( const char* filename);

	IplImage* create_image (
	int width
	, int height
	, const CvScalar color);

	void save_image (const char* filename, IplImage* pImg);

	void make_window (const char* winname);

	void show_image (const char* winname, IplImage* pImg);

	void destroy_window (const char* winname);

	void release_image ( IplImage* pImg );

	//crunched
	IplImage* create_image__crunched (
    int width
	, int height
	, double v0, double v1, double v2);

  //crunched
  void draw_line__crunched (
    IplImage* pImg,
    int pt1x, int pt1y,
    int pt2x, int pt2y,
    double v0, double v1, double v2,
    int thickness);

  void draw_circle__crunched (
  IplImage* pImg,
  int cx, int cy,
  int r,
  double v0, double v1, double v2,
  int thickness);
}

実装側は次のようにしました:

#include "ffi_opencv.h"
#include 
#include 
#include 

IplImage* load_image( const char* filename)
{
  return ::cvLoadImage(filename, CV_LOAD_IMAGE_ANYCOLOR);
}

IplImage* create_image( int width, int height, CvScalar color)
{
  IplImage* pImg = ::cvCreateImage(cvSize(width, height), IPL_DEPTH_8U, 3);
  ::cvSet(pImg, color);
  return pImg;
}

IplImage* create_image__crunched(
    int width,
    int height,
    double v0, double v1, double v2)
{
  return create_image(width, height, cvScalar(v0, v1, v2, 0));
}

void save_image(const char* filename, IplImage* pImg)
{
  ::cvSaveImage(filename, pImg);
  return;
}

void make_window(const char* winname)
{
  ::cvNamedWindow(winname);
  return;
}

void show_image(const char* winname, IplImage* pImg)
{
  ::cvShowImage(winname, pImg);
  enum {  Wait_msec = 100, ESC_key = 27,};
  while(true){ if (::cvWaitKey(Wait_msec) == ESC_key) {break;}}
  return;
}

void destroy_window(const char* winname)
{
  ::cvDestroyWindow(winname);
  return;
}

void release_image(IplImage* pImg)
{
  ::cvReleaseImage(&pImg);
  return;
}

void draw_line__crunched(
    IplImage* pImg,
    int pt1x, int pt1y,
    int pt2x, int pt2y,
    double v0, double v1, double v2,
    int thickness)
{
  ::cvLine(
    pImg
    , ::cvPoint(pt1x, pt1y)
    , ::cvPoint(pt2x, pt2y)
    , ::cvScalar(v0, v1, v2, 0)
    , thickness
    , CV_AA  );
  return;
}

void draw_circle__crunched(
  IplImage* pImg,
  int cx, int cy,
  int r,
  double v0, double v1, double v2,
  int thickness)
{
  ::cvCircle(
    pImg
    , ::cvPoint(cx, cy)
    , r
    , ::cvScalar(v0, v1, v2, 0)
    , thickness
    , CV_AA );
  return;
}

これらのコードを利用してカージオイドを描くHaskellプログラムは次のようになります:

import Foreign.C.Types
import Foreign.C.String
import Foreign.Ptr
import Control.Monad

data Point=Point{x::Double, y::Double}
data Scalar=Scalar{v0::Double, v1::Double, v2::Double}

toCInt :: Int -> CInt
toCInt = fromIntegral

toCDouble :: Double -> CDouble
toCDouble = realToFrac

data Image = Image
type ImagePtr = Ptr Image

type CV_Thickness = Int

foreign import ccall "ffi_opencv.h  load_image"
	c'load_image :: CString -> IO ImagePtr

foreign import ccall "ffi_opencv.h  create_image__crunched"
	c'create_image__crunched :: CInt -> CInt ->
				CDouble -> CDouble -> CDouble -> IO ImagePtr

foreign import ccall "ffi_opencv.h  save_image"
	c'save_image :: CString -> ImagePtr -> IO ()

foreign import ccall "ffi_opencv.h make_window"
	c'make_window :: CString -> IO ()

foreign import ccall "ffi_opencv.h show_image"
	c'show_image :: CString -> ImagePtr -> IO ()

foreign import ccall "ffi_opencv.h destroy_window"
	c'destroy_window :: CString -> IO ()

foreign import ccall "ffi_opencv.h release_image"
	c'release_image :: ImagePtr -> IO ()

foreign import ccall "ffi_opencv.h draw_line__crunched"
	c'draw_line__crunched :: ImagePtr -> CInt -> CInt -> CInt -> CInt -> CDouble -> CDouble -> CDouble -> CInt -> IO ()

foreign import ccall "ffi_opencv.h draw_circle__crunched"
	c'draw_circle__crunched :: ImagePtr -> CInt -> CInt -> CInt -> CDouble -> CDouble -> CDouble -> CInt -> IO ()

load_image :: String -> IO ImagePtr
load_image s = join $ fmap c'load_image $ newCString s

create_image :: Int -> Int -> Scalar -> IO ImagePtr
create_image w h (Scalar d0 d1 d2)
 = c'create_image__crunched (toCInt w) (toCInt h) (toCDouble d0) (toCDouble d1) (toCDouble d2)

save_image :: String -> ImagePtr -> IO ()
save_image s pImg =  join $ (liftM2  c'save_image) (newCString s) (return pImg)

make_window :: String -> IO ()
make_window s = join $ (liftM c'make_window) (newCString s)

show_image :: String -> ImagePtr -> IO ()
show_image s pImg = join $ (liftM2 c'show_image) (newCString s) (return pImg)

destroy_window :: String -> IO ()
destroy_window  s = join $ (liftM c'destroy_window) (newCString s)

release_image :: ImagePtr -> IO ()
release_image = c'release_image

draw_line__crunched :: ImagePtr -> Int -> Int -> Int -> Int -> Double -> Double -> Double -> Int -> IO ()
draw_line__crunched  pImg x1 y1 x2 y2 d0 d1 d2 c
  = c'draw_line__crunched pImg (toCInt x1) (toCInt y1) (toCInt x2) (toCInt y2) (toCDouble d0) (toCDouble d1) (toCDouble d2) (toCInt c)

draw_circle__crunched :: ImagePtr -> Double -> Double -> Double -> Double -> Double -> Double -> Int -> IO ()
draw_circle__crunched pImg cx cy r d0 d1 d2 t
  = c'draw_circle__crunched pImg cx' cy' r' d0' d1' d2' t'
    where
	  cx' = truncate cx
	  cy' = truncate cy
	  r' = truncate r
	  d0' = realToFrac d0
	  d1' = realToFrac d1
	  d2' = realToFrac d2
	  t' = fromIntegral t

draw_line ::  ImagePtr -> Point -> Point -> Scalar -> CV_Thickness -> IO ()
draw_line  pImg  Point{x=p1x,y=p1y}  Point{x=p2x,y=p2y}  Scalar{v0=d0,v1=d1,v2=d2} c = draw_line__crunched pImg p1x' p1y' p2x' p2y' d0 d1 d2  c
  where
    p1x' = truncate p1x
    p1y' = truncate p1y
    p2x' = truncate p2x
    p2y' = truncate p2y

draw_line_from_point_pair :: ImagePtr -> (Point,Point) -> IO ()
draw_line_from_point_pair  pImg  (p1,p2) = draw_line pImg p1 p2 (Scalar 0 0 0) 1

deg_to_rad :: Int -> Double
deg_to_rad t = (fromIntegral t) * pi / 180.0

shifted_sin :: Double -> Double
shifted_sin t =  c + r * sin t

shifted_cos :: Double -> Double
shifted_cos t =  c + r * cos t

main = do
  img >) nopIO (segments_cardioid img)
  fringe_circle img
  putStrLn "enter image save name : "
  savename  (Point, Point)
segment_info t = (
  Point{x=shifted_cos $ deg_to_rad t, y=shifted_sin $ deg_to_rad t},
  Point{x=shifted_cos $ deg_to_rad (2*t+180),  y=shifted_sin $ deg_to_rad (2*t+180)}
  )

segments_cardioid :: ImagePtr -> [IO()]
segments_cardioid  pImg = fmap ( (draw_line_from_point_pair pImg) . segment_info) deglist

fringe_circle :: ImagePtr -> IO()
fringe_circle pImg = draw_circle__crunched pImg c c r 128 128 128 1

実行すると保存するファイル名を聞いてくるので cardioid.jpg などと答えると画像を表示してくれます.画像にマウスカーソルを合わせてESCを押すと終了します.

こんな絵が生成されると思います:
cardioid

この絵は,円周上の対蹠する二点P,Qが,Pは角速度ωで,Qが角速度2ωで運動したときの線分PQの軌跡を描いたものです.屈折した三日月のような図形が線の中から浮かび上がっていますが,円から「折れた三日月」を取り除いたずんぐりとした形がなにやら動物の心臓を思わせるので「カージオイド(心臓形, cardioid)」と呼ばれています.円筒形の陶器のカップを日に晒すと円筒の内側で反射した光が屈曲して似たような図形を作りますが,あちらは腎臓形(nephroid)と呼ばれ,Pは角速度ωで,Qが角速度3ωで運動した場合に線分PQが作り出す包絡線です.

draw_cardioid.hs の説明をしておきましょう.今回描こうとしているものは円を基本とした図形なので,キャンバスの座標系に合わせたカスタム版の三角関数
shifted_sinshifted_cos
を用意しました.
deglist = [0, 3 .. 360-3]で3度刻みのリストを作っています.segments_cardioid :: ImagePtr -> [IO()]は定義部でこのdeglistを利用しており,「画像(へのポインタ)を受け取って線分を描くアクションのリストを返す」関数になっています.全部で120個のIO()(>>)でつないでmainを書いても良かったのですが面倒なのでfoldr (>>) nopIO (segments_cardioid img)としてやって合成したアクションを得ています.save_imageのあたりは,プログラムとしては別にイラナイのですが,Cの文字列が関与する関数の扱いを紹介したくてサンプルに入れた記憶があります.

この記事で使ったMinGW+MSYS用のMakefileです.環境に合わせて適宜修正して使ってください.

#####################################################
# Makefile for Haskell Advent Calendar 2013, Dec 2
#####################################################
.PHONY : madoka erfc_from_libc imshow_right imshow_bozo cardioid

GHC = ghc
LD_LIBRARY_PATH = $(PATH)
OPENCV_32_TRUNC = /c/opencv2.4.6.0-MSYS32
OPENCV_VERSION_SUFFIX = 246
OPENCV_HEADERS_INCLUDE32 = -I$(OPENCV_32_TRUNC)/include/opencv  \
-I$(OPENCV_32_TRUNC)/include
OPENCV_LIB_32_LOADPATH = -L$(OPENCV_32_TRUNC)/lib
CXXFLAGS = $(OPENCV_HEADERS_INCLUDE32)

LINK_OPENCV_LIBS = \
-lopencv_highgui$(OPENCV_VERSION_SUFFIX) \
-lopencv_core$(OPENCV_VERSION_SUFFIX) \
#-lopencv_objdetect$(OPENCV_VERSION_SUFFIX)  \
#-lopencv_nonfree$(OPENCV_VERSION_SUFFIX) \
#-lopencv_imgproc$(OPENCV_VERSION_SUFFIX) \
#-lopencv_legacy$(OPENCV_VERSION_SUFFIX)  \
#-lopencv_ml$(OPENCV_VERSION_SUFFIX)  \
#-lopencv_features2d$(OPENCV_VERSION_SUFFIX)  \

LDFLAGS = -lm -lstdc++ $(OPENCV_LIB_32_LOADPATH) $(LINK_OPENCV_LIBS)

madoka : show_madoka.o
	$(LINK.o)  -o madoka.exe  $(filter  %.o,   $^)  $(LDFLAGS)
	./madoka.exe

cardioid : draw_cardioid.hs ffi_opencv.o
	$(GHC) draw_cardioid.hs -o cardioid.exe ffi_opencv.o $(LDFLAGS)
	./cardioid.exe

imshow_bozo : imshow-bozo.hs  imshow.o
	$(GHC) imshow-bozo.hs -o imshow_bozo.exe imshow.o $(LDFLAGS)
	./imshow_bozo.exe

imshow_right : imshow-right.hs  imshow.o
	$(GHC) imshow-right.hs -o imshow-right.exe imshow.o $(LDFLAGS)
	./imshow-right.exe

erfc_from_libc : erfc_from_libc.hs
	$(GHC) erfc_from_libc.hs -o    erfctest.exe
	./erfctest.exe

clean :
	$(RM) ./*.o
	$(RM) ./*.exe

それでは皆様,良いお年を!

■2018/06/19 追記
FFIによるマーシャリングをこの記事で扱っている.

過去の投稿へ