Pure JS (JavaScript) | JS + Py Server | Pure Py - Jupyter | JS + Pyodide | |
---|---|---|---|---|
Pros | - strong interactive - high customizable UI - easy distribution |
- strong interactive - high customizable UI - extensibility (any Python package) |
Fast prototyping & experiment | - same as Pure JS - * scientific python stack supported & any pure Python package - offload Python server CPU loading |
Cons | - front-end skills - may lack some JS data libraries - JS parsing library may have different implementations with Python ver. |
- front-end skills - heavier: more effort on data move, API and distribution |
No Pros of Pure JS | - front-end skills - need to learn Pyodide - Pyodide is under development - download size (30~150MB) - not every Python package / function is supported - loading time (~3s) - Python in WebAssembly is slower (2~5 times), using numpy to speed up compensation |
We can see why JS + Pyodide may benefit
Support
Not support yet
Need effort
Try Pyodide in a REPL directly in your browser (no installation needed).
It uses plotly, pandas, fileio, http fetch
## https://pyodide.org/en/stable/usage/quickstart.html#accessing-javascript-scope-from-python
import js
div = js.document.createElement("div")
div.innerHTML = "<h1>This element was created from Python</h1>"
js.document.body.prepend(div)
Even Python operate HTML DOM via JavaScript
<!-- <script src="pyodide/pyodide.js"></script> -->
<!-- <script src="https://cdn.jsdelivr.net/pyodide/dev/full/pyodide.js"></script> -->
<script src="https://cdn.jsdelivr.net/pyodide/v0.18.1/full/pyodide.js"></script>
2. load Pyodide main WebAssembly build:
/* download followings
package.json, pyodide.asm.data, pyodide.asm, distutils.js, distutils.data */
// use globalThis.pyodide or local variable
const pyodide = await loadPyodide({ indexURL: baseURL + "pyodide/" });
3. get your Python scripts, either embed them in JavaScript string,
const pythonScript = `
print("hello world")
import micropip
await micropip.install('pydicom') # or self hosted wheel url
import sys
sys.version
`
or put them in external .py file(s), then fetch
// execute `npm install pyodide` first, then
const pyodide = await import("pyodide/pyodide.js");
// if omit "https://localhost://"" means fetching from same original
const url = 'python/hello_world.py'
const pythonCode = await (await fetch(url)).text();
4. (optional) load Pyodide python packages imported in your Python code
// it will download pillow.js pillow.data if `from PIL import Image` in your python code
await pyodide.loadPackagesFromImports(pythonCode);
5. run your Python code and you will hello world in your browser console, download pydicom from PyPI and get return value from the last line
const pythonVer = await pyodide.runPythonAsync(pythonCode);
console.log(pythonVer) // 3.9.5 (default, Sep 16 2021, 15:37:13)...
# test1.py
num = 1
num
# test2.py
print(num) # 1
num += 1
num # 2
/* in js file */
const num = await pyodide.runPythonAsync(pythonCodeTest1);
console.log(num); // 1
/* in js file */
const num = await pyodide.runPythonAsync(pythonCodeTest2);
console.log(num); // 2
const my_namespace = pyodide.globals.dict();
pyodide.runPython(`y = 4`, my_namespace);
console.log(my_namespace.get("y")); // ==> 4
# const my_js_module = {num: 3} // in JS
# pyodide.registerJsModule("my_js_module", my_js_module);
# in Python: read
from my_js_module import num
print(num)
# write
import my_js_module
my_js_module.num = 10
in Python: use JsProxy
in JavaScript: use PyProxy
print(obj) & console.log(obj) will auto trigger to_py()/toJs()
round trip: PythonObj -> PyProxy in JavaScript -> same PythonObj
const pyProxyObj = pyodide.runPython(`
import sys
sys.version
class TestObject:
num = 10
def test_object_type(test_obj):
print(type(test_obj)) # <class 'TestObject'>
print(test_obj.num) # 10
if test_obj is obj:
print("same object")
obj = TestObject()
obj
`);
console.log(pyProxyObj) // Proxy
const testFun = pyodide.globals.get("test_object_type")
testFun(pyProxyObj)
const obj = {
"num": 20
}
pyodide.runPython(`
def echo_obj(obj):
print(type(obj)) # <class 'pyodide.JsProxy'>
return obj
`);
const echoFun = pyodide.globals.get("echo_obj")
const obj2 = echoFun(obj);
console.log(obj2) // {num: 20}
if (obj2 == obj) {
console.log("same object2")
}
round trip: JsObj -> JsProxy in Python -> same JsObj
PyProxy needs the destroy method because even if all references to the PyProxy are removed, the PyProxy can hang around for a very long time because of the way the browser garbage collector work
// in JavaScript
const test_fun = pyodide.globals.get('test_fun')
// count + 1 for the memory
// in WebAssembly Python heap
const list = test_fun()
// so total reference count referring the
// Python list in WASM memory is 1,
// even not use list anymore
// need some way to decrease 1
// Either
// 1. explictly call list.destory()
// 2. Pyodide register destory() in FinalizationRegistry
// when Browser finally recycles it
list.destory()
## in python,
def test_fun():
# count + 1, allocate memory
# in WebAssembly heap
list_ = [1,2,3]
# count -1 when leaving this fun
return list_
Digital Imaging and Communications in Medicine, includes
Need to handle below DICOM tags about image:
Features
Adjust window
center/ width via mouse move, 20~30+ fps
3 plane views, use slider to switch sections
const jpegDecoder = {
// baseline, jpeg2000, jpegls and this
lossless: (bytes: PyProxyBuffer) => {
const buffer = bytes.getBuffer() // PyBufferData
const decoder = new jpegLossless.lossless.Decoder();
const data = buffer.data; // Uint8Array
const decoded = decoder.decode(data, data.byteOffset, data.byteLength);
buffer.release()
return decoded.buffer // ArrayBuffer
},
}
const dicomObj = PyodideDicom(buffer, bufferList, jpegDecoder)
@dataclass
class PyodideDicom:
jpeg_decoder: Any = None # JsProxy
final_rgba_1d_ndarray: Optional[np.ndarray] = None
def __init__(
self,
buffer: Any = None, # JSProxy of JS ArrayBuffer
buffer_list: Any = None,
jpeg_decoder: Any = None,
): # buffer.to_py() result in memoryview, ds is DICOM object
ds = self.get_pydicom_dataset_from_js_buffer(buffer.to_py())
## then go some pydicom parsing, after that, we call below function
def decompress_compressed_data(self, dicom_pixel_data: bytes): # dicom file's pixel_data
jsobj = pyodide.create_proxy(dicom_pixel_data)
uncompressed_jsproxy = jpeg_decoder.lossless( # ArrayBuffer's JsProxy
jsobj,
)
jsobj.destroy()
uncompressed = uncompressed_jsproxy.to_py() # memoryview
## .. ignore the code using DICOM tags to detect it is uint16/int16/int8/uint8
image: np.ndarray = np.frombuffer(uncompressed, dtype=np.uint16)
Use Python class & inject JS function
In Typescirpt
In Python
def render_frame_to_rgba_1d(): # total: 0.09 ~ 0.009s
# do
# 1. (optional) decompress jpeg & apply_modality_lut (uncompressed alreayd done) ~ 0.05
# 2. get ndarray max/min
# 3. inverse color for MONOCHROME1
# 4. normalization for 1) color bite to 8 bit 2) windoe center mode (possible np.clip)
# 5. convert to rgba_1d_image_array, cases:
# 1. rgb_image1d: reshape to rgb_image2d first (~0.03s), then use 2.
# using np.insert approach is 2~3 times slower
# 2. rgb_image2d: similar to 3. just np.dstack((image2d, alpha)) different
# 3. grey_image2d #### 0.002s
# 4. grey_image1d which use 3. function
# case 3
def flatten_grey_image_to_rgba_1d_image_array(self, image: np.ndarray):
alpha = np.full(image.shape, 255)
stacked = np.dstack((image, image, image, alpha))
image = stacked.flatten()
image = image.astype("uint8")
return image
# old slow way
def flatten_grey_image2d_to_rgba_1d_image_array_iterate_ndarray_way():
# 2d -> 1d -> 1d *4 (each array value -copy-> R+G+B+A)
for i_row in range(0, height):
for j_col in range(0, width):
numpy efficient usage is faster than numpy some usage
A speed test result on OT-MONO2-8-hip.dcm on https://barre.dev/medical/samples/.
convert "grey_image2d" to "rgba_1d_image" numpy.ndarray
/* get PyProxy obj of a PyodideDicom Python instance */
const image = PyodideDicom(buffer, bufferList, decompressJPEG)
/* directly access Python object's attributes */
setModality(image.modality)
/* get ndarray as canvas suitable data */
// image.final_rgba_1d_ndarray <- will leak: pyodide/pyodide#1853. Fixed in main branch.
const ndarray = image.get_rgba_1d_ndarray()
const buffer = (ndarray as PyProxyBuffer).getBuffer("u8clamped");
(ndarray as PyProxyBuffer).destroy();
const uncompressedData = buffer.data as Uint8ClampedArray
// draw on canvas
const imgData = new ImageData(uncompressedData, image.width, image.height);
const ctx = c.getContext("2d");
ctx.putImageData(imgData, 0, 0);
/* adjust windoe center/width & ask Python to update its ndarray */
// case1
const ndarray = image.render_frame_to_rgba_1d(newWindowCenter, newWindowWidth)
// case2
const ax_ndarray = image.render_axial_view.callKwargs({
normalize_window_center: newWindowCenter, normalize_window_width: newWindowWidth
});
to Python Keyword argument
const decompressJPEG = {
lossless: (bytes: any) => {
console.log("first byte:", bytes[0], bytes.get(0)); // undefined, 255
/* PyProxy of Python "bytes" can be accessable via get & for-of in JavaScript
so the below line works, too, although the function requires a ArrayBuffer parameter
below iterate each byte (& implicte copy to number at the same time) is slow,
so use getBuffer */
// const decoded = decoder.decode(bytes);
const buffer = bytes.getBuffer()
const decoder = new jpegLossless.lossless.Decoder();
const data = buffer.data;
const decoded = decoder.decode(data, data, data.byteOffset, data.byteLength);
buffer.release()
return decoded.buffer
},
}