機械学習基礎理論独習

誤りがあればご指摘いただけると幸いです。数式が整うまで少し時間かかります。リンクフリーです。

勉強ログです。リンクフリーです
目次へ戻る

クライアントサイドのみでJavaScriptからPythonの関数を呼べるPyodideの使い方

はじめに

Xで見かけたのがきっかけです。
JavaScript演算子オーバーロードができないので、A,Bが行列の時、行列の積ABはプログラムでA@Bのように書けないわけです。
でも、PyodideはPythonが使えるそうなので、numpyも使えちゃうわけです。
なので私のやりたいことが実現できそうです。
私は重たい処理をnumpyを使って効率よく処理したいわけではありません。Pythonを使った方が数式に近いプログラムを掛けるのが一番の理由ですかね。
仕組みというより、こうやれば動くよということを本記事では説明していきます。
今回は、JavaScript側でPythonを動かしましたが、その逆もできます。
また、今回使用したPyodideライブラリをラップしたPySciptなら、イベント処理から何から何までPythonで書けちゃうみたいです。

解説

今回のサンプルは index.html にJavaScriptを、Matrix3x3.pyにPythonを書いています。
行列の演算を使う箇所は2箇所あります。
1箇所目は、Rotateボタン押下時です。モデルビュー行列を更新します。
2箇所目は、描画時にモデルビュー行列と射影行列を掛けます。
射影行列は初期化時に決まります。
モデルは矩形でこれも初期化時に決めています。
サンプルアプリの仕様ですが、Pyodideにチェックがついている場合行列演算をPythonで行います。チェックがついていない場合は通常通りJavaScriptで行います。

Pyodideの使い方

1. <script>タグの埋め込みは以下のように書きましょう。

<script src="https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js"></script>

2. Pyodideをロードしましょう
Pyodideをロードします。これは非同期です。
このとき、私はnumpyが必要だったのでここでロードしました。
私はPythonにクラスを作成したので、一度そのクラスを定義していることをPythonに伝えるため、matrix3x3.pyファイるをテキストして読み込んでそれを動かしています。

        // Load Pyodide
        pyodide = await loadPyodide();
        await pyodide.loadPackage('numpy');
        console.log('Pyodide is ready.');

        // Load Python code
        const res = await window.fetch('matrix3x3.py');
        const pycode = await res.text();        
        
        // Run Python code
        pyodide.runPython(pycode);

3.Pythonのコードを動かして、戻り値を受け取ろう
今回のサンプルアプリでは3x3の行列を使っていますが、JavaScript側の型はFloat32Arrayであり、Python側の型はnumpy.ndarrayです。
pyodide.toPy()でPythonのローカル変数を登録して、pyodide.runPythonを呼べば、ローカル変数を渡しつつ、Pythonのコードが実行できます。
で戻り値がオブジェクトのときはtoJsメソッドを呼ぶとJavaScriptで扱えるようになります。戻り値の型がプリミティブ型の場合は何もしなくてよいようです。
Rotateボタン押下時の処理を貼り付けておきますね。

        document.querySelector('#rotate-button').addEventListener('click', () => {
            if(usePyodide) {// Python
                const locals = pyodide.toPy({ step: STEP_ANGLE, modelViewMatrix });
                const ret = pyodide.runPython(`
                    r = Matrix3x3.fromRotation(step)
                    mv = Matrix3x3.fromRowPriorityArray(modelViewMatrix)
                    (mv @ r).T.reshape(9)
                `, { locals });
                modelViewMatrix = ret.toJs();
            } else {// JavaScript
                const rotationMatrix = Matrix3x3.fromRotation(STEP_ANGLE);
                modelViewMatrix = Matrix3x3.multiply(modelViewMatrix, rotationMatrix);
            }
            viewToModel();
            modelToView();
        });

ソースコード

以下にhtmlファイルとpythonファイルをそのまま全部貼り付けておきます。
同じフォルダにindex.html,matrix3x3.pyとして保存すれば動きます。勿論ローカルにWebサーバーは立ててください。

<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js"></script>
<title>Pyodide Test</title>
<script>
// Matrix 3x3 Class (column priority)
class Matrix3x3 {
    static identity() {
        return new Float32Array([ 1, 0, 0, 0, 1, 0, 0, 0, 1]);
    }
    static fromRotation(angle) {
        const m = Matrix3x3.identity();
        const c = Math.cos(angle), s = Math.sin(angle);
        m[0] = c;
        m[1] = s;
        m[3] = -s;
        m[4] = c;
        return m;
    }
    static fromTranslation(a) {
        const m = Matrix3x3.identity();
        m[6] = a[0];
        m[7] = a[1];
        return m;
    }
    static fromScaling(a) {
        const m = Matrix3x3.identity();
        m[0] = a[0];
        m[4] = a[1];
        return m;
    }
    static multiply(m0, m1) {
        const m = Matrix3x3.identity();
        m[0] = m0[0] * m1[0] + m0[3] * m1[1] + m0[6] * m1[2];
        m[1] = m0[1] * m1[0] + m0[4] * m1[1] + m0[7] * m1[2];
        m[2] = m0[2] * m1[0] + m0[5] * m1[1] + m0[8] * m1[2];
        m[3] = m0[0] * m1[3] + m0[3] * m1[4] + m0[6] * m1[5];
        m[4] = m0[1] * m1[3] + m0[4] * m1[4] + m0[7] * m1[5];
        m[5] = m0[2] * m1[3] + m0[5] * m1[4] + m0[8] * m1[5];
        m[6] = m0[0] * m1[6] + m0[3] * m1[7] + m0[6] * m1[8];
        m[7] = m0[1] * m1[6] + m0[4] * m1[7] + m0[7] * m1[8];
        m[8] = m0[2] * m1[6] + m0[5] * m1[7] + m0[8] * m1[8];
        return m;
    }
    static projection(viewBox, viewport) {
        const delta = [ -viewBox.x, -viewBox.y - viewBox.height ];
        const translation = Matrix3x3.fromTranslation(delta);
        const scaling = [ viewport.width / viewBox.width, viewport.height / viewBox.height ];
        const scale = Matrix3x3.fromScaling(scaling);
        const reverseY = Matrix3x3.fromScaling([ 1, -1 ]);
        const m = Matrix3x3.multiply(scale, translation);            
        return Matrix3x3.multiply(reverseY, m);
    }
}
document.addEventListener('DOMContentLoaded', async () => {    

    // Constant value
    const STEP_ANGLE = Math.PI * 5 / 180;

    // Model
    let pyodide = null;
    let usePyodide = false;
    const rect = { x: 0, y: 0, width: 0, height: 0 };
    const viewport = { x: 0, y: 0, width: 0, height : 0 };
    const viewBox = { x: 0, y: 0, width: 0, height : 0 };
    let modelViewMatrix = Matrix3x3.identity();

    // Initialize Contoroller
    initController();

    // Initialize Model
    await initModel();
    
    // Model To View    
    modelToView();

    async function initModel() {
        // usePyodide
        usePyodide = false;
        
        // viewport
        const canvas = document.querySelector('#canvas');
        viewport.x = 0;
        viewport.y = 0;
        viewport.width = canvas.width;
        viewport.height = canvas.height;

        // viewBox
        viewBox.width = 300;
        viewBox.height = 200;
        viewBox.x = -viewBox.width / 2;
        viewBox.y = -viewBox.height / 2;

        // rect
        rect.width = 40;
        rect.height = 70;
        rect.x = -rect.width / 2;
        rect.y = -rect.height / 2;  
        
        // Load Pyodide
        pyodide = await loadPyodide();
        await pyodide.loadPackage('numpy');
        console.log('Pyodide is ready.');

        // Load Python code
        const res = await window.fetch('matrix3x3.py');
        const pycode = await res.text();        
        
        // Run Python code
        pyodide.runPython(pycode);
    }

    function initController() {
        document.querySelector('#use-pyodide-checkbox').addEventListener('change', () => {
            viewToModel();
            modelToView();
        });

        document.querySelector('#rotate-button').addEventListener('click', () => {
            if(usePyodide) {// Python
                const locals = pyodide.toPy({ step: STEP_ANGLE, modelViewMatrix });
                const ret = pyodide.runPython(`
                    r = Matrix3x3.fromRotation(step)
                    mv = Matrix3x3.fromRowPriorityArray(modelViewMatrix)
                    (mv @ r).T.reshape(9)
                `, { locals });
                modelViewMatrix = ret.toJs();
            } else {// JavaScript
                const rotationMatrix = Matrix3x3.fromRotation(STEP_ANGLE);
                modelViewMatrix = Matrix3x3.multiply(modelViewMatrix, rotationMatrix);
            }
            viewToModel();
            modelToView();
        });
    }

    function modelToView() {
        // #use-pyodide-checkbox
        document.querySelector('#use-pyodide-checkbox').checked = usePyodide;

        // <canvas>
        const canvas = document.querySelector('#canvas');
        const ctx = canvas.getContext('2d');
        
        ctx.save();
        ctx.fillStyle = 'black';
        ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
        let m;
        if(usePyodide) {// Python
            const locals = pyodide.toPy({ viewBox, viewport, modelViewMatrix });
            const ret = pyodide.runPython(`
                p = Matrix3x3.projection(viewBox, viewport)
                mv = Matrix3x3.fromRowPriorityArray(modelViewMatrix)
                (p @ mv).T.reshape(9)
            `, { locals });
            m = ret.toJs();
        } else {// JavaScript
            const projectionMatrix = Matrix3x3.projection(viewBox, viewport);
            m = Matrix3x3.multiply(projectionMatrix, modelViewMatrix);
        }        
        ctx.setTransform(m[0], m[1], m[3], m[4], m[6], m[7]);
        ctx.strokeStyle = 'white';
        ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
        ctx.restore();
    }

    function viewToModel() {
        // #use-pyodide-checkbox
        usePyodide = document.querySelector('#use-pyodide-checkbox').checked;
    }    
});
</script>
</head>
<body>
    <label for="use-pyodide-checkbox">
        <input type="checkbox" id="use-pyodide-checkbox" />Use Pyodide
    </label>
    <br>
    <button id="rotate-button">Rotate</button>
    <br><br>
    <canvas id="canvas" width="600" height="400"></canvas>
</body>
</html>
import numpy as np
class Matrix3x3():
    @staticmethod
    def identity():
        return np.array([ [1, 0, 0], [0, 1, 0], [0, 0, 1] ], dtype = 'float32')

    @staticmethod
    def fromRowPriorityArray(a):
        return np.array(a, dtype = 'float32').reshape(3,3).T

    @staticmethod
    def fromRotation(angle):
        m = Matrix3x3.identity()
        c = np.cos(angle)
        s = np.sin(angle)
        m[0][0] = c
        m[1][0] = s
        m[0][1] = -s
        m[1][1] = c
        return m

    @staticmethod
    def fromTranslation(a):
        m = Matrix3x3.identity() 
        m[0][2] = a[0]
        m[1][2] = a[1]
        return m
    
    @staticmethod
    def fromScaling(a):
        m = Matrix3x3.identity()
        m[0][0] = a[0]
        m[1][1] = a[1]
        return m

    @staticmethod
    def projection(viewBox, viewport):
        delta = [ -viewBox['x'], -viewBox['y'] - viewBox['height'] ]
        translation = Matrix3x3.fromTranslation(delta)
        scaling = [ viewport['width'] / viewBox['width'], viewport['height'] / viewBox['height'] ]
        scale = Matrix3x3.fromScaling(scaling)
        reverseY = Matrix3x3.fromScaling([ 1, -1 ])
        return reverseY @ scale @ translation
目次へ戻る