はじめに
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