LoginSignup
91
112

More than 1 year has passed since last update.

.NET (C#)からpythonを呼び出す

Last updated at Posted at 2019-07-23

.NET (C#)からpythonを呼び出す

pythonnetを使って、.NETからpython(cpython)コードを呼び出す方法。
例えば、「Chainerで作ったAI処理を、.NETでつくったGUIから叩きたい!」とかいうときに有用。

python -> .NET(C#)の資料はたくさんあるのに、.NET -> pythonの資料がほとんどない1ので作成。

前提

この記事は以下の知識があることを前提に作成した。

  • C#がある程度わかること。
  • pythonがある程度(pip等の環境管理の仕組みも含め)わかること。

手順

作業目標は、「Windows(x64)環境で、VS2017を使い、C#からpython3.7(x64)のコードを呼び出すサンプルを作る」とする。
なお、pythonnetは、本記事執筆時点(2019/07)で、python 3.7まで対応している。

pythonnetの設定

pythonnetをビルドするための設定を行う。
※nugetにビルド済のパッケージがあるが、あまり更新されていないようなので使用しない。

pythonnetのgithubリポジトリから最新コードをcloneし、pythonnet.slnを開く。
VS2015の場合はpythonnet.15.slnを使う。vs2019以降を使う場合は適当に変換してがんばる(未確認)。

ソリューションのビルド構成がいろいろあるので、適切なものを選ぶ。
構成名は、(Debug|Release)(Mono|Win)(|PY3)という構造になっている。
※ただし、この設定を廃止し、実行時に動的に選択可能にする改善が検討されているようなので、このあたりの手順は、そのうち大きく変わるかも。

  1. Debug|Release
    おなじみの、Debug/Releaseビルドの選択。

  2. Mono|Win
    Mono(Linux)用か通常のWindows用か。

  3. |PY3
    なにも付かないほうはpython2系向け、PY3はpython3系向けにビルド。

今回はWindows環境でpython3.7を呼び出すのが目的なので、ReleaseWinPY3を選ぶ。
なお、プラットフォーム(x86/x64)はどちらでもよい(今回ビルドしたいPython.RuntimeプロジェクトはAnyCPU設定になっているため)。

image.png

選んだら、プロジェクトPython.Runtimeのプロジェクト プロパティを開き、コンパイルシンボルを確認する。
PYTHON3;PYTHON37;UCS2のような文字列が定義されているので、狙ったpythonバージョンになっているか確認する。

image.png

今回はpython3.7を動かすのでPYTHON37でよいが、python3.5にしたい場合はPYTHON35のように書き換える。
有効な設定値は、src/runtime/runtime.csの#if節を参照するとわかる。

image.png

pythonnetのビルド

設定を済ませたプロジェクトをビルドする。

src/runtime/bin以下にPython.Runtime.dllが出力されるので、回収しておく。

同時に、Python.Runtime.pdb(デバッグ情報)とPython.Runtime.xml(ドキュメントコメントの情報)も回収しておくと、あとで少し幸せになれるかも。

image.png

呼び出し元プロジェクトの作成

pythonコードの呼び出し元となるプロジェクト / ソリューションを作る。

サンプルでは、.NET Framework 4.6.1向けのC#コンソール アプリとした。

※pythonnetは.NET Framework 4をターゲットに作られているので、それを呼び出せる設定にすること。

image.png

プラットフォームの変更

64bit用のPythonのDLLを呼び出すには、呼び出し元のexeも64bit向けにビルドされている必要があるため、その設定を行う。

作成したソリューションの構成マネージャーを開く。

プロジェクトのプラットフォームがAnyCPUとなっているので、x64を新規作成する。
※このとき、"新しいソリューション プラットフォームを作成する"にチェックを入れておくこと。

image.png

image.png

作成したら、プロジェクト プラットフォームとソリューション プラットフォームの両方から、AnyCPU設定を削除しておく。
※残しておくと、うっかりAnyCPU設定を使ってしまってドハマリする場合があるため。

image.png
image.png
image.png
image.png

参照設定

pythonnetのビルド時に回収しておいたDLLを、作成したプロジェクトのディレクトリにコピーする。

プロジェクトの参照設定から、コピーしたDLLに参照を設定する。
image.png

image.png
※見逃しがちだが、右下に参照ボタンがある。

image.png
追加されるとこうなる。

実装

以下を実装する。

using Python.Runtime;
using System;
using System.IO;
using System.Linq;

namespace PythonTest
{
    class Program
    {
        /// <summary>
        /// プロセスの環境変数PATHに、指定されたディレクトリを追加する(パスを通す)。
        /// </summary>
        /// <param name="paths">PATHに追加するディレクトリ。</param>
        public static void AddEnvPath(params string[] paths)
        {
            var envPaths = Environment.GetEnvironmentVariable("PATH").Split(Path.PathSeparator).ToList();
            foreach (var path in paths)
            {
                if (path.Length > 0 && !envPaths.Contains(path))
                {
                    envPaths.Insert(0, path);
                }
            }
            Environment.SetEnvironmentVariable("PATH", string.Join(Path.PathSeparator.ToString(), envPaths), EnvironmentVariableTarget.Process);
        }

        /// <summary>
        /// プログラムのエントリポイント。
        /// </summary>
        /// <param name="args">コマンドライン引数。</param>
        static void Main(string[] args)
        {
            // *-------------------------------------------------------*
            // * python環境の設定
            // *-------------------------------------------------------*

            // python環境にパスを通す
            // TODO: 環境に合わせてパスを直すこと
            var PYTHON_HOME = Environment.ExpandEnvironmentVariables(@"%userprofile%\Anaconda3\envs\pythonnet_test");

            // pythonnetが、python本体のDLLおよび依存DLLを見つけられるようにする
            AddEnvPath(
              PYTHON_HOME,
              Path.Combine(PYTHON_HOME, @"Library\bin")
            );

            // python環境に、PYTHON_HOME(標準pythonライブラリの場所)を設定
            PythonEngine.PythonHome = PYTHON_HOME;

            // python環境に、PYTHON_PATH(モジュールファイルのデフォルトの検索パス)を設定
            PythonEngine.PythonPath = string.Join(
              Path.PathSeparator.ToString(),
              new string[] {
                  PythonEngine.PythonPath,// 元の設定を残す
                  Path.Combine(PYTHON_HOME, @"Lib\site-packages"), //pipで入れたパッケージはここに入る
                  Path.Combine(@"C:\foo\bar\my_packages"), //自分で作った(動かしたい)pythonプログラムの置き場所も追加
              }
            );

            // 初期化 (明示的に呼ばなくても内部で自動実行されるようだが、一応呼ぶ)
            PythonEngine.Initialize();

            // *-------------------------------------------------------*
            // * pythonコードの実行
            // *-------------------------------------------------------*
            // Global Interpreter Lockを取得
            using (Py.GIL())
            {
                // モジュールの探索パスを表示 (pythonコードを直接指定して実行)
                PythonEngine.RunSimpleString(@"
import sys
import pprint
print('module path =')
pprint.pprint(sys.path)
");

                // numpyのオブジェクトを取得し、呼び出してみる
                dynamic np = Py.Import("numpy");
                Console.WriteLine($"np.cos(np.pi * 2) =  {np.cos(np.pi * 2)}");

                // 自作コードも叩ける
                dynamic myMath = Py.Import("my_awesome_lib.my_math"); // "from my_awesome_lib import my_math"
                dynamic calculator = myMath.Calculator(5, 7); // クラスのインスタンスを生成
                Console.WriteLine($"5 + 7 = {calculator.add()}"); // クラスのメソッド呼び出し
                Console.WriteLine($"sum(1,2,3,4,5) = {myMath.Calculator.sum(new[] { 1, 2, 3, 4, 5 })}"); //staticメソッドも当然呼べる
                dynamic dict = myMath.GetDict(); // 辞書型を返す関数呼び出し
                Console.WriteLine(dict[3]); // 辞書からキーを指定して読み取り
            }

            // python環境を破棄
            PythonEngine.Shutdown();

            Console.WriteLine("Press any key to continue...");
            Console.ReadKey();
        }
    }
}

ただし、このままではまだ動かない

python環境を作る

python環境を導入する。

今回は、Anacondaで、"pythonnet_test"という名前のpython3.7環境(64bit)を作り、利用する。
よって、python環境のルートディレクトリは%userprofile%\anaconda(最近はminicondaかも?)\envs\pythonnet_testとなる。

また、C#からpythonの追加パッケージが利用できることを確認するため、numpyを追加インストールした。

python環境を別の方法で導入する場合は、ルートディレクトリのパスを確認し、numpyもインストールしておくこと。

自前パッケージを配置

自作pythonコードが呼び出せることを確認するため、呼び出し先となるpythonプログラムを準備する。

以下コードを、<任意のディレクトリ>\my_awesome_lib\my_math.pyに配置する。

※配置したディレクトリは後で使うので覚えておくこと。サンプルではC:\foo\bar\my_packages\my_awesome_lib\my_math.pyに置いた。

# よくありがちなしょうもないテストコード
class Calculator:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def add(self):
        return self.x + self.y

    @staticmethod
    def sum(num_list):
        return sum(num_list)

def GetDict():
    return {
        0 : "this",
        1 : "is",
        2 : "my",
        3 : "dictionary",
        }

python環境にパスを通す

作成したpython環境にパスを通す。これが一番難しい(というか情報がない)。

ソースコード中、"python環境の設定"というコメント以降がこの設定に相当するので、コードを修正する。

pythonnetがpython本体のDLLを見つけられるようにする

pythonnetがpython本体のDLLを見つけられるよう、環境変数(PATH)にディレクトリを追加する。

サンプルでは、ユーティリティ関数AddEnvPath()によって、以下を設定している:

  • python環境のルート

    pythonXX.dll(python3.7ならpython37.dll)のあるディレクトリ。

  • python環境のルート\Library\bin

    このディレクトリに、pythonXX.dllが依存しているDLLの一部が格納されていた。
    python環境の作り方によっては必要ないか、別のディレクトリを指定する必要があるかも。

パスの通し方には複数の方法があるが、この例と同様、プログラム中(コード)で設定することをお勧めする。

  • プログラム中(コード)で設定

    きちんと設定されていることを保証できる。おすすめ。
    ただし、サンプルではパスをハードコードしているが、普通は設定ファイルとかから読むようにすべき。

  • Windowsのシステム環境変数に設定

    システム全体に効くため楽だが、複数のpython環境が運用できなくなる。

  • exe起動前に(bat等で)事前設定する

    デバッグ実行などがやりにくい。

python本体が、pythonのモジュール(標準モジュールを含む)を見つけられるようにする

python本体に、pythonのモジュールを探索するパスを設定する。

これが正しく設定されていないと、pythonの標準ライブラリやpip等で入れたライブラリ、自作コードを見つけることができない。

設定すべき項目は2種類ある。

  • PYTHON_HOME

    python環境のルート。

  • PYTHON_PATH

    pythonモジュールの探索場所。セミコロンで区切って指定する。

PYTHON_PATHには、デフォルトで以下が設定されている:

  • %PYTHON_HOME%\pythonXX.zip
  • %PYTHON_HOME%\DLLs
  • %PYTHON_HOME%\lib
  • カレントディレクトリ

サンプルでは、これらに加え、以下を設定している。

  • pipのインストール先ディレクトリ (Lib\site-packages)
  • 自作コードの存在するディレクトリ (~\my_packages、実際に置いた場所に合わせて適宜修正すること)

必要なら、さらに追加することもできる。

実行

ここまで設定すると、サンプルが動くようになる(はず)。

ビルドして実行してみて、以下のように出力されれば成功。

module path =
['C:\\~~~',
 ...
 'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\']
np.cos(np.pi * 2) =  1.0
5 + 7 = 12
sum(1,2,3,4,5) = 15
dictionary
Press any key to continue...

Pythonの関数やクラスが呼び出せ、値をやり取りできていることが分かる。

ハマりどころ(基本編)

よくあるトラブル。

Python.Runtime.PythonException: ModuleNotFoundError : No module named '~~'が起きる

pythonモジュールを見つけられていない。おそらく、PythonPathの設定をミスしている。

見つからないと怒られたモジュールの実体(ファイル)がどこにあるか調べ、PythonPathが適切に設定されているか確認する。

BadImageFormatExceptionが起きる

DLL関連の問題は、だいたい何でもこの例外になる。

原因が多岐にわたるので調査が難しいが、以下のどれかが原因のことが多い。

  • pythonのDLLがあるディレクトリにパスが通っていない

  • pythonnetのビルド時に、適切なpythonバージョンを指定しなかった
    python3.5の環境で動かしたいのにpython3.7向けにビルドしてしまった、など。
    pythonnetが探しに行くDLL名はビルド時に決まるので(python3.7向けにビルドした場合はpython37.dllを探しに行く)、ビルド時の環境と実環境が不一致だとDLLの読み込みに失敗する。

  • pythonのDLLが依存しているDLLにパスが通っていない(見つからない)
    DLLが見つかっても、そのDLLが依存しているDLLが見つからない場合は、やはり読み込みエラーとなる。

  • 呼び出し元プロジェクト・python本体のプラットフォーム(x86/x64)が一致してない
    特に、 呼び出し元のプロジェクトをAnyCPU設定にするのは混乱の元になるので避けたほうがよい(もともとの挙動がややこしい上、.NET 4.5以前と以後での挙動が異なる)。
    ※pythonnet本体は、AnyCPUでビルドされるため、呼び出し元プロジェクトの設定に従う。

この手の問題が発生してしまった場合は、以下のような調査手段で何とかする。

  • DependenciesでDLLの依存関係を追っかける

  • dumpbinコマンドで各DLLのプラットフォーム(x86/64)を確認する
    e.g. dumpbin /headers hoge.dll

  • Process Monitorでロードに失敗したDLLを特定する
    IRP_MJ_CREATEが何度も失敗しており(DLLの探索先パスを全て探すため)、最後までLoad Imageが実行されていない(= DLLが見つからなかった)DLLが犯人。
    また、Load Imageした結果がSUCCESSでないDLLも怪しい。
    フィルタ設定を以下にするとわかりやすい。

    • Process:python呼び出し元のexe
    • Operation:IRP_MJ_CREATE(ファイルのオープン)とLoad Image(DLLの読み込み)をInclude

    なお、IRP_MJ_CREATEはFiler -> Enable advanced outputを有効にしていないと表示されない。

ハマりどころ(応用編)

ちょっと難しいことをやろうとするとハマること。

複数スレッドからの実行

C#で、複数のスレッド(pythonnetを初期化した以外のスレッド)からPythonコードを呼び出す場合には、注意が必要。

Python(CPython)は、設計上、GILを採用している(GIL自体の説明は本題とずれるので割愛)。
また、明示的に開放処理を行わない限り、pythonnetはメインスレッド(= pythonnetを初期化したスレッド)でGILを保持したままにする。
よって、C#の別スレッドからPythonのコードを実行しようとした場合、GILの取得(using(Py.GIL())でデッドロックする。

これを回避するには、pythonnetの初期化後(PythonEngine.Initialize()の呼び出し後)に、PythonEngine.BeginAllowThreads()を呼び出し、明示的にGILを解放する。
これにより、他スレッドからもGILが取得できるようになり、C#の複数スレッドからPythonのコードが呼び出せるようになる。
ただし、GILで排他されることには変わりがないことには注意が必要。
Python側コードの並列実行はされず、単に複数スレッドから呼び出せるようになるだけ。

なお、pythonnetのBeginAllowThreads()メソッドが何をやっているのかは、PythonのC APIのドキュメントを読むとイメージがつかめる。

巨大データの扱い

C#からPythonに巨大な配列やリストを単純に渡すと、要素全てが個別にpythonのオブジェクトに変換されるため、とてつもなく遅くなる。
※ためしに4k解像度の画像データ(uint8の画素値)が入った配列を渡したら、なにもしないpython関数を呼び出すだけでも、秒単位の時間がかかった…

こういう場合には、ポインタ経由での引き渡しが有効。
たとえば8bit グレースケールの画像を受け渡す場合、以下のようにする。

import numpy as np
import ctypes.util
from ctypes import *

def my_func(img_ptr, width, stride, height):
    # ポインタからnumpyのarrayを作る
    img = np.ctypeslib.as_array((stride * height * ctypes.c_uint8).from_address(img_ptr)).reshape(1, height, stride)
    # imgに対していろいろ処理する…

C#からは、以下のように呼び出す。

// この画像を引き渡したい
var hugeImage = new Bitmap(65536, 65536, PixelFormat.Format8bppIndexed);

BitmapData bitmapData = null;
try
{
    // 画像をロック
    bitmapData = hugeImage.LockBits(
       new Rectangle(new Point(0, 0), image.Size),
       ImageLockMode.ReadOnly,
       PixelFormat.Format8bppIndexed
       );

    using (Py.GIL())
    {        
        // pythonモジュールを読み込む。ファイル名等は適宜変更すること
        dynamic myPython = Py.Import("myPython");
        myPython.my_func(
            bitmapData.Scan0.ToInt64(), // 画像バッファの先頭ポインタを引き渡す
            bitmapData.Width, //当然ながら画像サイズとストライドも必要
            bitmapData.Stride,
            bitmapData.Height
            );
    }
}
finally
{
    // ロックした画像を解放
    if (bitmapData != null) hugeImage.UnlockBits(bitmapData);
}

画像ではなく配列やリストを渡したい場合は、Marshalクラスを使ってIntPtrにさえ変換してしまえば、同じように渡せる。
unsafeコードを使えるなら、fixedを使っても、もちろんよい。

pythonnetを使わない方法

pythonnetを使わない方法としては、以下のような方法での呼び出し方が考えられる。

python側をコマンドラインプログラムとして設計し、C#側から起動する

データのやりとりは、標準入出力やファイルを経由して行う。

pros:

  • シンプルでわかりやすい。
  • pythonランタイム環境と疎結合にできる。

cons:

  • 起動終了などのオーバーヘッドが大きい。
  • 細かい制御がしにくい。

python側をサーバとして設計し、C#側からサーバに対して要求を投げる

python側をサービスにしてしまう。
WebAPIサーバにしてもよいし、Apache ThriftgRPCのようなRPCフレームワークを使ってもよい。

pros:

  • 設計自由度が高い。
  • オーバーヘッドが比較的小さい(ただしプロセス間通信やシリアライゼーションは発生するので、同一プロセスで処理するよりはやや遅いはず)。
  • pythonランタイム環境と疎結合にできる。

cons:

  • プロセスが2個になり、起動終了管理などが必要になる。
  • RPC部分を別途設計・実装・メンテしないといけない。

Ironpythonを使う

cpythonと決別する。
「既存のpythonコードをC#から叩きたい」という用途には、おそらく茨の道。

pros:

  • Ironpython自体が.NET上で動くpython処理系なので、.NETとの相性がいい。

cons:

  • cpythonとの互換性に難がある(例えば、*.pydを使うライブラリはほぼ使えないと思ったほうが良い)。
  • python2.x系しかサポートしていない(一応、3.xをサポートするIronpython 3も開発中らしいが、コミットログを見る限りほとんど停滞している)。

  1. 動かなかった…という記事はあったのでそっとリンク。 

91
112
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
91
112