diff --git a/packages/core/python/itkwasm/itkwasm/pipeline.py b/packages/core/python/itkwasm/itkwasm/pipeline.py index dc2a2061f..8041bdd99 100644 --- a/packages/core/python/itkwasm/itkwasm/pipeline.py +++ b/packages/core/python/itkwasm/itkwasm/pipeline.py @@ -316,19 +316,29 @@ def run( elif input_.type == InterfaceTypes.TransformList: transform_list = input_.data transform_list_json = [] - for idx, transform in enumerate(transform_list): - if transform.numberOfFixedParameters: - fpv = array_like_to_bytes(transform.fixedParameters) - else: - fpv = bytes([]) - fixed_parameters_ptr = ri.set_input_array(fpv, index, idx * 2) - fixed_parameters = f"data:application/vnd.itk.address,0:{fixed_parameters_ptr}" - if transform.numberOfParameters: - pv = array_like_to_bytes(transform.parameters) - else: - pv = bytes([]) - parameters_ptr = ri.set_input_array(pv, index, idx * 2 + 1) - parameters = f"data:application/vnd.itk.address,0:{parameters_ptr}" + input_array_index = 0 + for transform in transform_list: + fixed_parameters = "" + parameters = "" + + # Skip setting input arrays for Composite transforms as they don't have array data + if transform.transformType.transformParameterization != "Composite": + if transform.numberOfFixedParameters: + fpv = array_like_to_bytes(transform.fixedParameters) + else: + fpv = bytes([]) + fixed_parameters_ptr = ri.set_input_array(fpv, index, input_array_index) + fixed_parameters = f"data:application/vnd.itk.address,0:{fixed_parameters_ptr}" + input_array_index += 1 + + if transform.numberOfParameters: + pv = array_like_to_bytes(transform.parameters) + else: + pv = bytes([]) + parameters_ptr = ri.set_input_array(pv, index, input_array_index) + parameters = f"data:application/vnd.itk.address,0:{parameters_ptr}" + input_array_index += 1 + transform_json = { "transformType": asdict(transform.transformType), "numberOfFixedParameters": transform.numberOfFixedParameters, @@ -540,22 +550,33 @@ def run( elif output.type == InterfaceTypes.TransformList: transform_list_json = ri.get_output_json(index) transform_list = [] - for idx, transform_json in enumerate(transform_list_json): + output_array_index = 0 + for transform_json in transform_list_json: transform = Transform(**transform_json) + + # Skip array reading for Composite transforms as they don't have array data + if transform.transformType.transformParameterization == "Composite": + transform_list.append(transform) + continue + if transform.numberOfFixedParameters > 0: - data_ptr = ri.get_output_array_address(0, index, idx * 2) - data_size = ri.get_output_array_size(0, index, idx * 2) + data_ptr = ri.get_output_array_address(0, index, output_array_index) + data_size = ri.get_output_array_size(0, index, output_array_index) transform.fixedParameters = buffer_to_numpy_array( FloatTypes.Float64, ri.wasmtime_lift(data_ptr, data_size), ) + output_array_index += 1 + if transform.numberOfParameters > 0: - data_ptr = ri.get_output_array_address(0, index, idx * 2 + 1) - data_size = ri.get_output_array_size(0, index, idx * 2 + 1) + data_ptr = ri.get_output_array_address(0, index, output_array_index) + data_size = ri.get_output_array_size(0, index, output_array_index) transform.parameters = buffer_to_numpy_array( transform.transformType.parametersValueType, ri.wasmtime_lift(data_ptr, data_size), ) + output_array_index += 1 + transform_list.append(transform) output_data = PipelineOutput(InterfaceTypes.TransformList, transform_list) elif output.type == InterfaceTypes.PolyData: diff --git a/packages/core/python/itkwasm/itkwasm/pyodide.py b/packages/core/python/itkwasm/itkwasm/pyodide.py index f6e2aadf5..106c0f669 100644 --- a/packages/core/python/itkwasm/itkwasm/pyodide.py +++ b/packages/core/python/itkwasm/itkwasm/pyodide.py @@ -5,7 +5,7 @@ from .point_set import PointSet, PointSetType from .mesh import Mesh, MeshType from .polydata import PolyData, PolyDataType -from .transform import Transform, TransformType +from .transform import Transform, TransformType, TransformList from .binary_file import BinaryFile from .binary_stream import BinaryStream from .text_file import TextFile diff --git a/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/1/fixed-parameters.raw b/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/1/fixed-parameters.raw new file mode 100644 index 000000000..cbdf5324e Binary files /dev/null and b/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/1/fixed-parameters.raw differ diff --git a/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/1/parameters.raw b/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/1/parameters.raw new file mode 100644 index 000000000..204c33424 Binary files /dev/null and b/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/1/parameters.raw differ diff --git a/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/2/fixed-parameters.raw b/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/2/fixed-parameters.raw new file mode 100644 index 000000000..09385b9c1 Binary files /dev/null and b/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/2/fixed-parameters.raw differ diff --git a/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/2/parameters.raw b/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/2/parameters.raw new file mode 100644 index 000000000..66754726c Binary files /dev/null and b/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/data/2/parameters.raw differ diff --git a/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/index.json b/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/index.json new file mode 100644 index 000000000..3a90eca84 --- /dev/null +++ b/packages/core/python/itkwasm/test/input/CompositeTransform.iwt/index.json @@ -0,0 +1,50 @@ +[ + { + "transformType": { + "transformParameterization": "Composite", + "parametersValueType": "float32", + "inputDimension": 2, + "outputDimension": 2 + }, + "numberOfFixedParameters": 4, + "numberOfParameters": 9, + "name": "", + "inputSpaceName": "", + "outputSpaceName": "", + "fixedParameters": "data:application/vnd.itk.path,data/0/fixed-parameters.raw", + "parameters": "data:application/vnd.itk.path,data/0/parameters.raw", + "metadata": [] + }, + { + "transformType": { + "transformParameterization": "Rigid2D", + "parametersValueType": "float32", + "inputDimension": 2, + "outputDimension": 2 + }, + "numberOfFixedParameters": 2, + "numberOfParameters": 3, + "name": "", + "inputSpaceName": "", + "outputSpaceName": "", + "fixedParameters": "data:application/vnd.itk.path,data/1/fixed-parameters.raw", + "parameters": "data:application/vnd.itk.path,data/1/parameters.raw", + "metadata": [] + }, + { + "transformType": { + "transformParameterization": "Affine", + "parametersValueType": "float32", + "inputDimension": 2, + "outputDimension": 2 + }, + "numberOfFixedParameters": 2, + "numberOfParameters": 6, + "name": "", + "inputSpaceName": "", + "outputSpaceName": "", + "fixedParameters": "data:application/vnd.itk.path,data/2/fixed-parameters.raw", + "parameters": "data:application/vnd.itk.path,data/2/parameters.raw", + "metadata": [] + } +] \ No newline at end of file diff --git a/packages/core/python/itkwasm/test/input/LinearTransform.iwt/data/0/fixed-parameters.raw b/packages/core/python/itkwasm/test/input/LinearTransform.iwt/data/0/fixed-parameters.raw new file mode 100644 index 000000000..4ac5fc6cf Binary files /dev/null and b/packages/core/python/itkwasm/test/input/LinearTransform.iwt/data/0/fixed-parameters.raw differ diff --git a/packages/core/python/itkwasm/test/input/LinearTransform.iwt/data/0/parameters.raw b/packages/core/python/itkwasm/test/input/LinearTransform.iwt/data/0/parameters.raw new file mode 100644 index 000000000..0c580a3db Binary files /dev/null and b/packages/core/python/itkwasm/test/input/LinearTransform.iwt/data/0/parameters.raw differ diff --git a/packages/core/python/itkwasm/test/input/LinearTransform.iwt/index.json b/packages/core/python/itkwasm/test/input/LinearTransform.iwt/index.json new file mode 100644 index 000000000..e880f3438 --- /dev/null +++ b/packages/core/python/itkwasm/test/input/LinearTransform.iwt/index.json @@ -0,0 +1,18 @@ +[ + { + "transformType": { + "transformParameterization": "Affine", + "parametersValueType": "float64", + "inputDimension": 3, + "outputDimension": 3 + }, + "numberOfFixedParameters": 3, + "numberOfParameters": 12, + "name": "", + "inputSpaceName": "", + "outputSpaceName": "", + "fixedParameters": "data:application/vnd.itk.path,data/0/fixed-parameters.raw", + "parameters": "data:application/vnd.itk.path,data/0/parameters.raw", + "metadata": [] + } +] \ No newline at end of file diff --git a/packages/core/python/itkwasm/test/input/transform-read-write-composite-test.wasi.wasm b/packages/core/python/itkwasm/test/input/transform-read-write-composite-test.wasi.wasm new file mode 100644 index 000000000..65c6c4422 Binary files /dev/null and b/packages/core/python/itkwasm/test/input/transform-read-write-composite-test.wasi.wasm differ diff --git a/packages/core/python/itkwasm/test/input/transform-read-write-test.wasi.wasm b/packages/core/python/itkwasm/test/input/transform-read-write-test.wasi.wasm old mode 100755 new mode 100644 index 2e5d2d4fd..77f882e70 Binary files a/packages/core/python/itkwasm/test/input/transform-read-write-test.wasi.wasm and b/packages/core/python/itkwasm/test/input/transform-read-write-test.wasi.wasm differ diff --git a/packages/core/python/itkwasm/test/test_pyodide.py b/packages/core/python/itkwasm/test/test_pyodide.py index ad1332a6f..a4835f8e0 100644 --- a/packages/core/python/itkwasm/test/test_pyodide.py +++ b/packages/core/python/itkwasm/test/test_pyodide.py @@ -8,7 +8,7 @@ from pytest_pyodide import run_in_pyodide, copy_files_to_pyodide #from itkwasm import __version__ as test_package_version -test_package_version = '1.0b194' +test_package_version = '1.0b195' def package_wheel(): @@ -273,6 +273,160 @@ 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): + from itkwasm import Transform, TransformType, TransformParameterizations, FloatTypes, TransformList + from itkwasm.pyodide import to_js, to_py + import numpy as np + + # Create a single linear (affine) transform + transform_type = TransformType( + transformParameterization=TransformParameterizations.Affine, + parametersValueType=FloatTypes.Float64, + inputDimension=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) + + transform = Transform( + transformType=transform_type, + numberOfParameters=12, + numberOfFixedParameters=3, + fixedParameters=fixed_parameters, + parameters=parameters + ) + + transform_list: TransformList = [transform] + + # Convert to JS and back + transform_list_js = to_js(transform_list) + transform_list_py = to_py(transform_list_js) + + # Verify the conversion + assert len(transform_list_py) == 1 + + transform_py = transform_list_py[0] + assert transform_py.transformType.transformParameterization == TransformParameterizations.Affine + assert transform_py.transformType.parametersValueType == FloatTypes.Float64 + assert transform_py.transformType.inputDimension == 3 + assert transform_py.transformType.outputDimension == 3 + assert transform_py.numberOfFixedParameters == 3 + assert transform_py.numberOfParameters == 12 + + np.testing.assert_allclose(transform_py.fixedParameters, fixed_parameters) + np.testing.assert_allclose(transform_py.parameters, parameters) + + +@copy_files_to_pyodide(file_list=file_list, install_wheels=True) +@run_in_pyodide(packages=["numpy"]) +async def test_composite_transform_conversion(selenium): + from itkwasm import Transform, TransformType, TransformParameterizations, FloatTypes, TransformList + from itkwasm.pyodide import to_js, to_py + import numpy as np + + # Create a composite transform + composite_transform_type = TransformType( + transformParameterization=TransformParameterizations.Composite, + parametersValueType=FloatTypes.Float32, + inputDimension=2, + outputDimension=2 + ) + + composite_transform = Transform( + transformType=composite_transform_type, + 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) + ) + + # Create first component transform (Rigid2D) + rigid_transform_type = TransformType( + transformParameterization=TransformParameterizations.Rigid2D, + parametersValueType=FloatTypes.Float32, + inputDimension=2, + outputDimension=2 + ) + + rigid_transform = Transform( + transformType=rigid_transform_type, + 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) + ) + + # Create second component transform (Affine) + affine_transform_type = TransformType( + transformParameterization=TransformParameterizations.Affine, + parametersValueType=FloatTypes.Float32, + inputDimension=2, + outputDimension=2 + ) + + affine_transform = Transform( + transformType=affine_transform_type, + 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) + ) + + # Create transform list with composite + components + transform_list: TransformList = [composite_transform, rigid_transform, affine_transform] + + # Convert to JS and back + transform_list_js = to_js(transform_list) + transform_list_py = to_py(transform_list_js) + + # Verify the conversion + assert len(transform_list_py) == 3 + + # Verify composite transform + composite_py = transform_list_py[0] + assert composite_py.transformType.transformParameterization == TransformParameterizations.Composite + assert composite_py.transformType.parametersValueType == FloatTypes.Float32 + assert composite_py.transformType.inputDimension == 2 + assert composite_py.transformType.outputDimension == 2 + assert composite_py.numberOfFixedParameters == 4 + assert composite_py.numberOfParameters == 9 + + # Verify rigid transform + rigid_py = transform_list_py[1] + assert rigid_py.transformType.transformParameterization == TransformParameterizations.Rigid2D + assert rigid_py.transformType.parametersValueType == FloatTypes.Float32 + assert rigid_py.transformType.inputDimension == 2 + assert rigid_py.transformType.outputDimension == 2 + assert rigid_py.numberOfFixedParameters == 2 + assert rigid_py.numberOfParameters == 3 + np.testing.assert_allclose(rigid_py.fixedParameters, np.array([64.0, 64.0])) + np.testing.assert_allclose(rigid_py.parameters, np.array([0.0, 64.0, 64.0])) + + # Verify affine transform + affine_py = transform_list_py[2] + assert affine_py.transformType.transformParameterization == TransformParameterizations.Affine + assert affine_py.transformType.parametersValueType == FloatTypes.Float32 + assert affine_py.transformType.inputDimension == 2 + assert affine_py.transformType.outputDimension == 2 + assert affine_py.numberOfFixedParameters == 2 + assert affine_py.numberOfParameters == 6 + np.testing.assert_allclose(affine_py.fixedParameters, np.array([64.0, 64.0])) + np.testing.assert_allclose(affine_py.parameters, np.array([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])) + + @copy_files_to_pyodide(file_list=file_list, install_wheels=True) @run_in_pyodide(packages=["numpy"]) async def test_binary_stream_conversion(selenium): diff --git a/packages/core/python/itkwasm/test/test_transform.py b/packages/core/python/itkwasm/test/test_transform.py index e69de29bb..e09ad3621 100644 --- a/packages/core/python/itkwasm/test/test_transform.py +++ b/packages/core/python/itkwasm/test/test_transform.py @@ -0,0 +1,166 @@ +import json +import numpy as np +from pathlib import Path + +from itkwasm import ( + InterfaceTypes, + PipelineInput, + PipelineOutput, + Pipeline, + Transform, + TransformParameterizations, + FloatTypes, +) + +test_input_dir = Path(__file__).resolve().parent / "input" + + +def read_linear_transform(): + """Read the LinearTransform.iwt test data.""" + test_input_transform_dir = test_input_dir / "LinearTransform.iwt" + + # Read the JSON metadata + with open(test_input_transform_dir / "index.json", "r") as f: + transform_list_json = json.load(f) + + # Read binary data for each transform + for i, transform_json in enumerate(transform_list_json): + # Read fixed parameters + fixed_params_path = test_input_transform_dir / "data" / "0" / "fixed-parameters.raw" + with open(fixed_params_path, "rb") as f: + fixed_params_bytes = f.read() + fixed_parameters = np.frombuffer(fixed_params_bytes, dtype=np.float64) + transform_json["fixedParameters"] = fixed_parameters + + # Read parameters + params_path = test_input_transform_dir / "data" / "0" / "parameters.raw" + with open(params_path, "rb") as f: + params_bytes = f.read() + parameters = np.frombuffer(params_bytes, dtype=np.float64) + transform_json["parameters"] = parameters + + # Convert to Transform objects + transform_list = [Transform(**t) for t in transform_list_json] + return transform_list + + +def read_composite_transform(): + """Read the CompositeTransform.iwt test data.""" + test_input_transform_dir = test_input_dir / "CompositeTransform.iwt" + + # Read the JSON metadata + with open(test_input_transform_dir / "index.json", "r") as f: + transform_list_json = json.load(f) + + # Process each transform that has actual data files + for i, transform_json in enumerate(transform_list_json): + # Skip the composite transform (index 0) as it doesn't have separate data files + if transform_json["transformType"]["transformParameterization"] == "Composite": + continue + + # Determine the data directory index (1 for Rigid2D, 2 for Affine) + data_index = "1" if transform_json["transformType"]["transformParameterization"] == "Rigid2D" else "2" + + # Read fixed parameters + fixed_params_path = test_input_transform_dir / "data" / data_index / "fixed-parameters.raw" + with open(fixed_params_path, "rb") as f: + fixed_params_bytes = f.read() + fixed_parameters = np.frombuffer(fixed_params_bytes, dtype=np.float32) + transform_json["fixedParameters"] = fixed_parameters + + # Read parameters + params_path = test_input_transform_dir / "data" / data_index / "parameters.raw" + with open(params_path, "rb") as f: + params_bytes = f.read() + parameters = np.frombuffer(params_bytes, dtype=np.float32) + transform_json["parameters"] = parameters + + # Convert to Transform objects + transform_list = [Transform(**t) for t in transform_list_json] + return transform_list + + +def test_pipeline_write_read_linear_transform(): + """Test writing and reading a single linear transform via memory io.""" + pipeline = Pipeline(test_input_dir / "transform-read-write-test.wasi.wasm") + + transform_list = read_linear_transform() + + def verify_transform(transform_list): + assert len(transform_list) == 1 + transform = transform_list[0] + assert transform.transformType.transformParameterization == TransformParameterizations.Affine + assert transform.transformType.parametersValueType == FloatTypes.Float64 + assert transform.transformType.inputDimension == 3 + assert transform.transformType.outputDimension == 3 + assert transform.numberOfFixedParameters == 3 + assert len(transform.fixedParameters) == 3 + assert transform.numberOfParameters == 12 + assert len(transform.parameters) == 12 + + pipeline_inputs = [ + PipelineInput(InterfaceTypes.TransformList, transform_list), + ] + + pipeline_outputs = [ + PipelineOutput(InterfaceTypes.TransformList), + ] + + args = ["--memory-io", "0", "0"] + + outputs = pipeline.run(args, pipeline_outputs, pipeline_inputs) + verify_transform(outputs[0].data) + + +def test_pipeline_write_read_composite_transform(): + """Test writing and reading a composite transform via memory io.""" + pipeline = Pipeline(test_input_dir / "transform-read-write-composite-test.wasi.wasm") + + transform_list = read_composite_transform() + + def verify_transform(transform_list): + assert len(transform_list) == 3, "should have composite + 2 component transforms" + + # First transform should be the composite + composite_transform = transform_list[0] + assert composite_transform.transformType.transformParameterization == TransformParameterizations.Composite + assert composite_transform.transformType.parametersValueType == FloatTypes.Float32 + assert composite_transform.transformType.inputDimension == 2 + assert composite_transform.transformType.outputDimension == 2 + assert composite_transform.numberOfFixedParameters == 4 + assert composite_transform.numberOfParameters == 9 + + # Second transform should be Rigid2D + rigid_transform = transform_list[1] + assert rigid_transform.transformType.transformParameterization == TransformParameterizations.Rigid2D + assert rigid_transform.transformType.parametersValueType == FloatTypes.Float32 + assert rigid_transform.transformType.inputDimension == 2 + assert rigid_transform.transformType.outputDimension == 2 + assert rigid_transform.numberOfFixedParameters == 2 + assert rigid_transform.numberOfParameters == 3 + assert len(rigid_transform.fixedParameters) == 2 + assert len(rigid_transform.parameters) == 3 + + # Third transform should be Affine + affine_transform = transform_list[2] + assert affine_transform.transformType.transformParameterization == TransformParameterizations.Affine + assert affine_transform.transformType.parametersValueType == FloatTypes.Float32 + assert affine_transform.transformType.inputDimension == 2 + assert affine_transform.transformType.outputDimension == 2 + assert affine_transform.numberOfFixedParameters == 2 + assert affine_transform.numberOfParameters == 6 + assert len(affine_transform.fixedParameters) == 2 + assert len(affine_transform.parameters) == 6 + + pipeline_inputs = [ + PipelineInput(InterfaceTypes.TransformList, transform_list), + ] + + pipeline_outputs = [ + PipelineOutput(InterfaceTypes.TransformList), + ] + + args = ["--memory-io", "0", "0"] + + outputs = pipeline.run(args, pipeline_outputs, pipeline_inputs) + verify_transform(outputs[0].data) \ No newline at end of file