Pythonは可読性や生産性の高さに加えて、豊富で使い勝手の良い優れたライブラリ群により大人気のプログラミングの言語ですが、実行速度が遅くて困る場面に遭遇することがあります。たとえば、あるプログラムをライブラリを使わず自作で設計するときに、forループの処理、かつ、その中で配列の添え字操作なんかを行うと、要求する処理速度に全く届かない事になりがちです。本稿では別の記事で紹介した「輪郭追跡処理をやってみよう(その2)」の中で作ったC言語による拡張モジュールや処理速度について紹介します。

動機:特定の関数の実行速度を早くしたい
画像処理を扱っていると画像へのフィルタや演算のため画素一つ一つにアクセスする処理を多用します。この場合、画像データ(画素数)が大きいとそれだけ画像の配列データへのアクセス数が増えるため時間がかかることになります。もし動画を扱い、フレームごとに画像処理を実行しなければならない場合、この実行時間はとても重要になります。仮に30fpsの動画だと1フレームあたりに許される画像処理の実行時間は33ms未満になります。60fpsだと16msくらいしか許容されません。「輪郭追跡処理をやってみよう(その1)」や「輪郭追跡処理をやってみよう(その2)」で扱った2値化処理や、輪郭抽出処理のスタート座標を得るための頂点探索処理は画像データのすべての画素にアクセスするため、Pythonで可読性良くシンプルに記述すると長い時間がかかることが分かります。時間計測は、time.perf_counter()を用いました。

  • 2値化処理(520×480x1ch画像)の実行時間:約477ms(5回平均)
  • 頂点探索処理(520×480x1ch画像)の実行時間:約650ms(5回平均)

1フレームあたり他にも処理すべきことがある中で、上記の2つだけで1秒以上かかることになります。

下記は、2値化処理をPythonでベタに書いた例です。上記の実行時間は520×480x1ch画像から2値化画像を得るのに要した時間です。”for文使ったら負け”とか”配列の添え字アクセス使ったら負け”とか聞いたことがあるのですが、こういうことなのですね。

    # 空の二次元配列を作成
    bin_img = np.empty((row_size, column_size), dtype=np.uint8)
    # 輝度値が閾値より暗ければ(小さければ)1に変換、そうでなければ0に変換し2値化する
    for row in range(row_size):
        for column in range(column_size):
            if gray_img[row][column] > threshold:
                bin_img[row][column] = 0
            else:
                bin_img[row][column] = 1
    return bin_img

工夫した結果
2値化処理であれば一般的な画像処理なのでライブラリに頼るのが良さそうです。NumpyとかOpenCVとか。変わったことをやろうとするとライブラリが無いので自作することになります。C言語で記述して拡張モジュールを作りそれをPythonから呼び出すことにしました。解説の前に先に2値化処理と頂点検索処理の実行時間比較を示します。いずれも5回平均です。(平均回数、有効数字や桁数の妥当性は目をつぶってください)さすがOpenCVですね。Cを使って拡張モジュールを作るのも良いですが、せっかくのPythonの高い可読性と生産性がこの点は犠牲になってしまいます。一般的な画像処理であれば素直にOpenCVなどのライブラリを使うのが良さそうです。

Python ベタ書きPython+NumpyOpenCV(Python)Python+C(拡張モジュール)
477.132 ms0.592 ms0.138 ms0.196 ms
2値化処理(520×480x1ch画像)の実行時間

一方、一般的じゃないことをする選択をした、せざるを得ない場合は、Cによる拡張モジュールの作成をやる価値がありそうですね。(JIT/mumbaも使ってみましたが拡張モジュールを作る方が圧倒的に改善したのでJITの評価結果は割愛します。)

Python ベタ書きPython+C(拡張モジュール)
651.462 ms0.553 ms
頂点探索処理(520×480x1ch画像)

Cによる拡張モジュールの作成の流れ
動機と結果を先に紹介しました。ここでは本題である実行速度を改善したい関数をC言語で記述して拡張モジュールを作成してPythonから呼び出してみましょう。次の工程を踏みます。

  1. Pythonから呼ぶCまたはC++関数の作成
  2. Pythonモジュールのビルド&インストールのための”setup.py”の作成
  3. Pythonに作成したモジュールのインストールあるいはインストールなしで利用

Pythonから呼ぶCまたはC++関数の作成
見慣れない雰囲気で一見ハードルが高いのですが、注意すべき点は以下の4つでしょうか。
1. Python.hをインクルードする
2. 配列を扱う場合は、numpy/arrayobject.hもインクリードする
3. ”引数”と”関数”の型には”PyObject”を使う
4. 引数を教えるための表記の型がある
5. 引数を扱う際はパースが必要

引数から値またはオブジェクト(今回は画像)へのポインタを受け取ってしまえば後はC言語で処理を記述すれば良いです。一方、C言語にはなくてPythonにあるようなリスト形式をリターンしたい、NdArrayのオブジェクトをリターンしたい場合は、Python側の作法に従ってAPIを使うことになります。そのあたりを踏まえて、Cによる拡張モジュール例のソースを見ていただければと思います。

2値化処理は、static PyObject* binarizer(PyObject *self, PyObject *args)、
頂点探索処理は、static PyObject* summit_scanner(PyObject *self, PyObject *args) という名前で定義しています。

#include <Python.h>
#include <numpy/arrayobject.h>


static PyObject* hello_world (PyObject *self, PyObject *args) {
  printf("Hello_world\n");
  // C側で作成する関数のreturnはPy~~...となる
  Py_RETURN_NONE;
}

//2値化処理
static PyObject* binarizer(PyObject *self, PyObject *args)
{
	PyArrayObject *image_array;	    //The python objects to be extracted from the args
	int threshold;                  //The integer to be extracted from the args

    // Argument parsing, Object(image) and Integer
	if (!PyArg_ParseTuple(args, "Oi", &image_array, &threshold)) {
		fprintf(stderr, "invalid argument\n");
		Py_INCREF(Py_None);
		return Py_None;
	}

    // Get matrix dimensions
	int row_size = PyArray_DIM(image_array, 0);
	int columns_size = PyArray_DIM(image_array, 1);
	// Get the pointer of image data
    npy_intp* SnpArr = PyArray_STRIDES(image_array);


    // Get the new image area and pointer
    npy_intp dims[] = { row_size, columns_size, 1 };
	PyObject *new_image = PyArray_SimpleNew(2, dims, NPY_UBYTE);
	if (!new_image) {
		fprintf(stderr, "failed to allocate array\n");
		return Py_None;
	}
	PyArrayObject *new_image_array = (PyArrayObject *)new_image;

    // 2値化処理 ここではthresholdより大きい値を背景(0)としそうで無い場合をターゲット画素(1)に変換する
    // 画像データは2次元配列であるが1次元配列の体でポインタアクセスする。(バイトアクセス)
	for (int y = 0; y < row_size; y++) {
	    unsigned char const *src_array   = (unsigned char const *)image_array->data + (y) * columns_size;
	    unsigned char *dst_array   = (unsigned char *)new_image_array->data + (y) * columns_size;
	    for (int x = 0; x < columns_size; x++) {
	        if(src_array[x] > threshold){
	            dst_array[x] = 0;
	        }else{
	            dst_array[x] = 1;
	        }

	    }
	}
	return new_image;
}

static PyObject* summit_scanner(PyObject *self, PyObject *args)
{
	PyArrayObject *image_array;	    //The python objects to be extracted from the args
	 // Argument parsing, Object(image)
	if (!PyArg_ParseTuple(args, "O", &image_array)) {
		fprintf(stderr, "invalid argument\n");
		Py_INCREF(Py_None);
		return Py_None;
	}

    // Get matrix dimensions
	int row_size = PyArray_DIM(image_array, 0);
	int columns_size = PyArray_DIM(image_array, 1);
	// Get the pointer of image data
    npy_intp* SnpArr = PyArray_STRIDES(image_array);
//    頂点であると判断する15パターンのどれかに該当するか確認し、頂点であればその座標を記録する。
//    頂点は後に実行されるエッジトレースのスタート座標を示す。
//    b3 b2 b1
//    b4 b8 b0
//    b5 b6 b7
    int judge;
    int is_first = 1;
    int count = 0;
    int b8, b7, b6, b5, b4, b3, b2, b1, b0;
    unsigned char const *array_y_prev;
    unsigned char const *array_y;
    unsigned char const *array_y_next;

    PyObject* xy_list;
    PyObject* summit_list;
    summit_list = PyList_New(1);

	for (int y = 1; y < row_size-1; y++) {
	    array_y_prev = (unsigned char const *)image_array->data + (y-1) * columns_size;
	    array_y      = (unsigned char const *)image_array->data + (y) * columns_size;
	    array_y_next = (unsigned char const *)image_array->data + (y+1) * columns_size;
		for (int x = 1; x < columns_size-1; x++) {
            b8 = array_y[x];
            b7 = array_y_next[x + 1];
            b6 = array_y_next[x];
            b5 = array_y_next[x - 1];
            b4 = array_y[x - 1];
            b3 = array_y_prev[x - 1];
            b2 = array_y_prev[x];
            b1 = array_y_prev[x + 1];
            b0 = array_y[x + 1];
            judge = b8 & (~ (b1 | b2 | b3 | b4)) & (b0 | b5 | b6 | b7);
            count = count +  judge;
            if (judge){
                xy_list = PyList_New(2);
                PyList_SET_ITEM(xy_list, 0, PyLong_FromLong(x));
                PyList_SET_ITEM(xy_list, 1, PyLong_FromLong(y));
                if(is_first){
                    PyList_SET_ITEM(summit_list, 0, xy_list);
                    is_first = 0;
                } else {
                    PyList_Append(summit_list, xy_list);
                }
            }
		}
	}

    //printf("%d", judge);
	return summit_list;
}
static PyMethodDef myMethods[] = {
    { "hello_world", (PyCFunction)hello_world, METH_NOARGS, "my_module : hello_world"},
    { "binarizer", (PyCFunction)binarizer, METH_VARARGS, "my_module : binarizer" },
	{ "summit_scanner", (PyCFunction)summit_scanner, METH_VARARGS, "my_module : summit_scanner" },
	{ NULL }
};

static struct PyModuleDef my_module = {
	PyModuleDef_HEAD_INIT,
	"my_module",
	"Python3 C API Module",
	-1,
	myMethods
};

PyMODINIT_FUNC PyInit_my_module(void)
{
	import_array();
	return PyModule_Create(&my_module);
}

少し補足すると、Pythonからの引数をパースする PyArg_ParseTuple(args, “Oi”, &image_array, &threshold)を見ると、中に”Oi”という表記があります。これは続く &image_array, &thresholdが、それぞれオブジェクト型とInteger型だよというのを示しています。この辺は大量に定義されているので公式文書にあたりながら記述することになります。作成したコードの後半部分に関数本体とは違う定義ありますが、Pythonでモジュールとして扱うための定義なり初期化の作法なので、先人達が作ったたくさんのサンプルを参照しながらまねて作成しましょう。

Pythonモジュールのビルド&インストールのための”setup.py”の作成
これも見よう見まねで良いでしょう。

from distutils.core import setup, Extension
import numpy as np

setup(name='my_module',
      version='1.0',
      ext_modules=[Extension('my_module', ['my_module.c'])],
      include_dirs=[np.get_include()]     
      )

モジュール化するCのファイルと、上記Setup.pyの準備ができたらビルドです。今回はPythonにインストールすることなく拡張モジュールを使う方法を示します。(Pythonにインストールして使いたい場合は下記の参考リンクにやり方が書かれていますので参照してください。)モジュール化するCのファイルとsettup.pyのあるところからTerminalで以下のコマンドを実行します。

python setup.py build_ext -i

ビルドした結果エラーが無く成功すると、Windows環境の場合、拡張子が.pydの長い名前のファイルができあがります。筆者の場合はこんなファイル名です。 my_module.cp39-win_amd64.pyd

このファイルを、拡張モジュールを活用したいPytthonのプロジェクトフォルダにコピーします。そして拡張モジュールを活用するPythonのコード内で、import my_module as my などとすれば拡張モジュールをインポートしてモジュールのメソッドを利用することができます。

C言語による拡張モジュールの作成要領は、「【Python C API入門】C/C++で拡張モジュール作ってPythonから呼ぶ -前編-」「【Python C API入門】C/C++で拡張モジュール作ってPythonから呼ぶ -後編-」や、「Pythonで読み込んだ画像にフィルタをかけるモジュールをC言語で作った」を見てやり方を学びました。ありがとうございます。