diff --git a/.github/workflows/python-wasm.yml b/.github/workflows/python-wasm.yml index a0e5da601..e3d41650c 100644 --- a/.github/workflows/python-wasm.yml +++ b/.github/workflows/python-wasm.yml @@ -5,34 +5,70 @@ on: [push,pull_request] jobs: test-itkwasm: runs-on: ${{ matrix.os }} + env: + python-version: ${{ format('{0}.{1}', matrix.python-major-version, matrix.python-minor-version) }} strategy: max-parallel: 3 matrix: os: [ubuntu-22.04, windows-2022, macos-12] - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-major-version: [3] + python-minor-version: [7, 8, 9, 10, 11] steps: - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python ${{ env.python-version }} uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} + python-version: ${{ env.python-version }} - name: Install dependencies working-directory: ./packages/core/python/itkwasm + shell: bash run: | python -m pip install --upgrade pip cd test/test-accelerator python -m pip install -e "." cd - - python -m pip install -e ".[test]" - - name: Test with pytest + python -m pip install -e "." + python -m pip install hatch itk pytest + - name: Test on native + if: ${{ matrix.python-minor-version < 10 }} + working-directory: ./packages/core/python/itkwasm + run: | + hatch build -t wheel + pytest --junitxml=junit/test-results-${{ env.python-version }}.xml + - name: Download Pyodide + if: ${{ matrix.python-minor-version > 9 }} + shell: bash working-directory: ./packages/core/python/itkwasm run: | - pytest --junitxml=junit/test-results.xml - - name: Publish Test Report - uses: mikepenz/action-junit-report@v2 + curl -L 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 + - name: Install pytest-pyodide + if: ${{ matrix.python-minor-version > 9 }} + shell: bash + run: | + python -m pip install pytest-pyodide + - uses: pyodide/pyodide-actions/install-browser@main + if: ${{ matrix.python-minor-version > 9 }} with: - report_paths: 'packages/core/python/itkwasm/junit/test-results*.xml' + runner: selenium + browser: chrome + browser-version: latest + - name: Test with chrome + if: ${{ matrix.python-minor-version > 9 }} + working-directory: ./packages/core/python/itkwasm + run: | + hatch build -t wheel + ls dist + pytest --junitxml=junit/test-results-${{ env.python-version }}.xml --dist-dir=./dist --rt=chrome + - name: Upload pytest test results + uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + name: pytest-results-${{ env.python-version }} + path: 'packages/core/python/itkwasm/junit/test-results*.xml' test-pythonpackages: runs-on: ${{ matrix.os }} @@ -77,8 +113,8 @@ jobs: working-directory: ./packages/core/python/itkwasm run: | python -m pip install --upgrade pip - python -m pip install -e ".[test]" - python -m pip install hatch + python -m pip install -e "." + python -m pip install hatch itk pytest - name: Build Sphinx documentation working-directory: ./packages/core/python/itkwasm run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index b75eb4933..0f0aca3e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ [itk-wasm](https://wasm.itk.org) combines [ITK](https://itk.org) and [WebAssembly](https://webassembly.org/) to enable high-performance, multi-dimensional spatial analysis and visualization. +# [1.0.0-b.96](https://github.com/InsightSoftwareConsortium/itk-wasm/compare/itk-wasm-v1.0.0-b.95...itk-wasm-v1.0.0-b.96) (2023-04-20) + + +### Bug Fixes + +* **Python:** Support Pyodide Image metadata conversion as Map ([38a2fb3](https://github.com/InsightSoftwareConsortium/itk-wasm/commit/38a2fb365b9a89ff1aad46342a200d1204a38240)) + + +### Features + +* **Python:** Add Pyodide BinaryStream support ([cbf4909](https://github.com/InsightSoftwareConsortium/itk-wasm/commit/cbf490981697faa2e1442f04a10070585b064648)) +* **Python:** Add Pyodide Image support ([4247bbd](https://github.com/InsightSoftwareConsortium/itk-wasm/commit/4247bbd4d78ad87d194b5b9b8d82d97e346a0beb)) +* **Python:** Add Pyodide list support ([13e97d6](https://github.com/InsightSoftwareConsortium/itk-wasm/commit/13e97d6ba8f13671f819a9d5387a1a329d7cb1c4)) +* **Python:** Add Pyodide Mesh support ([fc9f404](https://github.com/InsightSoftwareConsortium/itk-wasm/commit/fc9f4046a4d5846e7af38d650b40a9e05c537ffb)) +* **Python:** Add Pyodide PointSet support ([f8d0fa3](https://github.com/InsightSoftwareConsortium/itk-wasm/commit/f8d0fa38ffdbc82cf91db85641fd772128d844c8)) +* **Python:** Add Pyodide PolyData support ([c379d1e](https://github.com/InsightSoftwareConsortium/itk-wasm/commit/c379d1e92e06ca2691881524553e804f56b7232a)) +* **Python:** Add Pyodide TextFile support ([f055478](https://github.com/InsightSoftwareConsortium/itk-wasm/commit/f0554781fd45062c0ce2d4cdca340ffc4dbc67fb)) +* **Python:** Add Pyodide TextStream support ([e95a3dc](https://github.com/InsightSoftwareConsortium/itk-wasm/commit/e95a3dcfc392a58fb01700373a920c5e7f96c2d0)) +* **Python:** Pyodide BinaryFile support ([e067d2f](https://github.com/InsightSoftwareConsortium/itk-wasm/commit/e067d2f99d66a475ed0973fa547656e5e3c172f7)) + # [1.0.0-b.95](https://github.com/InsightSoftwareConsortium/itk-wasm/compare/itk-wasm-v1.0.0-b.94...itk-wasm-v1.0.0-b.95) (2023-04-17) diff --git a/include/itkPipeline.h b/include/itkPipeline.h index 3c32c38d6..65a17447b 100644 --- a/include/itkPipeline.h +++ b/include/itkPipeline.h @@ -52,7 +52,7 @@ } \ if (arg == "--version") \ { \ - std::cout << "Version: " << (pipeline).get_version() << std::endl; \ + std::cout << "Version: " << (pipeline).version() << std::endl; \ std::exit(0); \ } \ } \ @@ -164,11 +164,6 @@ class WebAssemblyInterface_EXPORT Pipeline: public CLI::App return m_argv; } - const std::string& get_version() const - { - return m_Version; - } - void set_version(const char * version) { m_Version = version; diff --git a/packages/core/python/itkwasm/README.md b/packages/core/python/itkwasm/README.md index 35923353e..318d7150a 100644 --- a/packages/core/python/itkwasm/README.md +++ b/packages/core/python/itkwasm/README.md @@ -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 +``` \ No newline at end of file diff --git a/packages/core/python/itkwasm/itkwasm/__init__.py b/packages/core/python/itkwasm/itkwasm/__init__.py index 189b283ec..8a0a24f7c 100644 --- a/packages/core/python/itkwasm/itkwasm/__init__.py +++ b/packages/core/python/itkwasm/itkwasm/__init__.py @@ -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", @@ -42,5 +41,4 @@ "PixelTypes", "environment_dispatch", "function_factory", - "JsPackageConfig", -] + ] diff --git a/packages/core/python/itkwasm/itkwasm/_to_numpy_array.py b/packages/core/python/itkwasm/itkwasm/_to_numpy_array.py new file mode 100644 index 000000000..4e7104b7a --- /dev/null +++ b/packages/core/python/itkwasm/itkwasm/_to_numpy_array.py @@ -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') diff --git a/packages/core/python/itkwasm/itkwasm/image.py b/packages/core/python/itkwasm/itkwasm/image.py index cfc473887..7c07a30fb 100644 --- a/packages/core/python/itkwasm/itkwasm/image.py +++ b/packages/core/python/itkwasm/itkwasm/image.py @@ -20,12 +20,12 @@ class ImageType: components: int = 1 def _default_direction() -> ArrayLike: - return np.empty((0,), np.float32) + return np.empty((0,), np.float64) @dataclass class Image: imageType: Union[ImageType, Dict] = field(default_factory=ImageType) - name: str = 'image' + name: str = 'Image' origin: Sequence[float] = field(default_factory=list) spacing: Sequence[float] = field(default_factory=list) direction: ArrayLike = field(default_factory=_default_direction) @@ -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 diff --git a/packages/core/python/itkwasm/itkwasm/js_package_config.py b/packages/core/python/itkwasm/itkwasm/js_package_config.py deleted file mode 100644 index 68dfa154a..000000000 --- a/packages/core/python/itkwasm/itkwasm/js_package_config.py +++ /dev/null @@ -1,8 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -@dataclass -class JsPackageConfig: - module_url: str - pipelines_base_url: Optional[str] = None - pipeline_worker_url: Optional[str] = None diff --git a/packages/core/python/itkwasm/itkwasm/mesh.py b/packages/core/python/itkwasm/itkwasm/mesh.py index fc33b9ab7..b691f7de4 100644 --- a/packages/core/python/itkwasm/itkwasm/mesh.py +++ b/packages/core/python/itkwasm/itkwasm/mesh.py @@ -13,7 +13,7 @@ @dataclass class MeshType: - dimension: int = 2 + dimension: int = 3 pointComponentType: Union[IntTypes, FloatTypes] = FloatTypes.Float32 pointPixelComponentType: Union[IntTypes, FloatTypes] = FloatTypes.Float32 @@ -30,7 +30,7 @@ class MeshType: class Mesh: meshType: Union[MeshType, Dict] = field(default_factory=MeshType) - name: str = 'mesh' + name: str = 'Mesh' numberOfPoints: int = 0 points: Optional[ArrayLike] = None diff --git a/packages/core/python/itkwasm/itkwasm/pipeline.py b/packages/core/python/itkwasm/itkwasm/pipeline.py index d078484da..ffccfe0fb 100644 --- a/packages/core/python/itkwasm/itkwasm/pipeline.py +++ b/packages/core/python/itkwasm/itkwasm/pipeline.py @@ -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.""" diff --git a/packages/core/python/itkwasm/itkwasm/pointset.py b/packages/core/python/itkwasm/itkwasm/pointset.py index aae05f457..4af7ceb03 100644 --- a/packages/core/python/itkwasm/itkwasm/pointset.py +++ b/packages/core/python/itkwasm/itkwasm/pointset.py @@ -1,30 +1,38 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field -from typing import Optional +from typing import Optional, Union, Dict try: from numpy.typing import ArrayLike except ImportError: from numpy import ndarray as ArrayLike +from .float_types import FloatTypes +from .int_types import IntTypes +from .pixel_types import PixelTypes + @dataclass class PointSetType: - dimension: int + dimension: int = 3 - pointComponentType: str - pointPixelComponentType: str - pointPixelType: str - pointPixelComponents: int + pointComponentType: Union[IntTypes, FloatTypes] = FloatTypes.Float32 + pointPixelComponentType: Union[IntTypes, FloatTypes] = FloatTypes.Float32 + pointPixelType: PixelTypes = PixelTypes.Scalar + pointPixelComponents: int = 1 @dataclass class PointSet: - pointSetType: PointSetType + pointSetType: Union[PointSetType, Dict] = field(default_factory=PointSetType) + + name: str = 'PointSet' - name: str + numberOfPoints: int = 0 + points: Optional[ArrayLike] = None - numberOfPoints: int - points: Optional[ArrayLike] + numberOfPointPixels: int = 0 + pointData: Optional[ArrayLike] = None - numberOfPointPixels: int - pointData: Optional[ArrayLike] \ No newline at end of file + def __post_init__(self): + if isinstance(self.pointSetType, dict): + self.pointSetType = PointSetType(**self.pointSetType) \ No newline at end of file diff --git a/packages/core/python/itkwasm/itkwasm/polydata.py b/packages/core/python/itkwasm/itkwasm/polydata.py index 47b591370..51949030d 100644 --- a/packages/core/python/itkwasm/itkwasm/polydata.py +++ b/packages/core/python/itkwasm/itkwasm/polydata.py @@ -27,7 +27,7 @@ def _default_points() -> ArrayLike: @dataclass class PolyData: polyDataType: Union[PolyDataType, Dict] = field(default_factory=PolyDataType) - name: str = 'polydata' + name: str = 'PolyData' numberOfPoints: int = 0 points: ArrayLike = field(default_factory=_default_points) diff --git a/packages/core/python/itkwasm/itkwasm/pyodide.py b/packages/core/python/itkwasm/itkwasm/pyodide.py new file mode 100644 index 000000000..adf6762c9 --- /dev/null +++ b/packages/core/python/itkwasm/itkwasm/pyodide.py @@ -0,0 +1,214 @@ +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 ._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 isinstance(js_proxy, pyodide.ffi.JsArray): + return [to_py(value) for value in js_proxy] + elif hasattr(js_proxy, "imageType"): + image_dict = js_proxy.to_py() + image_type = ImageType(**image_dict['imageType']) + image_dict['imageType'] = image_type + dimension = image_type.dimension + component_type = image_type.componentType + image_dict['direction'] = _to_numpy_array(str(FloatTypes.Float64), image_dict['direction']).reshape((dimension, dimension)) + if image_dict['data'] is not None: + image_dict['data'] = _to_numpy_array(component_type, image_dict['data']).reshape((dimension, dimension)) + return Image(**image_dict) + elif hasattr(js_proxy, "pointSetType"): + point_set_dict = js_proxy.to_py() + point_set_type = PointSetType(**point_set_dict['pointSetType']) + point_set_dict['pointSetType'] = point_set_type + dimension = point_set_type.dimension + point_component_type = point_set_type.pointComponentType + point_pixel_component_type = point_set_type.pointPixelComponentType + if point_set_dict['points'] is not None: + point_set_dict['points'] = _to_numpy_array(point_component_type, point_set_dict['points']).reshape((-1, dimension)) + if point_set_dict['pointData'] is not None: + point_set_dict['pointData'] = _to_numpy_array(point_pixel_component_type, point_set_dict['pointData']) + return PointSet(**point_set_dict) + elif hasattr(js_proxy, "meshType"): + mesh_dict = js_proxy.to_py() + mesh_type = MeshType(**mesh_dict['meshType']) + mesh_dict['meshType'] = mesh_type + dimension = mesh_type.dimension + point_component_type = mesh_type.pointComponentType + point_pixel_component_type = mesh_type.pointPixelComponentType + cell_component_type = mesh_type.cellComponentType + cell_pixel_component_type = mesh_type.cellPixelComponentType + if mesh_dict['points'] is not None: + mesh_dict['points'] = _to_numpy_array(point_component_type, mesh_dict['points']).reshape((-1, dimension)) + if mesh_dict['pointData'] is not None: + mesh_dict['pointData'] = _to_numpy_array(point_pixel_component_type, mesh_dict['pointData']) + if mesh_dict['cells'] is not None: + mesh_dict['cells'] = _to_numpy_array(cell_component_type, mesh_dict['cells']) + if mesh_dict['cellData'] is not None: + mesh_dict['cellData'] = _to_numpy_array(cell_pixel_component_type, mesh_dict['cellData']) + return Mesh(**mesh_dict) + elif hasattr(js_proxy, "polyDataType"): + polydata_dict = js_proxy.to_py() + polydata_type = PolyDataType(**polydata_dict['polyDataType']) + polydata_dict['polyDataType'] = polydata_type + point_pixel_component_type = polydata_type.pointPixelComponentType + cell_pixel_component_type = polydata_type.cellPixelComponentType + if polydata_dict['points'] is not None: + polydata_dict['points'] = _to_numpy_array(str(FloatTypes.Float32), polydata_dict['points']).reshape((-1, 3)) + if polydata_dict['vertices'] is not None: + polydata_dict['vertices'] = _to_numpy_array(str(IntTypes.UInt32), polydata_dict['vertices']) + if polydata_dict['lines'] is not None: + polydata_dict['lines'] = _to_numpy_array(str(IntTypes.UInt32), polydata_dict['lines']) + if polydata_dict['polygons'] is not None: + polydata_dict['polygons'] = _to_numpy_array(str(IntTypes.UInt32), polydata_dict['polygons']) + if polydata_dict['triangleStrips'] is not None: + polydata_dict['triangleStrips'] = _to_numpy_array(str(IntTypes.UInt32), polydata_dict['triangleStrips']) + if polydata_dict['pointData'] is not None: + polydata_dict['pointData'] = _to_numpy_array(point_pixel_component_type, polydata_dict['pointData']) + if polydata_dict['cellData'] is not None: + polydata_dict['cellData'] = _to_numpy_array(cell_pixel_component_type, polydata_dict['cellData']) + return PolyData(**polydata_dict) + elif hasattr(js_proxy, "path") and hasattr(js_proxy, "data") and isinstance(js_proxy.data, str): + with open(js_proxy.path, 'w') as fp: + fp.write(js_proxy.data) + return TextFile(path=js_proxy.path) + elif hasattr(js_proxy, "path") and hasattr(js_proxy, "data"): + with open(js_proxy.path, 'wb') as fp: + js_proxy.data.to_file(fp) + return BinaryFile(path=js_proxy.path) + elif hasattr(js_proxy, "data") and isinstance(js_proxy.data, str): + text_stream_dict = js_proxy.to_py() + return TextStream(**text_stream_dict) + elif hasattr(js_proxy, "data"): + binary_stream_dict = js_proxy.to_py() + binary_stream_dict['data'] = bytes(binary_stream_dict['data']) + return BinaryStream(**binary_stream_dict) + return js_proxy.to_py() + +def to_js(py): + import pyodide + import js + if isinstance(py, list): + js_array = pyodide.ffi.to_js([]) + for value in py: + js_array.append(to_js(value)) + return js_array + elif isinstance(py, Image): + image_dict = asdict(py) + image_dict['direction'] = image_dict['direction'].ravel() + if image_dict['data'] is not None: + image_dict['data'] = image_dict['data'].ravel() + if image_dict['metadata']: + image_dict['metadata'] = pyodide.ffi.to_js(image_dict['metadata'], dict_converter=js.Map.new) + return pyodide.ffi.to_js(image_dict, dict_converter=js.Object.fromEntries) + elif isinstance(py, PointSet): + point_set_dict = asdict(py) + if point_set_dict['points'] is not None: + point_set_dict['points'] = point_set_dict['points'].ravel() + if point_set_dict['pointData'] is not None: + point_set_dict['pointData'] = point_set_dict['pointData'].ravel() + return pyodide.ffi.to_js(point_set_dict, dict_converter=js.Object.fromEntries) + elif isinstance(py, Mesh): + mesh_dict = asdict(py) + if mesh_dict['points'] is not None: + mesh_dict['points'] = mesh_dict['points'].ravel() + if mesh_dict['pointData'] is not None: + mesh_dict['pointData'] = mesh_dict['pointData'].ravel() + if mesh_dict['cells'] is not None: + mesh_dict['cells'] = mesh_dict['cells'].ravel() + if mesh_dict['cellData'] is not None: + mesh_dict['cellData'] = mesh_dict['cellData'].ravel() + return pyodide.ffi.to_js(mesh_dict, dict_converter=js.Object.fromEntries) + elif isinstance(py, PolyData): + polydata_dict = asdict(py) + if polydata_dict['points'] is not None: + polydata_dict['points'] = polydata_dict['points'].ravel() + if polydata_dict['vertices'] is not None: + polydata_dict['vertices'] = polydata_dict['vertices'].ravel() + if polydata_dict['lines'] is not None: + polydata_dict['lines'] = polydata_dict['lines'].ravel() + if polydata_dict['polygons'] is not None: + polydata_dict['polygons'] = polydata_dict['polygons'].ravel() + if polydata_dict['triangleStrips'] is not None: + polydata_dict['triangleStrips'] = polydata_dict['triangleStrips'].ravel() + if polydata_dict['pointData'] is not None: + polydata_dict['pointData'] = polydata_dict['pointData'].ravel() + if polydata_dict['cellData'] is not None: + polydata_dict['cellData'] = polydata_dict['cellData'].ravel() + return pyodide.ffi.to_js(polydata_dict, dict_converter=js.Object.fromEntries) + elif isinstance(py, TextStream): + text_stream_dict = asdict(py) + return pyodide.ffi.to_js(text_stream_dict, dict_converter=js.Object.fromEntries) + elif isinstance(py, BinaryStream): + binary_stream_dict = asdict(py) + return pyodide.ffi.to_js(binary_stream_dict, dict_converter=js.Object.fromEntries) + elif isinstance(py, BinaryFile): + binary_file_dict = asdict(py) + with open(py.path, 'rb') as fp: + data = fp.read() + binary_file_dict['data'] = data + return pyodide.ffi.to_js(binary_file_dict, dict_converter=js.Object.fromEntries) + elif isinstance(py, TextFile): + text_file_dict = asdict(py) + with open(py.path, 'r') as fp: + data = fp.read() + text_file_dict['data'] = data + return pyodide.ffi.to_js(text_file_dict, dict_converter=js.Object.fromEntries) + + return pyodide.ffi.to_js(py) diff --git a/packages/core/python/itkwasm/pyproject.toml b/packages/core/python/itkwasm/pyproject.toml index 2c013f3ec..758d52df1 100644 --- a/packages/core/python/itkwasm/pyproject.toml +++ b/packages/core/python/itkwasm/pyproject.toml @@ -41,16 +41,28 @@ 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 --rt=chrome -s", +] +download-pyodide = [ + "curl -L 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", + "mv pyodide dist", ] [tool.hatch.envs.docs] dependencies = [ - "sphinx", - "pydata-sphinx-theme", + "sphinx", + "pydata-sphinx-theme", ] [tool.hatch.envs.docs.scripts] diff --git a/packages/core/python/itkwasm/test/test_image.py b/packages/core/python/itkwasm/test/test_image.py index 09cc7eefc..e9a649421 100644 --- a/packages/core/python/itkwasm/test/test_image.py +++ b/packages/core/python/itkwasm/test/test_image.py @@ -24,12 +24,12 @@ def test_image_defaults(): assert image.imageType.pixelType == 'Scalar' assert image.imageType.components == 1 - assert image.name == "image" + 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.float32).ravel()) + assert np.array_equal(image.direction, np.eye(2).astype(np.float64)) assert image.size[0] == 1 assert image.size[1] == 1 diff --git a/packages/core/python/itkwasm/test/test_js_package_config.py b/packages/core/python/itkwasm/test/test_js_package_config.py index 7569cda5e..ea80dcff4 100644 --- a/packages/core/python/itkwasm/test/test_js_package_config.py +++ b/packages/core/python/itkwasm/test/test_js_package_config.py @@ -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/compress-stringify@0.4.2/dist/bundles/compress-stringify.js' pipelines_base_url = 'https://cdn.jsdelivr.net/npm/@itk-wasm/compress-stringify@0.4.2/dist/pipelines' pipeline_worker_url = 'https://cdn.jsdelivr.net/npm/@itk-wasm/compress-stringify@0.4.2/dist/web-workers/pipeline.worker.js' diff --git a/packages/core/python/itkwasm/test/test_pointset.py b/packages/core/python/itkwasm/test/test_pointset.py index 39c5017ec..28fb691a2 100644 --- a/packages/core/python/itkwasm/test/test_pointset.py +++ b/packages/core/python/itkwasm/test/test_pointset.py @@ -16,7 +16,7 @@ def test_pointset(): point_data = np.random.random((n_points,)).astype(np.float32) pointset.SetPointData(itk.vector_container_from_array(point_data.ravel())) - + itk_pointset_dict = itk.dict_from_pointset(pointset) # Bug, to be fixed by 5.3.0 itk_pointset_dict.pop('dimension', None) @@ -24,7 +24,7 @@ def test_pointset(): itkwasm_pointset_dict = asdict(itkwasm_pointset) itk_pointset_roundtrip = itk.pointset_from_dict(itkwasm_pointset_dict) itk_pointset_roundtrip_dict = itk.dict_from_pointset(itk_pointset_roundtrip) - + pointSetType = itk_pointset_dict["pointSetType"] pointSetType_roundtrip = itk_pointset_roundtrip_dict["pointSetType"] assert pointSetType["dimension"] == pointSetType_roundtrip["dimension"] diff --git a/packages/core/python/itkwasm/test/test_pyodide.py b/packages/core/python/itkwasm/test/test_pyodide.py new file mode 100644 index 000000000..ae64aa01c --- /dev/null +++ b/packages/core/python/itkwasm/test/test_pyodide.py @@ -0,0 +1,291 @@ +import pytest +import sys + +if sys.version_info < (3,10): + pytest.skip("Skipping pyodide tests on older Python", allow_module_level=True) + +from pytest_pyodide import run_in_pyodide + +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) + image.metadata['a_string'] = 'some text' + image.metadata['an int'] = 3 + 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 image_py.metadata['a_string'] == 'some text' + assert image_py.metadata['an int'] == 3 + + assert isinstance(image_py.metadata, dict) + assert np.array_equal(image_py.data, np.arange(4, dtype=np.uint8).reshape((2,2))) + +@run_in_pyodide(packages=['micropip', 'numpy']) +async def test_point_set_conversion(selenium, package_wheel): + import micropip + await micropip.install(package_wheel) + + from itkwasm import PointSet, PointSetType, PixelTypes, FloatTypes + from itkwasm.pyodide import to_js, to_py + import numpy as np + + n_points = 5 + dimension = 3 + + points = np.random.random((n_points, dimension)).astype(np.float32) + point_data = np.random.random((n_points,)).astype(np.float32) + + point_set_type = PointSetType(dimension, FloatTypes.Float32, FloatTypes.Float32, PixelTypes.Scalar, FloatTypes.Float32) + + point_set = PointSet(point_set_type, 'point_set', n_points, points, n_points, point_data) + + point_set_js = to_js(point_set) + point_set_py = to_py(point_set_js) + + point_set_type_py = point_set_py.pointSetType + assert point_set_type.dimension == point_set_type_py.dimension + assert point_set_type.pointComponentType == point_set_type_py.pointComponentType + assert point_set_type.pointPixelComponentType == point_set_type_py.pointPixelComponentType + assert point_set_type.pointPixelType == point_set_type_py.pointPixelType + assert point_set_type.pointPixelComponents == point_set_type_py.pointPixelComponents + + assert point_set.name == point_set_py.name + assert point_set.numberOfPoints == point_set_py.numberOfPoints + assert np.array_equal(point_set.points, point_set_py.points) + assert point_set.numberOfPointPixels == point_set_py.numberOfPointPixels + assert np.array_equal(point_set.pointData, point_set_py.pointData) + +@run_in_pyodide(packages=['micropip', 'numpy']) +async def test_mesh_conversion(selenium, package_wheel): + import micropip + await micropip.install(package_wheel) + + from itkwasm import Mesh, MeshType + from itkwasm.pyodide import to_js, to_py + import numpy as np + + n_points = 5 + dimension = 3 + + mesh_type = MeshType() + + points = np.random.random((n_points, dimension)).astype(np.float32) + point_data = np.random.random((n_points,)).astype(np.float32) + + mesh = Mesh(mesh_type, points=points, numberOfPoints=n_points, pointData=point_data, numberOfPointPixels=n_points) + + mesh_js = to_js(mesh) + mesh_py = to_py(mesh_js) + + mesh_type_py = mesh_py.meshType + assert mesh_type.dimension == mesh_type_py.dimension + assert mesh_type.pointComponentType == mesh_type_py.pointComponentType + assert mesh_type.pointPixelComponentType == mesh_type_py.pointPixelComponentType + assert mesh_type.pointPixelType == mesh_type_py.pointPixelType + assert mesh_type.pointPixelComponents == mesh_type_py.pointPixelComponents + + assert mesh.name == mesh_py.name + assert mesh.numberOfPoints == mesh_py.numberOfPoints + assert np.array_equal(mesh.points, mesh_py.points) + assert mesh.numberOfPointPixels == mesh_py.numberOfPointPixels + assert np.array_equal(mesh.pointData, mesh_py.pointData) + +@run_in_pyodide(packages=['micropip', 'numpy']) +async def test_polydata_conversion(selenium, package_wheel): + import micropip + await micropip.install(package_wheel) + + from itkwasm import PolyData, PolyDataType + from itkwasm.pyodide import to_js, to_py + import numpy as np + + n_points = 5 + dimension = 3 + + polydata_type = PolyDataType() + + points = np.random.random((n_points, dimension)).astype(np.float32) + point_data = np.random.random((n_points,)).astype(np.float32) + + polydata = PolyData(polydata_type, points=points, numberOfPoints=n_points, pointData=point_data, numberOfPointPixels=n_points) + + polydata_js = to_js(polydata) + polydata_py = to_py(polydata_js) + + polydata_type_py = polydata_py.polyDataType + assert polydata_type.pointPixelComponentType == polydata_type_py.pointPixelComponentType + assert polydata_type.pointPixelType == polydata_type_py.pointPixelType + assert polydata_type.pointPixelComponents == polydata_type_py.pointPixelComponents + + assert polydata.name == polydata_py.name + assert polydata.numberOfPoints == polydata_py.numberOfPoints + assert np.array_equal(polydata.points, polydata_py.points) + assert polydata.numberOfPointPixels == polydata_py.numberOfPointPixels + assert np.array_equal(polydata.pointData, polydata_py.pointData) + +@run_in_pyodide(packages=['micropip', 'numpy']) +async def test_binary_stream_conversion(selenium, package_wheel): + import micropip + await micropip.install(package_wheel) + + from itkwasm import BinaryStream + from itkwasm.pyodide import to_js, to_py + + data = bytes([222,173,190,239]) + binary_stream = BinaryStream(data) + + binary_stream_js = to_js(binary_stream) + binary_stream_py = to_py(binary_stream_js) + + assert binary_stream_py.data[0], 222 + assert binary_stream_py.data[1], 173 + assert binary_stream_py.data[2], 190 + assert binary_stream_py.data[3], 239 + +@run_in_pyodide(packages=['micropip', 'numpy']) +async def test_text_stream_conversion(selenium, package_wheel): + import micropip + await micropip.install(package_wheel) + + from itkwasm import TextStream + from itkwasm.pyodide import to_js, to_py + + data = "The answer is 42." + text_stream = TextStream(data) + + text_stream_js = to_js(text_stream) + text_stream_py = to_py(text_stream_js) + + assert text_stream_py.data == data + +@run_in_pyodide(packages=['micropip', 'numpy']) +async def test_binary_file_conversion(selenium, package_wheel): + import micropip + await micropip.install(package_wheel) + + from itkwasm import BinaryFile + from itkwasm.pyodide import to_js, to_py + import numpy as np + from pathlib import PurePosixPath + + data = bytes([222,173,190,239]) + path = PurePosixPath('file.bin') + with open(path, 'wb') as fp: + fp.write(data) + binary_file = BinaryFile(path) + + binary_file_js = to_js(binary_file) + binary_file_py = to_py(binary_file_js) + + with open(binary_file_py.path, 'rb') as fp: + data_py = fp.read() + + assert data_py[0], 222 + assert data_py[1], 173 + assert data_py[2], 190 + assert data_py[3], 239 + +@run_in_pyodide(packages=['micropip', 'numpy']) +async def test_text_file_conversion(selenium, package_wheel): + import micropip + await micropip.install(package_wheel) + + from itkwasm import TextFile + from itkwasm.pyodide import to_js, to_py + import numpy as np + from pathlib import PurePosixPath + + data = "The answer is 42." + path = PurePosixPath('file.txt') + with open(path, 'w') as fp: + fp.write(data) + text_file = TextFile(path) + + text_file_js = to_js(text_file) + text_file_py = to_py(text_file_js) + + with open(text_file_py.path, 'r') as fp: + data_py = fp.read() + + assert data_py == data + +@run_in_pyodide(packages=['micropip', 'numpy']) +async def test_list_conversion(selenium, package_wheel): + import micropip + await micropip.install(package_wheel) + + from itkwasm import TextFile + from itkwasm.pyodide import to_js, to_py + import numpy as np + from pathlib import PurePosixPath + + data = "The answer is 42." + + def create_text_file(index): + path = PurePosixPath(f'file{index}.txt') + with open(path, 'w') as fp: + fp.write(data) + text_file = TextFile(path) + return text_file + + text_files = [create_text_file(index) for index in range(4)] + + text_files_js = to_js(text_files) + text_files_py = to_py(text_files_js) + + def verify_text_file(text_file): + with open(text_file.path, 'r') as fp: + data_py = fp.read() + + assert data_py == data + + for text_file in text_files_py: + verify_text_file(text_file) diff --git a/src/itkPipeline.cxx b/src/itkPipeline.cxx index f6f48f7e5..38bddd868 100644 --- a/src/itkPipeline.cxx +++ b/src/itkPipeline.cxx @@ -41,7 +41,7 @@ ::Pipeline(std::string name, std::string description, int argc, char **argv): this->positionals_at_end(false); this->add_flag("--memory-io", m_UseMemoryIO, "Use itk-wasm memory IO")->group(""); - this->add_flag("--version", m_Version, "Output pipeline version")->group(""); + this->set_version_flag("--version", m_Version); // Set m_UseMemoryIO before it is used by other memory parsers this->preparse_callback([this](size_t arg) @@ -299,7 +299,7 @@ ::interface_json() document.AddMember("name", name.Move(), allocator); rapidjson::Value version; - version.SetString(this->get_version().c_str(), allocator); + version.SetString(this->version().c_str(), allocator); document.AddMember("version", version.Move(), allocator); rapidjson::Value inputs(rapidjson::kArrayType);