diff --git a/packages/core/python/itkwasm/README.md b/packages/core/python/itkwasm/README.md index 694604e3c..6c7883717 100644 --- a/packages/core/python/itkwasm/README.md +++ b/packages/core/python/itkwasm/README.md @@ -15,7 +15,7 @@ We are glad you are here and appreciate your contribution. Please keep in mind o ``` 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 +pixi global install hatch +pixi run download-pyodide +pixi run test ``` diff --git a/packages/core/python/itkwasm/itkwasm/__init__.py b/packages/core/python/itkwasm/itkwasm/__init__.py index 36b702683..8e1c405a3 100644 --- a/packages/core/python/itkwasm/itkwasm/__init__.py +++ b/packages/core/python/itkwasm/itkwasm/__init__.py @@ -1,6 +1,6 @@ """itkwasm: Python interface to itk-wasm WebAssembly modules.""" -__version__ = "1.0b195" +__version__ = "1.0b199" from .interface_types import InterfaceTypes from .image import Image, ImageType, ImageRegion diff --git a/packages/core/python/itkwasm/itkwasm/image.py b/packages/core/python/itkwasm/itkwasm/image.py index e67d76330..6f733a76f 100644 --- a/packages/core/python/itkwasm/itkwasm/image.py +++ b/packages/core/python/itkwasm/itkwasm/image.py @@ -31,6 +31,20 @@ class ImageRegion: size: Sequence[int] = field(default_factory=list) +def _buffered_region_size(data, dimension: int) -> Optional[Sequence[int]]: + """The buffered region size implied by the shape of a pixel data buffer. + + None when the buffer does not describe the region, so the current region is + kept. This is the case for a raveled buffer, and for the data: URI that + transiently occupies the field while pipeline output JSON is deserialized, + before the buffer it addresses is read into an array. + """ + shape = getattr(data, "shape", None) + if shape is None or len(shape) < dimension: + return None + return list(shape[:dimension][::-1]) + + @dataclass class Image: imageType: Union[ImageType, Dict] = field(default_factory=ImageType) @@ -48,9 +62,6 @@ class Image: bufferedRegion: Optional[ImageRegion] = None def __post_init__(self): - if isinstance(self.imageType, dict): - self.imageType = ImageType(**self.imageType) - dimension = self.imageType.dimension if len(self.origin) == 0: self.origin += [ @@ -71,18 +82,36 @@ def __post_init__(self): ] * dimension if self.bufferedRegion is None: - if self.data is not None and hasattr(self.data, 'shape'): - self.bufferedRegion = ImageRegion( - index=(0,) * dimension, - size=self.data.shape[:dimension][::-1], - ) - else: - self.bufferedRegion = ImageRegion( - index=( - 0, - ) - * dimension, - size=self.size, - ) - elif isinstance(self.bufferedRegion, dict): - self.bufferedRegion = ImageRegion(**self.bufferedRegion) + size = _buffered_region_size(self.data, dimension) + if size is None: + # A copy, so the buffered region does not track subsequent + # changes to the largest possible region + size = list(self.size) + self.bufferedRegion = ImageRegion( + index=[ + 0, + ] + * dimension, + size=size, + ) + + def __setattr__(self, name, value): + # Dicts, e.g. from JSON, are converted however they are assigned + if name == "imageType" and isinstance(value, dict): + value = ImageType(**value) + elif name == "bufferedRegion" and isinstance(value, dict): + value = ImageRegion(**value) + + super().__setattr__(name, value) + + # The data buffer holds the buffered region -- keep the region + # consistent when data is assigned after construction. + if name == "data" and value is not None: + buffered_region = getattr(self, "bufferedRegion", None) + if buffered_region is None: + return + size = _buffered_region_size(value, self.imageType.dimension) + if size is not None and list(buffered_region.size) != size: + # A new region, so a shallow copy of the image does not modify + # the region of the image it was copied from + self.bufferedRegion = ImageRegion(index=list(buffered_region.index), size=size) diff --git a/packages/core/python/itkwasm/pyproject.toml b/packages/core/python/itkwasm/pyproject.toml index f127cc710..08bb3279a 100644 --- a/packages/core/python/itkwasm/pyproject.toml +++ b/packages/core/python/itkwasm/pyproject.toml @@ -48,7 +48,7 @@ path = "itkwasm/__init__.py" [tool.black] line-length = 120 -[tool.pixi.project] +[tool.pixi.workspace] channels = ["conda-forge"] platforms = ["win-64", "linux-64", "linux-aarch64", "osx-arm64"] diff --git a/packages/core/python/itkwasm/test/test_image.py b/packages/core/python/itkwasm/test/test_image.py index 724633512..086dad187 100644 --- a/packages/core/python/itkwasm/test/test_image.py +++ b/packages/core/python/itkwasm/test/test_image.py @@ -1,8 +1,9 @@ +import copy from pathlib import Path import itk -from itkwasm import Image, ImageType +from itkwasm import Image, ImageRegion, ImageType from dataclasses import asdict import numpy as np @@ -43,6 +44,7 @@ def test_image_defaults(): assert isinstance(image.metadata, dict) assert image.data == None + def test_image_with_data(): """Test creating an Image with data.""" image_type = ImageType( @@ -78,4 +80,117 @@ def test_image_with_data(): assert image.bufferedRegion.index[0] == 0 assert image.bufferedRegion.index[1] == 0 assert image.bufferedRegion.size[0] == 6 - assert image.bufferedRegion.size[1] == 5 \ No newline at end of file + assert image.bufferedRegion.size[1] == 5 + + +def test_image_data_set_after_construction(): + """The buffered region follows data assigned after construction.""" + image = Image() + image.size = [4, 4] + image.data = np.arange(16, dtype=np.uint8).reshape((4, 4)) + + assert image.size == [4, 4] + assert list(image.bufferedRegion.index) == [0, 0] + assert list(image.bufferedRegion.size) == [4, 4] + + # The largest possible region is not the buffered region + image.size = [8, 8] + assert list(image.bufferedRegion.size) == [4, 4] + + +def test_image_information_only(): + """An explicit buffered region is preserved.""" + image = Image( + size=[4, 4], + bufferedRegion=ImageRegion(index=[0, 0], size=[0, 0]), + data=np.empty((0, 0), dtype=np.uint8), + ) + + assert image.size == [4, 4] + assert list(image.bufferedRegion.size) == [0, 0] + + +def test_image_buffered_region_does_not_alias_size(): + """The default buffered region is not the largest possible region.""" + image = Image(size=[4, 4]) + assert list(image.bufferedRegion.size) == [4, 4] + + image.size[0] = 8 + assert list(image.bufferedRegion.size) == [4, 4] + + +def test_image_data_preserves_buffered_region_index(): + """A buffered region index survives data assignment.""" + image = Image(size=[8, 8], bufferedRegion=ImageRegion(index=[2, 2], size=[1, 1])) + image.data = np.zeros((4, 4), dtype=np.uint8) + + assert list(image.bufferedRegion.index) == [2, 2] + assert list(image.bufferedRegion.size) == [4, 4] + + +def test_image_data_on_a_shallow_copy(): + """Data assigned to a shallow copy does not change the original region.""" + image = Image(size=[4, 4], data=np.zeros((4, 4), dtype=np.uint8)) + copied = copy.copy(image) + copied.data = np.zeros((2, 2), dtype=np.uint8) + + assert list(image.bufferedRegion.size) == [4, 4] + assert list(copied.bufferedRegion.size) == [2, 2] + + +def test_image_vector_data(): + """The buffered region excludes the components axis.""" + image_type = ImageType( + dimension=2, + componentType="uint8", + pixelType="VariableLengthVector", + components=3, + ) + image = Image(imageType=image_type) + image.size = [5, 4] + image.data = np.zeros((4, 5, 3), dtype=np.uint8) + + assert list(image.bufferedRegion.size) == [5, 4] + + +def test_image_type_assigned_a_dict(): + """A dict imageType is converted however it is assigned.""" + image = Image() + image.imageType = { + "dimension": 3, + "componentType": "uint16", + "pixelType": "Scalar", + "components": 1, + } + + assert isinstance(image.imageType, ImageType) + assert image.imageType.dimension == 3 + + image.size = [4, 3, 2] + image.data = np.zeros((2, 3, 4), dtype=np.uint16) + assert list(image.bufferedRegion.size) == [4, 3, 2] + + +def test_buffered_region_assigned_a_dict(): + """A dict bufferedRegion is converted however it is assigned.""" + image = Image(size=[4, 4]) + image.bufferedRegion = {"index": [0, 0], "size": [2, 2]} + + assert isinstance(image.bufferedRegion, ImageRegion) + assert list(image.bufferedRegion.size) == [2, 2] + + image.data = np.zeros((4, 4), dtype=np.uint8) + assert list(image.bufferedRegion.size) == [4, 4] + + +def test_image_data_without_a_region_shape(): + """Data that does not describe the buffered region leaves it alone.""" + image = Image(size=[4, 4], bufferedRegion=ImageRegion(index=[0, 0], size=[4, 4])) + + # A raveled buffer + image.data = np.zeros(16, dtype=np.uint8) + assert list(image.bufferedRegion.size) == [4, 4] + + # A data: URI, as found in pipeline output JSON + image.data = "data:application/vnd.itk.path,data/data.raw" + assert list(image.bufferedRegion.size) == [4, 4] diff --git a/packages/core/python/itkwasm/test/test_image_from_array.py b/packages/core/python/itkwasm/test/test_image_from_array.py index 7d5a383fd..da094cf58 100644 --- a/packages/core/python/itkwasm/test/test_image_from_array.py +++ b/packages/core/python/itkwasm/test/test_image_from_array.py @@ -51,6 +51,7 @@ def test_image_from_array_explicit(): assert image.imageType.pixelType == PixelTypes.VariableLengthVector assert image.imageType.components == 3 + def test_image_from_array_buffered_region(): arr = np.random.rand(9, 9, 3) image = image_from_array(arr, is_vector=True) @@ -62,4 +63,4 @@ def test_image_from_array_buffered_region(): assert image.bufferedRegion.index[0] == 0 assert image.bufferedRegion.index[1] == 0 assert image.bufferedRegion.size[0] == 9 - assert image.bufferedRegion.size[1] == 9 \ No newline at end of file + assert image.bufferedRegion.size[1] == 9 diff --git a/packages/core/python/itkwasm/test/test_pipeline.py b/packages/core/python/itkwasm/test/test_pipeline.py index 388878a50..090a11436 100644 --- a/packages/core/python/itkwasm/test/test_pipeline.py +++ b/packages/core/python/itkwasm/test/test_pipeline.py @@ -352,6 +352,7 @@ def test_pipeline_write_read_polydata(): assert out_mesh.GetNumberOfPoints() == 2903 assert out_mesh.GetNumberOfCells() == 3263 + def test_pipeline_write_read_transform(): pipeline = Pipeline(test_input_dir / "transform-read-write-test.wasi.wasm") @@ -384,8 +385,22 @@ def test_pipeline_write_read_transform(): assert transform.numberOfParameters == 12 assert transform.numberOfFixedParameters == 3 np.testing.assert_allclose(transform.fixedParameters, np.array([0.0, 0.0, 0.0])) - np.testing.assert_allclose(transform.parameters, np.array([ - 0.65631490118447, 0.5806583745824385, -0.4817536741017158, - -0.7407986817430222, 0.37486398378429736, -0.5573995934598175, - -0.14306664045479867, 0.7227121458012518, 0.676179776908723, - -65.99999999999997, 69.00000000000004, 32.000000000000036])) + np.testing.assert_allclose( + transform.parameters, + np.array( + [ + 0.65631490118447, + 0.5806583745824385, + -0.4817536741017158, + -0.7407986817430222, + 0.37486398378429736, + -0.5573995934598175, + -0.14306664045479867, + 0.7227121458012518, + 0.676179776908723, + -65.99999999999997, + 69.00000000000004, + 32.000000000000036, + ] + ), + ) diff --git a/packages/core/python/itkwasm/test/test_pyodide.py b/packages/core/python/itkwasm/test/test_pyodide.py index 56a3a4c85..a4fc8339d 100644 --- a/packages/core/python/itkwasm/test/test_pyodide.py +++ b/packages/core/python/itkwasm/test/test_pyodide.py @@ -7,16 +7,21 @@ from pytest_pyodide import run_in_pyodide, copy_files_to_pyodide -#from itkwasm import __version__ as test_package_version -test_package_version = '1.0b195' +from itkwasm import __version__ as test_package_version + +# manual override +# test_package_version = '1.0b195' def package_wheel(): wheel_stem = f"itkwasm-{test_package_version}-py3-none-any.whl" - wheel_path = Path(__file__).parent.parent / 'dist' / 'pyodide' / wheel_stem + wheel_path = Path(__file__).parent.parent / "dist" / "pyodide" / wheel_stem return wheel_path, wheel_stem -file_list = [package_wheel(),] + +file_list = [ + package_wheel(), +] @copy_files_to_pyodide(file_list=file_list, install_wheels=True) @@ -202,6 +207,7 @@ async def test_polydata_conversion(selenium): assert polydata.numberOfPointPixels == polydata_py.numberOfPointPixels assert np.array_equal(polydata.pointData, polydata_py.pointData) + @copy_files_to_pyodide(file_list=file_list, install_wheels=True) @run_in_pyodide(packages=["numpy"]) async def test_transform_conversion(selenium): @@ -212,13 +218,26 @@ async def test_transform_conversion(selenium): dimension = 3 transform_type = TransformType(TransformParameterizations.Affine) fixed_parameters = np.array([0.0, 0.0, 0.0]).astype(np.float64) - parameters = np.array([ - 0.65631490118447, 0.5806583745824385, -0.4817536741017158, - -0.7407986817430222, 0.37486398378429736, -0.5573995934598175, - -0.14306664045479867, 0.7227121458012518, 0.676179776908723, - -65.99999999999997, 69.00000000000004, 32.000000000000036]).astype(np.float64) + parameters = np.array( + [ + 0.65631490118447, + 0.5806583745824385, + -0.4817536741017158, + -0.7407986817430222, + 0.37486398378429736, + -0.5573995934598175, + -0.14306664045479867, + 0.7227121458012518, + 0.676179776908723, + -65.99999999999997, + 69.00000000000004, + 32.000000000000036, + ] + ).astype(np.float64) transform = Transform(transform_type, dimension, 12, fixedParameters=fixed_parameters, parameters=parameters) - transform_list = [transform,] + transform_list = [ + transform, + ] transform_list_js = to_js(transform_list) transform_list_py = to_py(transform_list_js) @@ -228,12 +247,27 @@ async def test_transform_conversion(selenium): assert transform_py.numberOfParameters == 12 assert transform_py.numberOfFixedParameters == 3 np.testing.assert_allclose(transform_py.fixedParameters, np.array([0.0, 0.0, 0.0])) - np.testing.assert_allclose(transform_py.parameters, np.array([ - 0.65631490118447, 0.5806583745824385, -0.4817536741017158, - -0.7407986817430222, 0.37486398378429736, -0.5573995934598175, - -0.14306664045479867, 0.7227121458012518, 0.676179776908723, - -65.99999999999997, 69.00000000000004, 32.000000000000036])) - print('transform_py', transform_py) + np.testing.assert_allclose( + transform_py.parameters, + np.array( + [ + 0.65631490118447, + 0.5806583745824385, + -0.4817536741017158, + -0.7407986817430222, + 0.37486398378429736, + -0.5573995934598175, + -0.14306664045479867, + 0.7227121458012518, + 0.676179776908723, + -65.99999999999997, + 69.00000000000004, + 32.000000000000036, + ] + ), + ) + print("transform_py", transform_py) + @copy_files_to_pyodide(file_list=file_list, install_wheels=True) @run_in_pyodide(packages=["numpy"]) @@ -247,18 +281,33 @@ async def test_transform_list_conversion(selenium): # First transform - Affine affine_type = TransformType(TransformParameterizations.Affine) affine_fixed_parameters = np.array([0.0, 0.0, 0.0]).astype(np.float64) - affine_parameters = np.array([ - 0.65631490118447, 0.5806583745824385, -0.4817536741017158, - -0.7407986817430222, 0.37486398378429736, -0.5573995934598175, - -0.14306664045479867, 0.7227121458012518, 0.676179776908723, - -65.99999999999997, 69.00000000000004, 32.000000000000036]).astype(np.float64) - affine_transform = Transform(affine_type, 3, 12, fixedParameters=affine_fixed_parameters, parameters=affine_parameters) + affine_parameters = np.array( + [ + 0.65631490118447, + 0.5806583745824385, + -0.4817536741017158, + -0.7407986817430222, + 0.37486398378429736, + -0.5573995934598175, + -0.14306664045479867, + 0.7227121458012518, + 0.676179776908723, + -65.99999999999997, + 69.00000000000004, + 32.000000000000036, + ] + ).astype(np.float64) + affine_transform = Transform( + affine_type, 3, 12, fixedParameters=affine_fixed_parameters, parameters=affine_parameters + ) # Second transform - Translation translation_type = TransformType(TransformParameterizations.Translation) translation_fixed_parameters = np.array([]).astype(np.float64) translation_parameters = np.array([10.0, 20.0, 30.0]).astype(np.float64) - translation_transform = Transform(translation_type, 0, 3, fixedParameters=translation_fixed_parameters, parameters=translation_parameters) + translation_transform = Transform( + translation_type, 0, 3, fixedParameters=translation_fixed_parameters, parameters=translation_parameters + ) # Create TransformList transform_list: TransformList = [affine_transform, translation_transform] @@ -285,6 +334,7 @@ async def test_transform_list_conversion(selenium): assert translation_py.numberOfFixedParameters == 0 np.testing.assert_allclose(translation_py.parameters, translation_parameters) + @copy_files_to_pyodide(file_list=file_list, install_wheels=True) @run_in_pyodide(packages=["numpy"]) async def test_linear_transform_conversion(selenium): @@ -297,23 +347,33 @@ async def test_linear_transform_conversion(selenium): transformParameterization=TransformParameterizations.Affine, parametersValueType=FloatTypes.Float64, inputDimension=3, - outputDimension=3 + outputDimension=3, ) fixed_parameters = np.array([0.0, 0.0, 0.0]).astype(np.float64) - parameters = np.array([ - 1.0, 0.0, 0.0, # 3x3 matrix row 1 - 0.0, 1.0, 0.0, # 3x3 matrix row 2 - 0.0, 0.0, 1.0, # 3x3 matrix row 3 - 10.0, 20.0, 30.0 # translation vector - ]).astype(np.float64) + parameters = np.array( + [ + 1.0, + 0.0, + 0.0, # 3x3 matrix row 1 + 0.0, + 1.0, + 0.0, # 3x3 matrix row 2 + 0.0, + 0.0, + 1.0, # 3x3 matrix row 3 + 10.0, + 20.0, + 30.0, # translation vector + ] + ).astype(np.float64) transform = Transform( transformType=transform_type, numberOfParameters=12, numberOfFixedParameters=3, fixedParameters=fixed_parameters, - parameters=parameters + parameters=parameters, ) transform_list: TransformList = [transform] @@ -349,7 +409,7 @@ async def test_composite_transform_conversion(selenium): transformParameterization=TransformParameterizations.Composite, parametersValueType=FloatTypes.Float32, inputDimension=2, - outputDimension=2 + outputDimension=2, ) composite_transform = Transform( @@ -357,12 +417,21 @@ async def test_composite_transform_conversion(selenium): numberOfParameters=9, # Sum of parameters from component transforms numberOfFixedParameters=4, # Sum of fixed parameters from component transforms fixedParameters=np.array([64.0, 64.0, 64.0, 64.0]).astype(np.float32), - parameters=np.array([ - # Rigid2D parameters (3) - 0.0, 64.0, 64.0, - # Affine parameters (6) - 1.0, 0.0, 0.0, 1.0, 0.0, 0.0 - ]).astype(np.float32) + parameters=np.array( + [ + # Rigid2D parameters (3) + 0.0, + 64.0, + 64.0, + # Affine parameters (6) + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + ] + ).astype(np.float32), ) # Create first component transform (Rigid2D) @@ -370,7 +439,7 @@ async def test_composite_transform_conversion(selenium): transformParameterization=TransformParameterizations.Rigid2D, parametersValueType=FloatTypes.Float32, inputDimension=2, - outputDimension=2 + outputDimension=2, ) rigid_transform = Transform( @@ -378,7 +447,7 @@ async def test_composite_transform_conversion(selenium): numberOfParameters=3, numberOfFixedParameters=2, fixedParameters=np.array([64.0, 64.0]).astype(np.float32), - parameters=np.array([0.0, 64.0, 64.0]).astype(np.float32) + parameters=np.array([0.0, 64.0, 64.0]).astype(np.float32), ) # Create second component transform (Affine) @@ -386,7 +455,7 @@ async def test_composite_transform_conversion(selenium): transformParameterization=TransformParameterizations.Affine, parametersValueType=FloatTypes.Float32, inputDimension=2, - outputDimension=2 + outputDimension=2, ) affine_transform = Transform( @@ -394,7 +463,7 @@ async def test_composite_transform_conversion(selenium): numberOfParameters=6, numberOfFixedParameters=2, fixedParameters=np.array([64.0, 64.0]).astype(np.float32), - parameters=np.array([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]).astype(np.float32) + parameters=np.array([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]).astype(np.float32), ) # Create transform list with composite + components @@ -593,30 +662,19 @@ async def test_json_object_conversion(selenium): assert data_py["b"] == True assert data_py["c"]["nested"] == True + @copy_files_to_pyodide(file_list=file_list, install_wheels=True) @run_in_pyodide(packages=["micropip"]) async def test_parameter_object_conversion(selenium): from itkwasm.pyodide import to_js, to_py + params = [ + {"AutomaticScalesEstimation": ["true"], "AutomaticTransformInitialization": ["true"]}, { - "AutomaticScalesEstimation": [ - "true" - ], - "AutomaticTransformInitialization": [ - "true" - ] + "AutomaticScalesEstimation": ["true"], + "AutomaticTransformInitialization": ["true"], + "BSplineInterpolationOrder": ["1"], }, - { - "AutomaticScalesEstimation": [ - "true" - ], - "AutomaticTransformInitialization": [ - "true" - ], - "BSplineInterpolationOrder": [ - "1" - ] - } ] params_js = to_js(params) diff --git a/packages/core/python/itkwasm/test/test_transform.py b/packages/core/python/itkwasm/test/test_transform.py index e09ad3621..437d886e3 100644 --- a/packages/core/python/itkwasm/test/test_transform.py +++ b/packages/core/python/itkwasm/test/test_transform.py @@ -163,4 +163,4 @@ def verify_transform(transform_list): args = ["--memory-io", "0", "0"] outputs = pipeline.run(args, pipeline_outputs, pipeline_inputs) - verify_transform(outputs[0].data) \ No newline at end of file + verify_transform(outputs[0].data)