Skip to content

Commit

Permalink
feat(Python): Add Pyodide Image support
Browse files Browse the repository at this point in the history
  • Loading branch information
thewtex committed Apr 19, 2023
1 parent 2363b0c commit 4247bbd
Show file tree
Hide file tree
Showing 11 changed files with 209 additions and 44 deletions.
16 changes: 16 additions & 0 deletions packages/core/python/itkwasm/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
# itkwasm

Python interface to [itk-wasm](https://wasm.itk.org) WebAssembly modules.

## Development

Thank you for contributing a pull request!

**Welcome to the ITK community!**

We are glad you are here and appreciate your contribution. Please keep in mind our [community participation guidelines](https://github.com/InsightSoftwareConsortium/ITK/blob/main/CODE_OF_CONDUCT.md).

```
git clone https://github.com/InsightSoftwareConsortium/itk-wasm
cd itk-wasm/packages/core/python/itkwasm
pip install hatch
hatch run download-pyodide
hatch run test
```
4 changes: 1 addition & 3 deletions packages/core/python/itkwasm/itkwasm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from .int_types import IntTypes
from .pixel_types import PixelTypes
from .environment_dispatch import environment_dispatch, function_factory
from .js_package_config import JsPackageConfig

__all__ = [
"InterfaceTypes",
Expand All @@ -42,5 +41,4 @@
"PixelTypes",
"environment_dispatch",
"function_factory",
"JsPackageConfig",
]
]
28 changes: 28 additions & 0 deletions packages/core/python/itkwasm/itkwasm/_to_numpy_array.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import numpy as np

from .int_types import IntTypes
from .float_types import FloatTypes

def _to_numpy_array(component_type, buf):
if component_type == IntTypes.UInt8:
return np.frombuffer(buf, dtype=np.uint8)
elif component_type == IntTypes.Int8:
return np.frombuffer(buf, dtype=np.int8)
elif component_type == IntTypes.UInt16:
return np.frombuffer(buf, dtype=np.uint16)
elif component_type == IntTypes.Int16:
return np.frombuffer(buf, dtype=np.int16)
elif component_type == IntTypes.UInt32:
return np.frombuffer(buf, dtype=np.uint32)
elif component_type == IntTypes.Int32:
return np.frombuffer(buf, dtype=np.int32)
elif component_type == IntTypes.UInt64:
return np.frombuffer(buf, dtype=np.uint64)
elif component_type == IntTypes.Int64:
return np.frombuffer(buf, dtype=np.int64)
elif component_type == FloatTypes.Float32:
return np.frombuffer(buf, dtype=np.float32)
elif component_type == FloatTypes.Float64:
return np.frombuffer(buf, dtype=np.float64)
else:
raise ValueError('Unsupported component type')
4 changes: 2 additions & 2 deletions packages/core/python/itkwasm/itkwasm/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class ImageType:
components: int = 1

def _default_direction() -> ArrayLike:
return np.empty((0,), np.float32)
return np.empty((0,), np.float64)

@dataclass
class Image:
Expand All @@ -45,7 +45,7 @@ def __post_init__(self):
self.spacing += [1.0,] * dimension

if len(self.direction) == 0:
self.direction = np.eye(dimension).astype(np.float32).ravel()
self.direction = np.eye(dimension).astype(np.float64)

if len(self.size) == 0:
self.size += [1,] * dimension
8 changes: 0 additions & 8 deletions packages/core/python/itkwasm/itkwasm/js_package_config.py

This file was deleted.

26 changes: 1 addition & 25 deletions packages/core/python/itkwasm/itkwasm/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,35 +19,11 @@
from .polydata import PolyData
from .int_types import IntTypes
from .float_types import FloatTypes
from ._to_numpy_array import _to_numpy_array

if sys.platform != "emscripten":
from wasmtime import Config, Store, Engine, Module, WasiConfig, Linker

def _to_numpy_array(component_type, buf):
if component_type == IntTypes.UInt8:
return np.frombuffer(buf, dtype=np.uint8)
elif component_type == IntTypes.Int8:
return np.frombuffer(buf, dtype=np.int8)
elif component_type == IntTypes.UInt16:
return np.frombuffer(buf, dtype=np.uint16)
elif component_type == IntTypes.Int16:
return np.frombuffer(buf, dtype=np.int16)
elif component_type == IntTypes.UInt32:
return np.frombuffer(buf, dtype=np.uint32)
elif component_type == IntTypes.Int32:
return np.frombuffer(buf, dtype=np.int32)
elif component_type == IntTypes.UInt64:
return np.frombuffer(buf, dtype=np.uint64)
elif component_type == IntTypes.Int64:
return np.frombuffer(buf, dtype=np.int64)
elif component_type == FloatTypes.Float32:
return np.frombuffer(buf, dtype=np.float32)
elif component_type == FloatTypes.Float64:
return np.frombuffer(buf, dtype=np.float64)
else:
raise ValueError('Unsupported component type')


class Pipeline:
"""Run an itk-wasm WASI pipeline."""

Expand Down
83 changes: 83 additions & 0 deletions packages/core/python/itkwasm/itkwasm/pyodide.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from dataclasses import dataclass, asdict
from typing import Optional

from .image import Image, ImageType
from .pointset import PointSet, PointSetType
from .mesh import Mesh, MeshType
from .polydata import PolyData, PolyDataType
from .binary_file import BinaryFile
from .binary_stream import BinaryStream
from .text_file import TextFile
from .text_stream import TextStream
from .float_types import FloatTypes
from .int_types import IntTypes
from .pixel_types import PixelTypes
from ._to_numpy_array import _to_numpy_array

@dataclass
class JsPackageConfig:
module_url: str
pipelines_base_url: Optional[str] = None
pipeline_worker_url: Optional[str] = None

class JsPackage:
def __init__(self, config: JsPackageConfig):
self._config = config
self._js_module = None

@property
def config(self):
return self._config

@config.setter
def config(self, value):
self._config = value

@property
async def js_module(self):
if self._js_module is not None:
return self._js_module
from pyodide.code import run_js
js_module = await run_js(f"import('{self._config.module_url}')")
if self._config.pipelines_base_url is not None:
js_module.setPipelinesBaseUrl(self._config.pipelines_base_url)
if self._config.pipeline_worker_url is not None:
js_module.setPipelineWorkerUrl(self._config.pipeline_worker_url)
self._js_module = js_module
return js_module

class JsResources:
def __init__(self):
self._web_worker = None

@property
def web_worker(self):
return self._web_worker

@web_worker.setter
def web_worker(self, value):
self._web_worker = value

js_resources = JsResources()

def to_py(js_proxy):
import pyodide
if hasattr(js_proxy, "imageType"):
image_dict = js_proxy.to_py()
image_type = image_dict['imageType']
dimension = image_type['dimension']
component_type = image_type['componentType']
image_dict['direction'] = _to_numpy_array(str(FloatTypes.Float64), image_dict['direction']).reshape((dimension, dimension))
image_dict['data'] = _to_numpy_array(component_type, image_dict['data']).reshape((dimension, dimension))
return Image(**image_dict)
return js_proxy.to_py()

def to_js(py):
import pyodide
import js
if isinstance(py, Image):
image_dict = asdict(py)
image_dict['direction'] = image_dict['direction'].ravel()
image_dict['data'] = image_dict['data'].ravel()
return pyodide.ffi.to_js(image_dict, dict_converter=js.Object.fromEntries)
return py
21 changes: 17 additions & 4 deletions packages/core/python/itkwasm/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,29 @@ Home = "https://wasm.itk.org/"
Source = "https://github.com/InsightSoftwareConsortium/itk-wasm"
Issues = "https://github.com/InsightSoftwareConsortium/itk-wasm/issues"

[project.optional-dependencies]
test = [
[tool.hatch.envs.default]
dependencies = [
"itk>=5.3.0",
"pytest >=2.7.3",
"pytest-pyodide",
]

[tool.hatch.envs.default.scripts]
test = [
"hatch build -t wheel",
"pytest --dist-dir=./dist --runtime=chrome -s",
]
download-pyodide = [
"wget -q https://github.com/pyodide/pyodide/releases/download/0.23.1/pyodide-0.23.1.tar.bz2 -O pyodide.tar.bz2",
"tar xjf pyodide.tar.bz2",
"rm -rf dist",
"mv pyodide dist",
]

[tool.hatch.envs.docs]
dependencies = [
"sphinx",
"pydata-sphinx-theme",
"sphinx",
"pydata-sphinx-theme",
]

[tool.hatch.envs.docs.scripts]
Expand Down
2 changes: 1 addition & 1 deletion packages/core/python/itkwasm/test/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def test_image_defaults():
assert image.origin[1] == 0.0
assert image.spacing[0] == 1.0
assert image.spacing[1] == 1.0
assert np.array_equal(image.direction, np.eye(2).astype(np.float32).ravel())
assert np.array_equal(image.direction, np.eye(2).astype(np.float32))

assert image.size[0] == 1
assert image.size[1] == 1
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

def test_itkwasm_js_package_config():
from itkwasm import JsPackageConfig
from itkwasm.pyodide import JsPackageConfig
module_url = 'https://cdn.jsdelivr.net/npm/@itk-wasm/[email protected]/dist/bundles/compress-stringify.js'
pipelines_base_url = 'https://cdn.jsdelivr.net/npm/@itk-wasm/[email protected]/dist/pipelines'
pipeline_worker_url = 'https://cdn.jsdelivr.net/npm/@itk-wasm/[email protected]/dist/web-workers/pipeline.worker.js'
Expand Down
59 changes: 59 additions & 0 deletions packages/core/python/itkwasm/test/test_pyodide.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from pytest_pyodide import run_in_pyodide
import pytest

from itkwasm import __version__ as test_package_version

@pytest.fixture
def package_wheel():
return f"itkwasm-{test_package_version}-py3-none-any.whl"

@run_in_pyodide(packages=['micropip', 'numpy'])
async def test_image_conversion(selenium, package_wheel):
import micropip
await micropip.install(package_wheel)

from itkwasm import Image
from itkwasm.pyodide import to_js, to_py
import numpy as np

image = Image()

assert image.imageType.dimension == 2
assert image.imageType.componentType == 'uint8'
assert image.imageType.pixelType == 'Scalar'
assert image.imageType.components == 1

assert image.name == "image"
assert image.origin[0] == 0.0
assert image.origin[1] == 0.0
assert image.spacing[0] == 1.0
assert image.spacing[1] == 1.0
assert np.array_equal(image.direction, np.eye(2).astype(np.float64))

assert image.size[0] == 1
assert image.size[1] == 1
image.size = [2, 2]

assert isinstance(image.metadata, dict)
assert image.data == None
image.data = np.arange(4, dtype=np.uint8).reshape((2,2))
image_js = to_js(image)

image_py = to_py(image_js)
assert image_py.imageType.dimension == 2
assert image_py.imageType.componentType == 'uint8'
assert image_py.imageType.pixelType == 'Scalar'
assert image_py.imageType.components == 1

assert image_py.name == "image"
assert image_py.origin[0] == 0.0
assert image_py.origin[1] == 0.0
assert image_py.spacing[0] == 1.0
assert image_py.spacing[1] == 1.0
assert np.array_equal(image_py.direction, np.eye(2).astype(np.float64))

assert image_py.size[0] == 2
assert image_py.size[1] == 2

assert isinstance(image_py.metadata, dict)
assert np.array_equal(image_py.data, np.arange(4, dtype=np.uint8).reshape((2,2)))

0 comments on commit 4247bbd

Please sign in to comment.