Repository: InsightSoftwareConsortium/ITK-Wasm (language: python)

Issue:
Python web demo output types



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 4d4e978fc..0994ea538 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,6 +43,8 @@ packages/compress-stringify/typescript/demo-app
 packages/dicom/wasi-build/
 packages/dicom/typescript/package-lock.json
 packages/dicom/python/itkwasm-dicom-emscripten/test/test_itkwasm_dicom.py
+packages/compare-images/emscripten-build/
+packages/compare-images/wasi-build/
 
 node_modules
 .DS_Store
@@ -75,7 +77,7 @@ test/pipelines/input-output-json-pipeline/emscripten-build/
 test/pipelines/input-output-json-pipeline/wasi-build/
 test/pipelines/bindgen-interface-types-pipeline/python/
 test/pipelines/bindgen-interface-types-pipeline/wasi-build/
-test/pipelines/bindgen-interface-types-pipeline/python-web-demo
+test/pipelines/bindgen-interface-types-pipeline/python-web-demo/
 
 cypress/screenshots/
 cypress/videos/
diff --git a/docs/introduction/file_formats/index.md b/docs/introduction/file_formats/index.md
index c2ae78e3d..7666a2b8a 100644
--- a/docs/introduction/file_formats/index.md
+++ b/docs/introduction/file_formats/index.md
@@ -1,18 +1,22 @@
 # File Formats
 
-While IO modules are available to work with a number of [scientific image](./images) and [mesh](./meshes) file formats, itk-wasm execution pipeline WebAssembly modules only support the itk-wasm file formats by default. This ensures that size of the WebAssembly pipeline binary is minimal.
+Support for data serialized in file formats are a critical bridge that facilitate comprehensive analysis with a suite of diverse software tools.
 
-## itk-wasm file formats
+ITK-Wasm provides IO modules to work with a number of open standard [scientific image](./images) and [mesh](./meshes) file formats, These IO modules can be used to load data into language-native [interfaces types](/typescript/interface_types/index) in with bindings such as TypeScript and Python.
 
-itk-wasm provides its own file formats. itk-wasm file formats are a performant, one-to-one mapping to the spatial [interface types](/typescript/interface_types/index) in a simple JSON + binary array buffer format.
+## ITK-Wasm file formats
 
-The formats can be output in a directory or bundled in a single `.cbor` file.
+ITK-Wasm provides its own file formats. ITK-Wasm file formats are a performant, one-to-one mapping to the spatial [interface types](/typescript/interface_types/index) in a simple JSON + binary array buffer format.
 
-The itk-wasm file formats are available in the itk-wasm IO functions, but also in C++ via the *WebAssemblyInterface* ITK module and through native binary Python bindings via the *itk-webassemblyinterface* Python package.
+Execution pipeline WebAssembly modules only support ITK-Wasm formats by default -- this ensures that size of the WebAssembly pipeline binary is minimal. When using ITK-Wasm pipelines on with the command line interface, wasm modules from the image-io and mesh-io packages can transform data in other formats into the format supported by all ITK-Wasm modules.
+
+ITK-Wasm formats can be output in a directory or bundled in a single `.cbor` file. The [Concise Binary Object Representation (CBOR)](https://cbor.io/) format supports JSON + binary array data in the data schema, is extremely small and lightweight in implementations, has support across programming languages, is highly performant, and provides a [link to Web3 storage mechanisms](https://ipld.io/docs/codecs/known/dag-cbor/).
+
+ITK-Wasm file formats are available in ITK-Wasm IO functions but also in C++ via the *WebAssemblyInterface* ITK module. This module can be enabled in an ITK build by setting the `-DModule_WebAssemblyInterface:BOOL=ON` flag in CMake. And, loading and conversion is also available native-binary Python bindings via the [*itk-webassemblyinterface* Python package](https://pypi.org/project/itk-webassemblyinterface/).
 
 <dl>
-  <dt><b>ITK Wasm Image (.iwi, .iwi.cbor)</b><dt><dd>Serialization of an <a href="/typescript/interface_types/Image.html"><code>Image</code></a>.</dd>
-  <dt><b>ITK Wasm Mesh (.iwm, .iwm.cbor)</b><dt><dd>Serialization of a <a href="/typescript/interface_types/Mesh.html"><code>Mesh</code></a>, or <a href="/typescript/interface_types/PolyData.html"><code>PolyData</code></a>.</dd>
+  <dt><b>ITK-Wasm Image (.iwi, .iwi.cbor)</b><dt><dd>Serialization of an <a href="/typescript/interface_types/Image.html"><code>Image</code></a>.</dd>
+  <dt><b>ITK-Wasm Mesh (.iwm, .iwm.cbor)</b><dt><dd>Serialization of a <a href="/typescript/interface_types/Mesh.html"><code>Mesh</code></a>, or <a href="/typescript/interface_types/PolyData.html"><code>PolyData</code></a>.</dd>
 </dl>
 
 
diff --git a/package.json b/package.json
index 0930c8e7e..6bb8c4e0e 100644
--- a/package.json
+++ b/package.json
@@ -42,11 +42,15 @@
     "build:emscripten:dicom": "node ./src/itk-wasm-cli.js -s packages/dicom -b emscripten-build build",
     "build:bindgen:typescript:dicom": "./src/itk-wasm-cli.js -s packages/dicom -b emscripten-build bindgen --package-version 3.2.0 --package-name @itk-wasm/dicom --package-description \"Read files and images related to DICOM file format.\" --repository 'https://github.com/InsightSoftwareConsortium/itk-wasm'",
     "build:bindgen:python:dicom": "./src/itk-wasm-cli.js -s packages/dicom -b wasi-build bindgen --package-version 3.2.0 --interface python --package-name itkwasm-dicom --package-description \"Read files and images related to DICOM file format.\" --repository 'https://github.com/InsightSoftwareConsortium/itk-wasm'",
-    "build:emscripten:packages": "npm run build:emscripten:compress-stringify && npm run build:bindgen:typescript:compress-stringify && npm run build:emscripten:dicom && npm run build:bindgen:typescript:dicom",
+    "build:emscripten:compare-images": "node ./src/itk-wasm-cli.js -s packages/compare-images -b emscripten-build build",
+    "build:bindgen:typescript:compare-images": "./src/itk-wasm-cli.js -s packages/compare-images -b emscripten-build bindgen --package-version 1.0.0 --package-name @itk-wasm/compare-images --package-description \"Compare images with a tolerance for regression testing.\" --repository 'https://github.com/InsightSoftwareConsortium/itk-wasm'",
+    "build:bindgen:python:compare-images": "./src/itk-wasm-cli.js -s packages/compare-images -b wasi-build bindgen --package-version 1.0.0 --interface python --package-name itkwasm-compare-images --package-description \"Compare images with a tolerance for regression testing.\" --repository 'https://github.com/InsightSoftwareConsortium/itk-wasm'",
+    "build:emscripten:packages": "npm run build:emscripten:compress-stringify && npm run build:bindgen:typescript:compress-stringify && npm run build:emscripten:dicom && npm run build:bindgen:typescript:dicom && npm run build:emscripten:compare-images && npm run build:bindgen:typescript:compare-images",
     "build:wasi": "node ./src/build-wasi.js && npm run build:wasi:packages",
     "build:wasi:compress-stringify": "node ./src/itk-wasm-cli.js -i itkwasm/wasi:latest -s packages/compress-stringify -b wasi-build build",
     "build:wasi:dicom": "node ./src/itk-wasm-cli.js -i itkwasm/wasi:latest -s packages/dicom -b wasi-build build",
-    "build:wasi:packages": "npm run build:wasi:compress-stringify && npm run build:bindgen:python:compress-stringify && npm run build:wasi:dicom && npm run build:bindgen:python:dicom",
+    "build:wasi:compare-images": "node ./src/itk-wasm-cli.js -i itkwasm/wasi:latest -s packages/compare-images -b wasi-build build",
+    "build:wasi:packages": "npm run build:wasi:compress-stringify && npm run build:bindgen:python:compress-stringify && npm run build:wasi:compare-images && npm run build:bindgen:python:compare-images && npm run build:wasi:dicom && npm run build:bindgen:python:dicom",
     "cypress:open": "npx cypress open",
     "cypress:run": "npx cypress run --config defaultCommandTimeout=8000",
     "cypress:install": "npx cypress install",
diff --git a/packages/compare-images/.gitignore b/packages/compare-images/.gitignore
new file mode 100644
index 000000000..298b39742
--- /dev/null
+++ b/packages/compare-images/.gitignore
@@ -0,0 +1,4 @@
+typescript/demo-app/
+dist/
+package-lock.json
+typescript/test/browser/demo-app/public
diff --git a/packages/compare-images/CMakeLists.txt b/packages/compare-images/CMakeLists.txt
new file mode 100644
index 000000000..45f69adde
--- /dev/null
+++ b/packages/compare-images/CMakeLists.txt
@@ -0,0 +1,48 @@
+cmake_minimum_required(VERSION 3.16)
+project(itkwasm-compare-images LANGUAGES CXX)
+
+set(CMAKE_CXX_STANDARD 17)
+
+if(EMSCRIPTEN)
+  set(io_components
+    )
+elseif(WASI)
+  set(io_components
+    ITKIOPNG
+    )
+else()
+  set(io_components
+    ITKImageIO
+    )
+endif()
+
+find_package(ITK REQUIRED
+ COMPONENTS
+   WebAssemblyInterface
+   ITKImageIntensity
+   ITKTestKernel
+   ${io_components}
+ )
+include(${ITK_USE_FILE})
+
+add_executable(compare-double-images compare-double-images.cxx)
+target_link_libraries(compare-double-images PUBLIC ${ITK_LIBRARIES})
+
+add_executable(vector-magnitude vector-magnitude.cxx)
+target_link_libraries(vector-magnitude PUBLIC ${ITK_LIBRARIES})
+
+enable_testing()
+add_test(NAME compare-double-images
+  COMMAND compare-double-images
+    ${CMAKE_CURRENT_SOURCE_DIR}/test/data/input/cake_easy.iwi.cbor
+    ${CMAKE_CURRENT_BINARY_DIR}/metrics.json
+    ${CMAKE_CURRENT_BINARY_DIR}/cake_diff_double.iwi.cbor
+    ${CMAKE_CURRENT_BINARY_DIR}/cake_diff.png
+    --baseline-images ${CMAKE_CURRENT_SOURCE_DIR}/test/data/input/cake_hard.iwi.cbor
+    )
+
+add_test(NAME vector-magnitude
+  COMMAND vector-magnitude
+    ${CMAKE_CURRENT_SOURCE_DIR}/test/data/input/apple.iwi.cbor
+    ${CMAKE_CURRENT_BINARY_DIR}/apply_magnitude.iwi.cbor
+    )
\ No newline at end of file
diff --git a/packages/compare-images/compare-double-images.cxx b/packages/compare-images/compare-double-images.cxx
new file mode 100644
index 000000000..624a48926
--- /dev/null
+++ b/packages/compare-images/compare-double-images.cxx
@@ -0,0 +1,225 @@
+/*=========================================================================
+
+ *  Copyright NumFOCUS
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *         https://www.apache.org/licenses/LICENSE-2.0.txt
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ *=========================================================================*/
+
+#include <vector>
+
+#include "itkPipeline.h"
+#include "itkInputImage.h"
+#include "itkOutputImage.h"
+#include "itkOutputTextStream.h"
+#include "itkSupportInputImageTypes.h"
+
+#include "itkImage.h"
+#include "itkRescaleIntensityImageFilter.h"
+#include "itkExtractImageFilter.h"
+#include "itkTestingComparisonImageFilter.h"
+
+#include "rapidjson/document.h"
+#include "rapidjson/stringbuffer.h"
+#include "rapidjson/writer.h"
+
+template<typename TImage>
+int
+CompareImages(itk::wasm::Pipeline & pipeline, const TImage * testImage)
+{
+  using ImageType = TImage;
+
+  pipeline.get_option("test-image")->required()->type_name("INPUT_IMAGE");
+
+  using InputImageType = itk::wasm::InputImage<ImageType>;
+  std::vector<InputImageType> baselineImages;
+  pipeline.add_option("-b,--baseline-images", baselineImages, "Baseline images compare against")->required()->expected(1,-1)->type_name("INPUT_IMAGE");
+
+  itk::wasm::OutputTextStream metrics;
+  pipeline.add_option("metrics", metrics, "Metrics for the baseline with the fewest number of pixels outside the tolerances.")->type_name("OUTPUT_JSON");
+
+  using OutputImageType = itk::wasm::OutputImage<ImageType>;
+  OutputImageType differenceImage;
+  pipeline.add_option("difference-image", differenceImage, "Absolute difference image")->type_name("OUTPUT_IMAGE");
+
+  using Image2DType = itk::Image<double, 2>;
+  using Uchar2DImageType = itk::Image<unsigned char, 2>;
+  using OutputUchar2DImageType = itk::wasm::OutputImage<Uchar2DImageType>;
+  OutputUchar2DImageType differenceUchar2DImage;
+  pipeline.add_option("difference-uchar-2d-image", differenceUchar2DImage, "Unsigned char, 2D difference image for rendering")->type_name("OUTPUT_IMAGE");
+
+  double differenceThreshold = 0.0;
+  pipeline.add_option("-d,--difference-threshold", differenceThreshold, "Intensity difference for pixels to be considered different.");
+
+  unsigned int radiusTolerance = 0;
+  pipeline.add_option("-r,--radius-tolerance", radiusTolerance, "Radius of the neighborhood around a pixel to search for similar intensity values.");
+
+  uint64_t numberOfPixelsTolerance = 0;
+  pipeline.add_option("-n,--number-of-pixels-tolerance", numberOfPixelsTolerance, "Number of pixels that can be different before the test fails.");
+
+  bool ignoreBoundaryPixels = false;
+  pipeline.add_flag("-i,--ignore-boundary-pixels", ignoreBoundaryPixels, "Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge.");
+
+  ITK_WASM_PARSE(pipeline);
+
+  using DiffType = itk::Testing::ComparisonImageFilter<ImageType, ImageType>;
+  auto diff = DiffType::New();
+  diff->SetValidInput(testImage);
+
+  diff->SetDifferenceThreshold(differenceThreshold);
+  diff->SetToleranceRadius(radiusTolerance);
+  diff->SetIgnoreBoundaryPixels(ignoreBoundaryPixels);
+
+  double minimumDifference = itk::NumericTraits<double>::max();
+  double maximumDifference = itk::NumericTraits<double>::NonpositiveMin();
+  double totalDifference = 0.0;
+  double meanDifference = 0.0;
+  uint64_t numberOfPixelsWithDifferences = itk::NumericTraits<uint64_t>::max();
+
+  size_t bestBaselineIndex = 0;
+  size_t baselineCount = 0;
+  for (auto baselineImage : baselineImages)
+  {
+    diff->SetTestInput(baselineImage.Get());
+    ITK_WASM_CATCH_EXCEPTION(pipeline, diff->UpdateLargestPossibleRegion());
+
+    if (diff->GetNumberOfPixelsWithDifferences() <= numberOfPixelsWithDifferences)
+    {
+      minimumDifference = diff->GetMinimumDifference();
+      maximumDifference = diff->GetMaximumDifference();
+      totalDifference = diff->GetTotalDifference();
+      meanDifference = diff->GetMeanDifference();
+      numberOfPixelsWithDifferences = diff->GetNumberOfPixelsWithDifferences();
+      bestBaselineIndex = baselineCount;
+    }
+    ++baselineCount;
+  }
+
+  if (bestBaselineIndex != baselineCount)
+  {
+    diff->SetTestInput(baselineImages[bestBaselineIndex].Get());
+    ITK_WASM_CATCH_EXCEPTION(pipeline, diff->UpdateLargestPossibleRegion());
+
+    minimumDifference = diff->GetMinimumDifference();
+    maximumDifference = diff->GetMaximumDifference();
+    totalDifference = diff->GetTotalDifference();
+    meanDifference = diff->GetMeanDifference();
+  }
+
+  const bool almostEqual = (numberOfPixelsWithDifferences <= numberOfPixelsTolerance);
+
+  rapidjson::Document metricsJson;
+  metricsJson.SetObject();
+  rapidjson::Document::AllocatorType& allocator = metricsJson.GetAllocator();
+
+  rapidjson::Value almostEqualValue;
+  almostEqualValue.SetBool(almostEqual);
+  metricsJson.AddMember("almostEqual", almostEqualValue, allocator);
+
+  rapidjson::Value numberOfPixelsWithDifferencesValue;
+  numberOfPixelsWithDifferencesValue.SetUint64(numberOfPixelsWithDifferences);
+  metricsJson.AddMember("numberOfPixelsWithDifferences", numberOfPixelsWithDifferencesValue, allocator);
+
+  rapidjson::Value minimumDifferenceValue;
+  minimumDifferenceValue.SetDouble(minimumDifference);
+  metricsJson.AddMember("minimumDifference", minimumDifferenceValue, allocator);
+
+  rapidjson::Value maximumDifferenceValue;
+  maximumDifferenceValue.SetDouble(maximumDifference);
+  metricsJson.AddMember("maximumDifference", maximumDifferenceValue, allocator);
+
+  rapidjson::Value totalDifferenceValue;
+  totalDifferenceValue.SetDouble(totalDifference);
+  metricsJson.AddMember("totalDifference", totalDifferenceValue, allocator);
+
+  rapidjson::Value meanDifferenceValue;
+  meanDifferenceValue.SetDouble(meanDifference);
+  metricsJson.AddMember("meanDifference", meanDifferenceValue, allocator);
+
+  rapidjson::StringBuffer stringBuffer;
+  rapidjson::Writer<rapidjson::StringBuffer> writer(stringBuffer);
+  metricsJson.Accept(writer);
+
+  metrics.Get() << stringBuffer.GetString();
+
+  typename ImageType::ConstPointer difference = diff->GetOutput();
+  differenceImage.Set(difference);
+
+  using ExtractType = itk::ExtractImageFilter<ImageType, Image2DType>;
+  using RescaleType = itk::RescaleIntensityImageFilter<Image2DType, Uchar2DImageType>;
+
+  using RegionType = typename ImageType::RegionType;
+  RegionType region;
+  typename ImageType::IndexType index;
+  index.Fill(0);
+  typename ImageType::SizeType size;
+  size.Fill(0);
+
+  size = diff->GetOutput()->GetLargestPossibleRegion().GetSize();
+  for (unsigned int i = 2; i < ImageType::ImageDimension; ++i)
+  {
+    index[i] = size[i] / 2;
+    size[i] = 0;
+  }
+  region.SetIndex(index);
+  region.SetSize(size);
+
+  auto extract = ExtractType::New();
+  extract->SetDirectionCollapseToSubmatrix();
+
+  extract->SetInput(diff->GetOutput());
+  extract->SetExtractionRegion(region);
+
+  auto rescale = RescaleType::New();
+
+  const unsigned char nonPositiveMin = itk::NumericTraits<unsigned char>::NonpositiveMin();
+  rescale->SetOutputMinimum(nonPositiveMin);
+  const unsigned char unsignedCharMax = itk::NumericTraits<unsigned char>::max();
+  rescale->SetOutputMaximum(unsignedCharMax);
+  rescale->SetInput(extract->GetOutput());
+  ITK_WASM_CATCH_EXCEPTION(pipeline, rescale->UpdateLargestPossibleRegion());
+
+  typename Uchar2DImageType::ConstPointer rescaled = rescale->GetOutput();
+  differenceUchar2DImage.Set(rescaled);
+
+  return EXIT_SUCCESS;
+}
+
+template<typename TImage>
+class PipelineFunctor
+{
+public:
+  int operator()(itk::wasm::Pipeline & pipeline)
+  {
+    using ImageType = TImage;
+
+    using InputImageType = itk::wasm::InputImage<ImageType>;
+    InputImageType testImage;
+    pipeline.add_option("test-image", testImage, "The input test image");
+
+    ITK_WASM_PRE_PARSE(pipeline);
+
+    typename ImageType::ConstPointer image = testImage.Get();
+    return CompareImages<ImageType>(pipeline, image);
+  }
+};
+
+int main(int argc, char * argv[])
+{
+  itk::wasm::Pipeline pipeline("compare-double-images", "Compare double pixel type images with a tolerance for regression testing.", argc, argv);
+
+  return itk::wasm::SupportInputImageTypes<PipelineFunctor,
+
+   double>
+  ::Dimensions<2U,3U,4U,5U,6U>("test-image", pipeline);
+}
\ No newline at end of file
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/README.md b/packages/compare-images/python/itkwasm-compare-images-emscripten/README.md
new file mode 100644
index 000000000..33c7afdc5
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/README.md
@@ -0,0 +1,23 @@
+# itkwasm-compare-images-emscripten
+
+[![PyPI version](https://badge.fury.io/py/itkwasm-compare-images-emscripten.svg)](https://badge.fury.io/py/itkwasm-compare-images-emscripten)
+
+Compare images with a tolerance for regression testing. Emscripten implementation.
+
+This package provides the Emscripten WebAssembly implementation. It is usually not called directly. Please use the [`itkwasm-compare-images`](https://pypi.org/project/itkwasm-compare-images/) instead.
+
+
+## Installation
+
+```sh
+import micropip
+await micropip.install('itkwasm-compare-images-emscripten')
+```
+
+## Development
+
+```sh
+pip install hatch
+hatch run download-pyodide
+hatch run test
+```
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/__init__.py b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/__init__.py
new file mode 100644
index 000000000..3aae2a8eb
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/__init__.py
@@ -0,0 +1,7 @@
+"""itkwasm-compare-images-emscripten: Compare images with a tolerance for regression testing. Emscripten implementation."""
+
+from .compare_double_images_async import compare_double_images_async
+from .compare_images_async import compare_images_async
+from .vector_magnitude_async import vector_magnitude_async
+
+from ._version import __version__
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/_version.py b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/_version.py
new file mode 100644
index 000000000..5becc17c0
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/_version.py
@@ -0,0 +1,1 @@
+__version__ = "1.0.0"
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/compare_double_images_async.py b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/compare_double_images_async.py
new file mode 100644
index 000000000..0d33c437a
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/compare_double_images_async.py
@@ -0,0 +1,84 @@
+from pathlib import Path
+import os
+from typing import Dict, Tuple, Optional, List
+
+from .js_package import js_package
+
+from itkwasm.pyodide import (
+    to_js,
+    to_py,
+    js_resources
+)
+from itkwasm import (
+    InterfaceTypes,
+    Image,
+)
+
+async def compare_double_images_async(
+    test_image: Image,
+    baseline_images: List[Image] = [],
+    difference_threshold: float = 0,
+    radius_tolerance: int = 0,
+    number_of_pixels_tolerance: int = 0,
+    ignore_boundary_pixels: bool = False,
+) -> Tuple[Dict, Image, Image]:
+    """Compare double pixel type images with a tolerance for regression testing.
+
+    :param test_image: The input test image
+    :type  test_image: Image
+
+    :param baseline_images: Baseline images compare against
+    :type  baseline_images: Image
+
+    :param difference_threshold: Intensity difference for pixels to be considered different.
+    :type  difference_threshold: float
+
+    :param radius_tolerance: Radius of the neighborhood around a pixel to search for similar intensity values.
+    :type  radius_tolerance: int
+
+    :param number_of_pixels_tolerance: Number of pixels that can be different before the test fails.
+    :type  number_of_pixels_tolerance: int
+
+    :param ignore_boundary_pixels: Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge.
+    :type  ignore_boundary_pixels: bool
+
+    :return: Metrics for the baseline with the fewest number of pixels outside the tolerances.
+    :rtype:  Dict
+
+    :return: Absolute difference image
+    :rtype:  Image
+
+    :return: Unsigned char, 2D difference image for rendering
+    :rtype:  Image
+    """
+    js_module = await js_package.js_module
+    web_worker = js_resources.web_worker
+
+    kwargs = {}
+    if baseline_images is not None:
+        kwargs["baselineImages"] = to_js(baseline_images)
+    if difference_threshold:
+        kwargs["differenceThreshold"] = to_js(difference_threshold)
+    if radius_tolerance:
+        kwargs["radiusTolerance"] = to_js(radius_tolerance)
+    if number_of_pixels_tolerance:
+        kwargs["numberOfPixelsTolerance"] = to_js(number_of_pixels_tolerance)
+    if ignore_boundary_pixels:
+        kwargs["ignoreBoundaryPixels"] = to_js(ignore_boundary_pixels)
+
+    outputs = await js_module.compareDoubleImages(web_worker, to_js(test_image), **kwargs)
+
+    output_web_worker = None
+    output_list = []
+    outputs_object_map = outputs.as_object_map()
+    for output_name in outputs.object_keys():
+        if output_name == 'webWorker':
+            output_web_worker = outputs_object_map[output_name]
+        else:
+            output_list.append(to_py(outputs_object_map[output_name]))
+
+    js_resources.web_worker = output_web_worker
+
+    if len(output_list) == 1:
+        return output_list[0]
+    return tuple(output_list)
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/compare_images_async.py b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/compare_images_async.py
new file mode 100644
index 000000000..afcb75cd4
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/compare_images_async.py
@@ -0,0 +1,84 @@
+from pathlib import Path
+import os
+from typing import Dict, Tuple, Optional, List
+
+from .js_package import js_package
+
+from itkwasm.pyodide import (
+    to_js,
+    to_py,
+    js_resources
+)
+from itkwasm import (
+    InterfaceTypes,
+    Image,
+)
+
+async def compare_images_async(
+    test_image: Image,
+    baseline_images: List[Image] = [],
+    difference_threshold: float = 0,
+    radius_tolerance: int = 0,
+    number_of_pixels_tolerance: int = 0,
+    ignore_boundary_pixels: bool = False,
+) -> Tuple[Dict, Image, Image]:
+    """Compare double pixel type images with a tolerance for regression testing.
+
+    :param test_image: The input test image
+    :type  test_image: Image
+
+    :param baseline_images: Baseline images compare against
+    :type  baseline_images: Image
+
+    :param difference_threshold: Intensity difference for pixels to be considered different.
+    :type  difference_threshold: float
+
+    :param radius_tolerance: Radius of the neighborhood around a pixel to search for similar intensity values.
+    :type  radius_tolerance: int
+
+    :param number_of_pixels_tolerance: Number of pixels that can be different before the test fails.
+    :type  number_of_pixels_tolerance: int
+
+    :param ignore_boundary_pixels: Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge.
+    :type  ignore_boundary_pixels: bool
+
+    :return: Metrics for the baseline with the fewest number of pixels outside the tolerances.
+    :rtype:  Dict
+
+    :return: Absolute difference image
+    :rtype:  Image
+
+    :return: Unsigned char, 2D difference image for rendering
+    :rtype:  Image
+    """
+    js_module = await js_package.js_module
+    web_worker = js_resources.web_worker
+
+    kwargs = {}
+    if baseline_images is not None:
+        kwargs["baselineImages"] = to_js(baseline_images)
+    if difference_threshold:
+        kwargs["differenceThreshold"] = to_js(difference_threshold)
+    if radius_tolerance:
+        kwargs["radiusTolerance"] = to_js(radius_tolerance)
+    if number_of_pixels_tolerance:
+        kwargs["numberOfPixelsTolerance"] = to_js(number_of_pixels_tolerance)
+    if ignore_boundary_pixels:
+        kwargs["ignoreBoundaryPixels"] = to_js(ignore_boundary_pixels)
+
+    outputs = await js_module.compareImages(web_worker, to_js(test_image), **kwargs)
+
+    output_web_worker = None
+    output_list = []
+    outputs_object_map = outputs.as_object_map()
+    for output_name in outputs.object_keys():
+        if output_name == 'webWorker':
+            output_web_worker = outputs_object_map[output_name]
+        else:
+            output_list.append(to_py(outputs_object_map[output_name]))
+
+    js_resources.web_worker = output_web_worker
+
+    if len(output_list) == 1:
+        return output_list[0]
+    return tuple(output_list)
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/js_package.py b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/js_package.py
new file mode 100644
index 000000000..a940ad6cd
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/js_package.py
@@ -0,0 +1,6 @@
+from itkwasm.pyodide import JsPackageConfig, JsPackage
+
+from ._version import __version__
+
+default_config = JsPackageConfig(f"https://cdn.jsdelivr.net/npm/@itk-wasm/compare-images@{__version__}/dist/bundles/compare-images.js")
+js_package = JsPackage(default_config)
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/vector_magnitude_async.py b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/vector_magnitude_async.py
new file mode 100644
index 000000000..13376d8cd
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/itkwasm_compare_images_emscripten/vector_magnitude_async.py
@@ -0,0 +1,52 @@
+# Generated file. To retain edits, remove this comment.
+
+# Generated file. Do not edit.
+
+from pathlib import Path
+import os
+from typing import Dict, Tuple, Optional, List, Any
+
+from .js_package import js_package
+
+from itkwasm.pyodide import (
+    to_js,
+    to_py,
+    js_resources
+)
+from itkwasm import (
+    InterfaceTypes,
+    Image,
+)
+
+async def vector_magnitude_async(
+    vector_image: Image,
+) -> Image:
+    """Generate a scalar magnitude image based on the input vector's norm.
+
+    :param vector_image: Input vector image
+    :type  vector_image: Image
+
+    :return: Output magnitude image
+    :rtype:  Image
+    """
+    js_module = await js_package.js_module
+    web_worker = js_resources.web_worker
+
+    kwargs = {}
+
+    outputs = await js_module.vectorMagnitude(web_worker, to_js(vector_image), **kwargs)
+
+    output_web_worker = None
+    output_list = []
+    outputs_object_map = outputs.as_object_map()
+    for output_name in outputs.object_keys():
+        if output_name == 'webWorker':
+            output_web_worker = outputs_object_map[output_name]
+        else:
+            output_list.append(to_py(outputs_object_map[output_name]))
+
+    js_resources.web_worker = output_web_worker
+
+    if len(output_list) == 1:
+        return output_list[0]
+    return tuple(output_list)
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/pyproject.toml b/packages/compare-images/python/itkwasm-compare-images-emscripten/pyproject.toml
new file mode 100644
index 000000000..b298c1268
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/pyproject.toml
@@ -0,0 +1,74 @@
+[build-system]
+requires = ["hatchling", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+[project]
+name = "itkwasm-compare-images-emscripten"
+readme = "README.md"
+license = "Apache-2.0"
+dynamic = ["version", "description"]
+classifiers = [
+  "License :: OSI Approved :: Apache Software License",
+  "Programming Language :: Python",
+  "Programming Language :: C++",
+  "Environment :: WebAssembly",
+  "Environment :: WebAssembly :: Emscripten",
+  "Environment :: WebAssembly :: WASI",
+  "Development Status :: 3 - Alpha",
+  "Intended Audience :: Developers",
+  "Intended Audience :: Science/Research",
+  "Programming Language :: Python :: 3",
+  "Programming Language :: Python :: 3.7",
+  "Programming Language :: Python :: 3.8",
+  "Programming Language :: Python :: 3.9",
+  "Programming Language :: Python :: 3.10",
+  "Programming Language :: Python :: 3.11",
+]
+keywords = [
+  "itkwasm",
+  "webassembly",
+  "emscripten",
+]
+
+requires-python = ">=3.7"
+dependencies = [
+    "itkwasm >= 1.0.b130",
+]
+
+[tool.hatch.version]
+path = "itkwasm_compare_images_emscripten/_version.py"
+
+[tool.hatch.envs.default]
+dependencies = [
+  "pytest",
+  "pytest-pyodide",
+  "itk-webassemblyinterface >= 1.0.b127",
+  "itkwasm >= 1.0.b130",
+]
+
+[project.urls]
+Home = "https://github.com/InsightSoftwareConsortium/itk-wasm"
+Source = "https://github.com/InsightSoftwareConsortium/itk-wasm"
+
+[tool.hatch.envs.default.scripts]
+test = [
+  "hatch build -t wheel",
+  "pytest -s --dist-dir=./dist --rt=chrome",
+]
+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",
+  "rm -rf dist pyodide.tar.bz2",
+  "mv pyodide dist",
+]
+serve = [
+  "hatch build -t wheel",
+  'echo "\nVisit http://localhost:8877/console.html\n"',
+  "python -m http.server --directory=./dist 8877",
+]
+
+
+[tool.hatch.build]
+exclude = [
+  "/examples",
+]
diff --git a/packages/compare-images/python/itkwasm-compare-images-wasi/README.md b/packages/compare-images/python/itkwasm-compare-images-wasi/README.md
new file mode 100644
index 000000000..71c438c53
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-wasi/README.md
@@ -0,0 +1,26 @@
+# itkwasm-compare-images-wasi
+
+[![PyPI version](https://badge.fury.io/py/itkwasm-compare-images-wasi.svg)](https://badge.fury.io/py/itkwasm-compare-images-wasi)
+
+Compare images with a tolerance for regression testing. WASI implementation.
+
+This package provides the WASI WebAssembly implementation. It is usually not called directly. Please use [`itkwasm-compare-images`](https://pypi.org/project/itkwasm-compare-images/) instead.
+
+
+## Installation
+
+```sh
+pip install itkwasm-compare-images-wasi
+```
+
+## Development
+
+```sh
+pip install pytest itk-webassemblyinterface>=1.0b127
+pip install -e .
+pytest
+
+# or
+pip install hatch
+hatch run test
+```
diff --git a/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/__init__.py b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/__init__.py
new file mode 100644
index 000000000..ac6410777
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/__init__.py
@@ -0,0 +1,7 @@
+"""itkwasm-compare-images-wasi: Compare images with a tolerance for regression testing. WASI implementation."""
+
+from .compare_images import compare_images
+from .compare_double_images import compare_double_images
+from .vector_magnitude import vector_magnitude
+
+from ._version import __version__
diff --git a/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/_version.py b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/_version.py
new file mode 100644
index 000000000..5becc17c0
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/_version.py
@@ -0,0 +1,1 @@
+__version__ = "1.0.0"
diff --git a/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/compare_double_images.py b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/compare_double_images.py
new file mode 100644
index 000000000..5ba870a52
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/compare_double_images.py
@@ -0,0 +1,109 @@
+from pathlib import Path, PurePosixPath
+import os
+from typing import Dict, Tuple, Optional, List, Any
+
+from importlib_resources import files as file_resources
+
+_pipeline = None
+
+from itkwasm import (
+    InterfaceTypes,
+    PipelineOutput,
+    PipelineInput,
+    Pipeline,
+    Image,
+)
+
+def compare_double_images(
+    test_image: Image,
+    baseline_images: List[Image] = [],
+    difference_threshold: float = 0,
+    radius_tolerance: int = 0,
+    number_of_pixels_tolerance: int = 0,
+    ignore_boundary_pixels: bool = False,
+) -> Tuple[Dict, Image, Image]:
+    """Compare double pixel type images with a tolerance for regression testing.
+
+    :param test_image: The input test image
+    :type  test_image: Image
+
+    :param baseline_images: Baseline images compare against
+    :type  baseline_images: Image
+
+    :param difference_threshold: Intensity difference for pixels to be considered different.
+    :type  difference_threshold: float
+
+    :param radius_tolerance: Radius of the neighborhood around a pixel to search for similar intensity values.
+    :type  radius_tolerance: int
+
+    :param number_of_pixels_tolerance: Number of pixels that can be different before the test fails.
+    :type  number_of_pixels_tolerance: int
+
+    :param ignore_boundary_pixels: Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge.
+    :type  ignore_boundary_pixels: bool
+
+    :return: Metrics for the baseline with the fewest number of pixels outside the tolerances.
+    :rtype:  Dict
+
+    :return: Absolute difference image
+    :rtype:  Image
+
+    :return: Unsigned char, 2D difference image for rendering
+    :rtype:  Image
+    """
+    global _pipeline
+    if _pipeline is None:
+        _pipeline = Pipeline(file_resources('itkwasm_compare_images_wasi').joinpath(Path('wasm_modules') / Path('compare-double-images.wasi.wasm')))
+
+    pipeline_outputs: List[PipelineOutput] = [
+        PipelineOutput(InterfaceTypes.JsonObject),
+        PipelineOutput(InterfaceTypes.Image),
+        PipelineOutput(InterfaceTypes.Image),
+    ]
+
+    pipeline_inputs: List[PipelineInput] = [
+        PipelineInput(InterfaceTypes.Image, test_image),
+    ]
+
+    args: List[str] = ['--memory-io',]
+    # Inputs
+    args.append('0')
+    # Outputs
+    args.append('0')
+    args.append('1')
+    args.append('2')
+    # Options
+    if len(baseline_images) < 1:
+       raise ValueError('"baseline-images" kwarg must have a length > 1')
+    if len(baseline_images) > 0:
+        args.append('--baseline-images')
+        for value in baseline_images:
+            input_count_string = str(len(pipeline_inputs))
+            pipeline_inputs.append(PipelineInput(InterfaceTypes.Image, value))
+            args.append(input_count_string)
+
+    if difference_threshold:
+        args.append('--difference-threshold')
+        args.append(str(difference_threshold))
+
+    if radius_tolerance:
+        args.append('--radius-tolerance')
+        args.append(str(radius_tolerance))
+
+    if number_of_pixels_tolerance:
+        args.append('--number-of-pixels-tolerance')
+        args.append(str(number_of_pixels_tolerance))
+
+    if ignore_boundary_pixels:
+        args.append('--ignore-boundary-pixels')
+
+
+    outputs = _pipeline.run(args, pipeline_outputs, pipeline_inputs)
+
+    result = (
+        outputs[0].data.data,
+        outputs[1].data,
+        outputs[2].data,
+    )
+    return result
+
diff --git a/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/compare_images.py b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/compare_images.py
new file mode 100644
index 000000000..9ec399849
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/compare_images.py
@@ -0,0 +1,73 @@
+from typing import Dict, Tuple, Optional, List, Any
+from .compare_double_images import compare_double_images
+from .vector_magnitude import vector_magnitude
+
+from itkwasm import (
+    Image,
+    FloatTypes,
+    PixelTypes,
+    cast_image,
+)
+
+def _to_scalar_double(image: Image) -> Image:
+    scalar_double = image
+
+    if scalar_double.imageType.componentType != FloatTypes.Float64:
+        pixel_type = None
+        if image.imageType.pixelType != PixelTypes.Scalar and image.imageType.pixelType != PixelTypes.VariableLengthVector:
+            pixel_type = PixelTypes.VariableLengthVector
+        scalar_double = cast_image(image, pixel_type=pixel_type, component_type=FloatTypes.Float64)
+    else:
+        if image.imageType.pixelType != PixelTypes.Scalar and image.imageType.pixelType != PixelTypes.VariableLengthVector:
+            pixel_type = PixelTypes.VariableLengthVector
+            scalar_double = cast_image(image, pixel_type=pixel_type)
+
+    if scalar_double.imageType.pixelType == PixelTypes.VariableLengthVector:
+        scalar_double = vector_magnitude(scalar_double)
+
+    return scalar_double
+
+def compare_images(
+    test_image: Image,
+    baseline_images: List[Image] = [],
+    difference_threshold: float = 0,
+    radius_tolerance: int = 0,
+    number_of_pixels_tolerance: int = 0,
+    ignore_boundary_pixels: bool = False,
+) -> Tuple[Dict, Image, Image]:
+    """Compare double pixel type images with a tolerance for regression testing.
+
+    For multi-component images, the intensity difference threshold
+    is based on the pixel vector magnitude.
+
+    :param test_image: The input test image
+    :type  test_image: Image
+
+    :param baseline_images: Baseline images compare against
+    :type  baseline_images: Image
+
+    :param difference_threshold: Intensity difference for pixels to be considered different.
+    :type  difference_threshold: float
+
+    :param radius_tolerance: Radius of the neighborhood around a pixel to search for similar intensity values.
+    :type  radius_tolerance: int
+
+    :param number_of_pixels_tolerance: Number of pixels that can be different before the test fails.
+    :type  number_of_pixels_tolerance: int
+
+    :param ignore_boundary_pixels: Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge.
+    :type  ignore_boundary_pixels: bool
+
+    :return: Metrics for the baseline with the fewest number of pixels outside the tolerances.
+    :rtype:  Dict
+
+    :return: Absolute difference image
+    :rtype:  Image
+
+    :return: Unsigned char, 2D difference image for rendering
+    :rtype:  Image
+    """
+    test_image_double = _to_scalar_double(test_image)
+    baseline_images_double = [_to_scalar_double(baseline_image) for baseline_image in baseline_images]
+
+    return compare_double_images(test_image_double, baseline_images=baseline_images_double, difference_threshold=difference_threshold, radius_tolerance=radius_tolerance, number_of_pixels_tolerance=number_of_pixels_tolerance, ignore_boundary_pixels=ignore_boundary_pixels) 
diff --git a/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/vector_magnitude.py b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/vector_magnitude.py
new file mode 100644
index 000000000..6fff71007
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/vector_magnitude.py
@@ -0,0 +1,55 @@
+# Generated file. To retain edits, remove this comment.
+
+# Generated file. Do not edit.
+
+from pathlib import Path, PurePosixPath
+import os
+from typing import Dict, Tuple, Optional, List, Any
+
+from importlib_resources import files as file_resources
+
+_pipeline = None
+
+from itkwasm import (
+    InterfaceTypes,
+    PipelineOutput,
+    PipelineInput,
+    Pipeline,
+    Image,
+)
+
+def vector_magnitude(
+    vector_image: Image,
+) -> Image:
+    """Generate a scalar magnitude image based on the input vector's norm.
+
+    :param vector_image: Input vector image
+    :type  vector_image: Image
+
+    :return: Output magnitude image
+    :rtype:  Image
+    """
+    global _pipeline
+    if _pipeline is None:
+        _pipeline = Pipeline(file_resources('itkwasm_compare_images_wasi').joinpath(Path('wasm_modules') / Path('vector-magnitude.wasi.wasm')))
+
+    pipeline_outputs: List[PipelineOutput] = [
+        PipelineOutput(InterfaceTypes.Image),
+    ]
+
+    pipeline_inputs: List[PipelineInput] = [
+        PipelineInput(InterfaceTypes.Image, vector_image),
+    ]
+
+    args: List[str] = ['--memory-io',]
+    # Inputs
+    args.append('0')
+    # Outputs
+    args.append('0')
+    # Options
+
+    outputs = _pipeline.run(args, pipeline_outputs, pipeline_inputs)
+
+    result = outputs[0].data
+    return result
+
diff --git a/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/wasm_modules/compare-double-images.wasi.wasm b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/wasm_modules/compare-double-images.wasi.wasm
new file mode 100755
index 000000000..faa55383d
Binary files /dev/null and b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/wasm_modules/compare-double-images.wasi.wasm differ
diff --git a/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/wasm_modules/vector-magnitude.wasi.wasm b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/wasm_modules/vector-magnitude.wasi.wasm
new file mode 100755
index 000000000..d514fc75e
Binary files /dev/null and b/packages/compare-images/python/itkwasm-compare-images-wasi/itkwasm_compare_images_wasi/wasm_modules/vector-magnitude.wasi.wasm differ
diff --git a/packages/compare-images/python/itkwasm-compare-images-wasi/pyproject.toml b/packages/compare-images/python/itkwasm-compare-images-wasi/pyproject.toml
new file mode 100644
index 000000000..dc5db0930
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-wasi/pyproject.toml
@@ -0,0 +1,60 @@
+[build-system]
+requires = ["hatchling", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+[project]
+name = "itkwasm-compare-images-wasi"
+readme = "README.md"
+license = "Apache-2.0"
+dynamic = ["version", "description"]
+classifiers = [
+  "License :: OSI Approved :: Apache Software License",
+  "Programming Language :: Python",
+  "Programming Language :: C++",
+  "Environment :: WebAssembly",
+  "Environment :: WebAssembly :: Emscripten",
+  "Environment :: WebAssembly :: WASI",
+  "Development Status :: 3 - Alpha",
+  "Intended Audience :: Developers",
+  "Intended Audience :: Science/Research",
+  "Programming Language :: Python :: 3",
+  "Programming Language :: Python :: 3.7",
+  "Programming Language :: Python :: 3.8",
+  "Programming Language :: Python :: 3.9",
+  "Programming Language :: Python :: 3.10",
+  "Programming Language :: Python :: 3.11",
+]
+keywords = [
+  "itkwasm",
+  "webassembly",
+  "wasi",
+]
+
+requires-python = ">=3.7"
+dependencies = [
+    "itkwasm >= 1.0.b130",
+    "importlib_resources",
+
+]
+
+[tool.hatch.version]
+path = "itkwasm_compare_images_wasi/_version.py"
+
+[tool.hatch.envs.default]
+dependencies = [
+  "pytest",
+  "itk-webassemblyinterface >= 1.0.b127"
+]
+
+[project.urls]
+Home = "https://github.com/InsightSoftwareConsortium/itk-wasm"
+Source = "https://github.com/InsightSoftwareConsortium/itk-wasm"
+
+[tool.hatch.envs.default.scripts]
+test = "pytest"
+
+
+[tool.hatch.build]
+exclude = [
+  "/examples",
+]
diff --git a/packages/compare-images/python/itkwasm-compare-images/README.md b/packages/compare-images/python/itkwasm-compare-images/README.md
new file mode 100644
index 000000000..96440504a
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/README.md
@@ -0,0 +1,11 @@
+# itkwasm-compare-images
+
+[![PyPI version](https://badge.fury.io/py/itkwasm-compare-images.svg)](https://badge.fury.io/py/itkwasm-compare-images)
+
+Compare images with a tolerance for regression testing.
+
+## Installation
+
+```sh
+pip install itkwasm-compare-images
+```
diff --git a/packages/compare-images/python/itkwasm-compare-images/docs/Makefile b/packages/compare-images/python/itkwasm-compare-images/docs/Makefile
new file mode 100644
index 000000000..d4bb2cbb9
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS    ?=
+SPHINXBUILD   ?= sphinx-build
+SOURCEDIR     = .
+BUILDDIR      = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/packages/compare-images/python/itkwasm-compare-images/docs/_static/favicon.png b/packages/compare-images/python/itkwasm-compare-images/docs/_static/favicon.png
new file mode 100644
index 000000000..bd1e535c6
Binary files /dev/null and b/packages/compare-images/python/itkwasm-compare-images/docs/_static/favicon.png differ
diff --git a/packages/compare-images/python/itkwasm-compare-images/docs/_static/logo.svg b/packages/compare-images/python/itkwasm-compare-images/docs/_static/logo.svg
new file mode 100644
index 000000000..669a445ed
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/docs/_static/logo.svg
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 792 612" xmlns:v="https://vecta.io/nano"><path d="M301.8 416.7h14.9l5.2 28.4h.2l11.7-28.4H349l4.4 28.4h.2l12.5-28.4h14.1l-22.4 45.2H344l-5.2-30h-.2l-12.6 30h-14l-10.2-45.2zm105.7 39.6h-.2c-3.8 4.8-9.7 6.8-15.3 6.8-8.5 0-15.3-4.8-14.1-14 2.2-15.6 21.2-15.9 32.8-15.9.7-4.9-2.9-8.1-7.7-8.1-4.6 0-8.6 2-12.1 5.4l-6.4-7.6c5.8-4.8 13.6-7.3 20.9-7.3 16.3 0 19.4 8.3 17.4 23.3l-3.3 23h-12.8l.8-5.6zm-1.5-14.2c-4.3 0-13.9.4-14.7 6.5-.5 3.3 2.9 4.9 6 4.9 5.5 0 11-2.9 11.7-8.5l.5-3H406zm52.3-12.1c-1.7-2.5-4.2-3.8-7.5-3.8-2.3 0-5.3 1-5.7 3.7-.9 6.7 23.4 1 21 18.2-1.7 11.5-13.3 14.9-23 14.9-6.3 0-13-1.6-16.8-6.1l9.8-9.4c2.1 3.2 4.9 4.9 9.1 4.9 3.2 0 6.5-.8 6.8-3.3 1-7.3-23.3-1.1-20.9-18.3 1.5-10.6 11.6-15.3 21.1-15.3 6 0 12.4 1.5 15.9 6l-9.8 8.5zm19.3-13.3H491l-.8 6.1h.2c2.3-3.5 7.4-7.3 14.2-7.3 6.3 0 11.2 2.6 12.8 8.1 4-5.8 8.7-8.1 15.4-8.1 12.3 0 14.8 8.6 13.2 19.6l-3.7 26.7h-14l3.7-26.3c.6-4.2-.6-7.7-5.1-7.7-6.4 0-9 4.6-9.9 10.1l-3.3 23.9h-14l3.3-23.9c.5-3.3 1.6-10.1-4.8-10.1-7.3 0-9.1 5.7-10 11.5l-3.2 22.5h-14l6.6-45.1z" fill="#afafaf"/><path d="M239.7 153l-40.9 230.7h66.8l41-230.7z" fill="#064f8c"/><path d="M191.3 357.6l-66.3 21c-1.3.4-2.1 1.8-1.7 3.1h0c.3 1.2 1.3 2 2.5 2h62.6c1.3 0 2.3-.9 2.5-2.1l3.7-21c.4-2-1.4-3.6-3.3-3z" fill="#f7941d"/><path d="M334.3 383.7h9.4l29.7-168.9H364zm129.1 0h9.4l29.8-168.9h-9.4zm107.3-68.5l-4.9 27.7 23 94.4h11zm58.7-162.3h-11.7l-24.8 37-4 22.6h.6z" fill="#073e66"/><path d="M526.6 153H320.5l-9.7 53.7h73.4L353 383.6h60.6l31.2-176.9h56.1z" fill="#064f8c"/><path d="M187 246.1l85.6 59c18.8 13.4 26.3 37.7 18.4 59.4l-2.8 7.6-90.7-63.2C152 270.6 187 246.1 187 246.1z" fill="#f6b015"/><path d="M706.5 152.8h-67.4l-54.7 81.7h-7.9l14.4-81.5H539l-25.6 53.4-31.3 177.3h68.1l16.1-91.7h7.6l35.4 145.3h67.4L634.1 261z" fill="#064f8c"/><path d="M53.1 173.2l12.3-.7-7.4 7.1c-.8.8-.7 1 .3.5 6.3-3.3 26.2-13.2 26.2-13.2-8.5.1-20.8 1.2-31.6 5.7-1.1.3-1 .6.2.6z" fill="#f7941d"/><path d="M290.3 207.7l-64-44.4s-24.5-18.6-48.3-27l-19.2-8.2c-9.7-5.8-28.1.7-41.1 6.2l-34.4 14.3c-5.3 3.1-8.4 8.9-8.2 15v.1c.3 6.7 5.5 12.1 12.1 13.1l59.6 3.7c7.7.2 14.3-.3 20.4-3.9h0s10.3 1.2 31.6 17.2h0l107 80.7h.1l2.7-7.5c7.5-24.7.4-45.9-18.3-59.3z" fill="#f6b015"/><path d="M301.4 260.4L200.1 183h0c-21.2-16-31.6-17.2-31.6-17.2-6.1 3.6-12.6 4.1-20.4 3.9L88.5 166c-5.7-.8-10.3-5-11.6-10.3-1.2 2.5-1.8 5.2-1.7 8v.1c.3 6.5 5.2 11.8 11.5 13 .4.1.8.1 1.2.1l59 3.6c7.5.2 14.3-.3 20.4-3.9 0 0 10.3 1.2 31.6 17.2h0l97 73.3-3.3 18.5c5.7-1.7 11.4-6.1 13.4-10.8v-.1c1.6-5.2-.2-11-4.6-14.3z" fill="#f7941d"/><path d="M115.1 149.9s5.7-5.4 10-6 7.9.7 8.3 3c.3 2.3-3.4 2.7-7.7 3.3-4.2.6-10.6-.3-10.6-.3z" fill="#f1f2f2"/><path d="M283.8 358L204 302.4c-11-9.2-17.3-17.7-20.5-25.1-1.9-6.2-1.7-15.3 8-26.1.8-.8 1.7-.8 3.6.5h0l15.9 11 3.6-20.2-14.3-.1c-8.4 0-13.3 3.8-13.3 3.8s-35 24.5 10.5 62.8l79.2 55.2c.8.6 1.2 1.5 1 2.5l-2.9 16.2c5.7-1.7 11.5-5.7 13.3-10.7 2-5.3.2-11.1-4.3-14.2z" fill="#f7941d"/></svg>
\ No newline at end of file
diff --git a/packages/compare-images/python/itkwasm-compare-images/docs/conf.py b/packages/compare-images/python/itkwasm-compare-images/docs/conf.py
new file mode 100644
index 000000000..a12ffb792
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/docs/conf.py
@@ -0,0 +1,56 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+from datetime import date
+
+project = 'itkwasm-compare-images'
+copyright = f'{date.today().year}, NumFOCUS'
+author = 'Insight Software Consortium'
+
+extensions = [
+    'sphinx.ext.autosummary',
+    'autodoc2',
+    'myst_parser',
+    'sphinx.ext.intersphinx',
+    'sphinx_copybutton',
+    'sphinxext.opengraph',
+    'sphinx_design',
+]
+
+myst_enable_extensions = ["colon_fence", "fieldlist"]
+
+templates_path = ['_templates']
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+autodoc2_packages = [
+    {
+        "path": "../itkwasm_compare_images",
+        "exclude_files": ["_version.py"],
+    },
+]
+autodoc2_render_plugin = "myst"
+
+intersphinx_mapping = {
+    "python": ("https://docs.python.org/3/", None),
+    "numpy": ("https://numpy.org/doc/stable", None),
+    "itkwasm": ("https://itkwasm.readthedocs.io/en/latest/", None),
+}
+
+html_theme = 'furo'
+html_static_path = ['_static']
+html_logo = "_static/logo.svg"
+html_favicon = "_static/favicon.png"
+html_title = f"{project}"
+
+# Furo options
+html_theme_options = {
+    "top_of_page_button": "edit",
+    "source_repository": "https://github.com/InsightSoftwareConsortium/itk-wasm",
+    "source_branch": "main",
+    "source_directory": "docs",
+}
\ No newline at end of file
diff --git a/packages/compare-images/python/itkwasm-compare-images/docs/index.md b/packages/compare-images/python/itkwasm-compare-images/docs/index.md
new file mode 100644
index 000000000..c73ce0273
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/docs/index.md
@@ -0,0 +1,35 @@
+itkwasm-compare-images
+=======
+
+> Compare images with a tolerance for regression testing.
+
+[![itkwasm-compare-images version](https://badge.fury.io/py/itkwasm_compare_images.svg)](https://pypi.org/project/itkwasm_compare_images/)
+
+## Installation
+
+::::{tab-set}
+
+:::{tab-item} System
+```shell
+pip install itkwasm-compare-images
+```
+:::
+
+:::{tab-item} Browser
+In Pyodide, e.g. the [Pyodide REPL](https://pyodide.org/en/stable/console.html) or [JupyterLite](https://jupyterlite.readthedocs.io/en/latest/try/lab),
+
+```python
+import micropip
+await micropip.install('itkwasm-compare-images')
+:::
+
+::::
+
+```{toctree}
+:hidden:
+:maxdepth: 3
+:caption: 📖 Reference
+
+apidocs/index.rst
+itkwasm docs <https://itkwasm.readthedocs.io/>
+```
\ No newline at end of file
diff --git a/packages/compare-images/python/itkwasm-compare-images/docs/make.bat b/packages/compare-images/python/itkwasm-compare-images/docs/make.bat
new file mode 100644
index 000000000..32bb24529
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+	echo.installed, then set the SPHINXBUILD environment variable to point
+	echo.to the full path of the 'sphinx-build' executable. Alternatively you
+	echo.may add the Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.https://www.sphinx-doc.org/
+	exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/packages/compare-images/python/itkwasm-compare-images/docs/requirements.txt b/packages/compare-images/python/itkwasm-compare-images/docs/requirements.txt
new file mode 100644
index 000000000..eaed1dc05
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/docs/requirements.txt
@@ -0,0 +1,7 @@
+sphinx
+furo
+sphinx-autodoc2
+myst-parser
+sphinx-copybutton
+sphinxext-opengraph
+sphinx-design
\ No newline at end of file
diff --git a/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/__init__.py b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/__init__.py
new file mode 100644
index 000000000..35215d764
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/__init__.py
@@ -0,0 +1,10 @@
+"""itkwasm-compare-images: Compare images with a tolerance for regression testing."""
+
+from .compare_double_images_async import compare_double_images_async
+from .compare_double_images import compare_double_images
+from .compare_images_async import compare_images_async
+from .compare_images import compare_images
+from .vector_magnitude_async import vector_magnitude_async
+from .vector_magnitude import vector_magnitude
+
+from ._version import __version__
diff --git a/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/_version.py b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/_version.py
new file mode 100644
index 000000000..5becc17c0
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/_version.py
@@ -0,0 +1,1 @@
+__version__ = "1.0.0"
diff --git a/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_double_images.py b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_double_images.py
new file mode 100644
index 000000000..e1d7f5232
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_double_images.py
@@ -0,0 +1,50 @@
+# Generated file. Do not edit.
+
+import os
+from typing import Dict, Tuple, Optional, List, Any
+
+from itkwasm import (
+    environment_dispatch,
+    Image,
+)
+
+def compare_double_images(
+    test_image: Image,
+    baseline_images: List[Image] = [],
+    difference_threshold: float = 0,
+    radius_tolerance: int = 0,
+    number_of_pixels_tolerance: int = 0,
+    ignore_boundary_pixels: bool = False,
+) -> Tuple[Any, Image, Image]:
+    """Compare double pixel type images with a tolerance for regression testing.
+
+    :param test_image: The input test image
+    :type  test_image: Image
+
+    :param baseline_images: Baseline images compare against
+    :type  baseline_images: Image
+
+    :param difference_threshold: Intensity difference for pixels to be considered different.
+    :type  difference_threshold: float
+
+    :param radius_tolerance: Radius of the neighborhood around a pixel to search for similar intensity values.
+    :type  radius_tolerance: int
+
+    :param number_of_pixels_tolerance: Number of pixels that can be different before the test fails.
+    :type  number_of_pixels_tolerance: int
+
+    :param ignore_boundary_pixels: Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge.
+    :type  ignore_boundary_pixels: bool
+
+    :return: Metrics for the baseline with the fewest number of pixels outside the tolerances.
+    :rtype:  Any
+
+    :return: Absolute difference image
+    :rtype:  Image
+
+    :return: Unsigned char, 2D difference image for rendering
+    :rtype:  Image
+    """
+    func = environment_dispatch("itkwasm_compare_images", "compare_double_images")
+    output = func(test_image, baseline_images=baseline_images, difference_threshold=difference_threshold, radius_tolerance=radius_tolerance, number_of_pixels_tolerance=number_of_pixels_tolerance, ignore_boundary_pixels=ignore_boundary_pixels)
+    return output
diff --git a/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_double_images_async.py b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_double_images_async.py
new file mode 100644
index 000000000..967f189b6
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_double_images_async.py
@@ -0,0 +1,50 @@
+# Generated file. Do not edit.
+
+import os
+from typing import Dict, Tuple, Optional, List, Any
+
+from itkwasm import (
+    environment_dispatch,
+    Image,
+)
+
+async def compare_double_images_async(
+    test_image: Image,
+    baseline_images: List[Image] = [],
+    difference_threshold: float = 0,
+    radius_tolerance: int = 0,
+    number_of_pixels_tolerance: int = 0,
+    ignore_boundary_pixels: bool = False,
+) -> Tuple[Any, Image, Image]:
+    """Compare double pixel type images with a tolerance for regression testing.
+
+    :param test_image: The input test image
+    :type  test_image: Image
+
+    :param baseline_images: Baseline images compare against
+    :type  baseline_images: Image
+
+    :param difference_threshold: Intensity difference for pixels to be considered different.
+    :type  difference_threshold: float
+
+    :param radius_tolerance: Radius of the neighborhood around a pixel to search for similar intensity values.
+    :type  radius_tolerance: int
+
+    :param number_of_pixels_tolerance: Number of pixels that can be different before the test fails.
+    :type  number_of_pixels_tolerance: int
+
+    :param ignore_boundary_pixels: Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge.
+    :type  ignore_boundary_pixels: bool
+
+    :return: Metrics for the baseline with the fewest number of pixels outside the tolerances.
+    :rtype:  Any
+
+    :return: Absolute difference image
+    :rtype:  Image
+
+    :return: Unsigned char, 2D difference image for rendering
+    :rtype:  Image
+    """
+    func = environment_dispatch("itkwasm_compare_images", "compare_double_images_async")
+    output = await func(test_image, baseline_images=baseline_images, difference_threshold=difference_threshold, radius_tolerance=radius_tolerance, number_of_pixels_tolerance=number_of_pixels_tolerance, ignore_boundary_pixels=ignore_boundary_pixels)
+    return output
diff --git a/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_images.py b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_images.py
new file mode 100644
index 000000000..fb3ca999d
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_images.py
@@ -0,0 +1,48 @@
+import os
+from typing import Dict, Tuple, Optional, List, Any
+
+from itkwasm import (
+    environment_dispatch,
+    Image,
+)
+
+def compare_images(
+    test_image: Image,
+    baseline_images: List[Image] = [],
+    difference_threshold: float = 0,
+    radius_tolerance: int = 0,
+    number_of_pixels_tolerance: int = 0,
+    ignore_boundary_pixels: bool = False,
+) -> Tuple[Any, Image, Image]:
+    """Compare images with a tolerance for regression testing.
+
+    :param test_image: The input test image
+    :type  test_image: Image
+
+    :param baseline_images: Baseline images compare against
+    :type  baseline_images: Image
+
+    :param difference_threshold: Intensity difference for pixels to be considered different.
+    :type  difference_threshold: float
+
+    :param radius_tolerance: Radius of the neighborhood around a pixel to search for similar intensity values.
+    :type  radius_tolerance: int
+
+    :param number_of_pixels_tolerance: Number of pixels that can be different before the test fails.
+    :type  number_of_pixels_tolerance: int
+
+    :param ignore_boundary_pixels: Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge.
+    :type  ignore_boundary_pixels: bool
+
+    :return: Metrics for the baseline with the fewest number of pixels outside the tolerances.
+    :rtype:  Any
+
+    :return: Absolute difference image
+    :rtype:  Image
+
+    :return: Unsigned char, 2D difference image for rendering
+    :rtype:  Image
+    """
+    func = environment_dispatch("itkwasm_compare_images", "compare_images")
+    output = func(test_image, baseline_images=baseline_images, difference_threshold=difference_threshold, radius_tolerance=radius_tolerance, number_of_pixels_tolerance=number_of_pixels_tolerance, ignore_boundary_pixels=ignore_boundary_pixels)
+    return output
diff --git a/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_images_async.py b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_images_async.py
new file mode 100644
index 000000000..cac25b5e1
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/compare_images_async.py
@@ -0,0 +1,48 @@
+import os
+from typing import Dict, Tuple, Optional, List, Any
+
+from itkwasm import (
+    environment_dispatch,
+    Image,
+)
+
+async def compare_images_async(
+    test_image: Image,
+    baseline_images: List[Image] = [],
+    difference_threshold: float = 0,
+    radius_tolerance: int = 0,
+    number_of_pixels_tolerance: int = 0,
+    ignore_boundary_pixels: bool = False,
+) -> Tuple[Any, Image, Image]:
+    """Compare images with a tolerance for regression testing.
+
+    :param test_image: The input test image
+    :type  test_image: Image
+
+    :param baseline_images: Baseline images compare against
+    :type  baseline_images: Image
+
+    :param difference_threshold: Intensity difference for pixels to be considered different.
+    :type  difference_threshold: float
+
+    :param radius_tolerance: Radius of the neighborhood around a pixel to search for similar intensity values.
+    :type  radius_tolerance: int
+
+    :param number_of_pixels_tolerance: Number of pixels that can be different before the test fails.
+    :type  number_of_pixels_tolerance: int
+
+    :param ignore_boundary_pixels: Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge.
+    :type  ignore_boundary_pixels: bool
+
+    :return: Metrics for the baseline with the fewest number of pixels outside the tolerances.
+    :rtype:  Any
+
+    :return: Absolute difference image
+    :rtype:  Image
+
+    :return: Unsigned char, 2D difference image for rendering
+    :rtype:  Image
+    """
+    func = environment_dispatch("itkwasm_compare_images", "compare_images_async")
+    output = await func(test_image, baseline_images=baseline_images, difference_threshold=difference_threshold, radius_tolerance=radius_tolerance, number_of_pixels_tolerance=number_of_pixels_tolerance, ignore_boundary_pixels=ignore_boundary_pixels)
+    return output
diff --git a/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/vector_magnitude.py b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/vector_magnitude.py
new file mode 100644
index 000000000..b422f0003
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/vector_magnitude.py
@@ -0,0 +1,24 @@
+# Generated file. Do not edit.
+
+import os
+from typing import Dict, Tuple, Optional, List, Any
+
+from itkwasm import (
+    environment_dispatch,
+    Image,
+)
+
+def vector_magnitude(
+    vector_image: Image,
+) -> Image:
+    """Generate a scalar magnitude image based on the input vector's norm.
+
+    :param vector_image: Input vector image
+    :type  vector_image: Image
+
+    :return: Output magnitude image
+    :rtype:  Image
+    """
+    func = environment_dispatch("itkwasm_compare_images", "vector_magnitude")
+    output = func(vector_image)
+    return output
diff --git a/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/vector_magnitude_async.py b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/vector_magnitude_async.py
new file mode 100644
index 000000000..e8834bded
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/itkwasm_compare_images/vector_magnitude_async.py
@@ -0,0 +1,24 @@
+# Generated file. Do not edit.
+
+import os
+from typing import Dict, Tuple, Optional, List, Any
+
+from itkwasm import (
+    environment_dispatch,
+    Image,
+)
+
+async def vector_magnitude_async(
+    vector_image: Image,
+) -> Image:
+    """Generate a scalar magnitude image based on the input vector's norm.
+
+    :param vector_image: Input vector image
+    :type  vector_image: Image
+
+    :return: Output magnitude image
+    :rtype:  Image
+    """
+    func = environment_dispatch("itkwasm_compare_images", "vector_magnitude_async")
+    output = await func(vector_image)
+    return output
diff --git a/packages/compare-images/python/itkwasm-compare-images/pyproject.toml b/packages/compare-images/python/itkwasm-compare-images/pyproject.toml
new file mode 100644
index 000000000..9033de171
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/pyproject.toml
@@ -0,0 +1,72 @@
+[build-system]
+requires = ["hatchling", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+[project]
+name = "itkwasm-compare-images"
+readme = "README.md"
+license = "Apache-2.0"
+dynamic = ["version", "description"]
+classifiers = [
+  "License :: OSI Approved :: Apache Software License",
+  "Programming Language :: Python",
+  "Programming Language :: C++",
+  "Environment :: WebAssembly",
+  "Environment :: WebAssembly :: Emscripten",
+  "Environment :: WebAssembly :: WASI",
+  "Development Status :: 3 - Alpha",
+  "Intended Audience :: Developers",
+  "Intended Audience :: Science/Research",
+  "Programming Language :: Python :: 3",
+  "Programming Language :: Python :: 3.7",
+  "Programming Language :: Python :: 3.8",
+  "Programming Language :: Python :: 3.9",
+  "Programming Language :: Python :: 3.10",
+  "Programming Language :: Python :: 3.11",
+]
+keywords = [
+  "itkwasm",
+  "webassembly",
+  "wasi",
+  "emscripten",
+]
+
+requires-python = ">=3.7"
+dependencies = [
+    "itkwasm >= 1.0.b130",
+    "itkwasm-compare-images-wasi; sys_platform != \"emscripten\"",
+    "itkwasm-compare-images-emscripten; sys_platform == \"emscripten\"",
+
+]
+
+[tool.hatch.version]
+path = "itkwasm_compare_images/_version.py"
+
+[tool.hatch.envs.default]
+dependencies = [
+  "pytest",
+  "pytest-pyodide",
+  "itk-webassemblyinterface >= 1.0.b127",
+  "itkwasm >= 1.0.b130",
+]
+
+[project.urls]
+Home = "https://github.com/InsightSoftwareConsortium/itk-wasm"
+Source = "https://github.com/InsightSoftwareConsortium/itk-wasm"
+
+[tool.hatch.envs.default.scripts]
+test = [
+  "hatch build -t wheel",
+  "pytest -s --dist-dir=./dist --rt=chrome",
+]
+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",
+  "rm -rf dist pyodide.tar.bz2",
+  "mv pyodide dist",
+]
+
+[tool.hatch.build]
+exclude = [
+  "/examples",
+]
diff --git a/packages/compare-images/typescript/.nojekll b/packages/compare-images/typescript/.nojekll
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/compare-images/typescript/.npmignore b/packages/compare-images/typescript/.npmignore
new file mode 100644
index 000000000..ff327591c
--- /dev/null
+++ b/packages/compare-images/typescript/.npmignore
@@ -0,0 +1,5 @@
+node_modules
+.DS_Store
+test
+cypress
+demo-app
\ No newline at end of file
diff --git a/packages/compare-images/typescript/README.md b/packages/compare-images/typescript/README.md
new file mode 100644
index 000000000..d9c95ae90
--- /dev/null
+++ b/packages/compare-images/typescript/README.md
@@ -0,0 +1,145 @@
+# @itk-wasm/compare-images
+
+[![npm version](https://badge.fury.io/js/@itk-wasm%2Fcompare-images.svg)](https://www.npmjs.com/package/@itk-wasm/compare-images)
+
+> Compare images with a tolerance for regression testing.
+
+## Installation
+
+```sh
+npm install @itk-wasm/compare-images
+```
+
+## Usage
+
+### Browser interface
+
+Import:
+
+```js
+import {
+  compareImages,
+  setPipelinesBaseUrl,
+  getPipelinesBaseUrl,
+  setPipelineWorkerUrl,
+  getPipelineWorkerUrl,
+} from "@itk-wasm/compare-images"
+```
+
+#### compareImages
+
+*Compare images with a tolerance for regression testing.*
+
+```ts
+async function compareImages(
+  webWorker: null | Worker,
+  testImage: Image,
+  options: CompareImagesOptions = { baselineImages: [] as Image[], }
+) : Promise<CompareImagesResult>
+```
+
+|  Parameter  |   Type  | Description          |
+| :---------: | :-----: | :------------------- |
+| `testImage` | *Image* | The input test image |
+
+**`CompareImagesOptions` interface:**
+
+|          Property         |    Type   | Description                                                                                                      |
+| :-----------------------: | :-------: | :--------------------------------------------------------------------------------------------------------------- |
+|      `baselineImages`     | *Image[]* | Baseline images compare against                                                                                  |
+|   `differenceThreshold`   |  *number* | Intensity difference for pixels to be considered different.                                                      |
+|     `radiusTolerance`     |  *number* | Radius of the neighborhood around a pixel to search for similar intensity values.                                |
+| `numberOfPixelsTolerance` |  *number* | Number of pixels that can be different before the test fails.                                                    |
+|   `ignoreBoundaryPixels`  | *boolean* | Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge. |
+
+**`CompareImagesResult` interface:**
+
+|         Property         |   Type   | Description                                                                       |
+| :----------------------: | :------: | :-------------------------------------------------------------------------------- |
+|       **webWorker**      | *Worker* | WebWorker used for computation                                                    |
+|         `metrics`        | *Object* | Metrics for the baseline with the fewest number of pixels outside the tolerances. |
+|     `differenceImage`    |  *Image* | Absolute difference image                                                         |
+| `differenceUchar2dImage` |  *Image* | Unsigned char, 2D difference image for rendering                                  |
+
+#### setPipelinesBaseUrl
+
+*Set base URL for WebAssembly assets when vendored.*
+
+```ts
+function setPipelinesBaseUrl(
+  baseUrl: string | URL
+) : void
+```
+
+#### getPipelinesBaseUrl
+
+*Get base URL for WebAssembly assets when vendored.*
+
+```ts
+function getPipelinesBaseUrl() : string | URL
+```
+
+#### setPipelineWorkerUrl
+
+*Set base URL for the itk-wasm pipeline worker script when vendored.*
+
+```ts
+function setPipelineWorkerUrl(
+  baseUrl: string | URL
+) : void
+```
+
+#### getPipelineWorkerUrl
+
+*Get base URL for the itk-wasm pipeline worker script when vendored.*
+
+```ts
+function getPipelineWorkerUrl() : string | URL
+```
+
+### Node interface
+
+Import:
+
+```js
+import {
+  compareImagesNode,
+  setPipelinesBaseUrl,
+  getPipelinesBaseUrl,
+  setPipelineWorkerUrl,
+  getPipelineWorkerUrl,
+} from "@itk-wasm/compare-images"
+```
+
+#### compareImagesNode
+
+*Compare images with a tolerance for regression testing.*
+
+```ts
+async function compareImagesNode(
+  testImage: Image,
+  options: CompareImagesOptions = { baselineImages: [] as Image[], }
+) : Promise<CompareImagesNodeResult>
+```
+
+|  Parameter  |   Type  | Description          |
+| :---------: | :-----: | :------------------- |
+| `testImage` | *Image* | The input test image |
+
+**`CompareImagesNodeOptions` interface:**
+
+|          Property         |    Type   | Description                                                                                                      |
+| :-----------------------: | :-------: | :--------------------------------------------------------------------------------------------------------------- |
+|      `baselineImages`     | *Image[]* | Baseline images compare against                                                                                  |
+|   `differenceThreshold`   |  *number* | Intensity difference for pixels to be considered different.                                                      |
+|     `radiusTolerance`     |  *number* | Radius of the neighborhood around a pixel to search for similar intensity values.                                |
+| `numberOfPixelsTolerance` |  *number* | Number of pixels that can be different before the test fails.                                                    |
+|   `ignoreBoundaryPixels`  | *boolean* | Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge. |
+
+**`CompareImagesNodeResult` interface:**
+
+|         Property         |   Type   | Description                                                                       |
+| :----------------------: | :------: | :-------------------------------------------------------------------------------- |
+|         `metrics`        | *Object* | Metrics for the baseline with the fewest number of pixels outside the tolerances. |
+|     `differenceImage`    |  *Image* | Absolute difference image                                                         |
+| `differenceUchar2dImage` |  *Image* | Unsigned char, 2D difference image for rendering                                  |
diff --git a/packages/compare-images/typescript/build/rollup.browser.config.js b/packages/compare-images/typescript/build/rollup.browser.config.js
new file mode 100644
index 000000000..ac03845a3
--- /dev/null
+++ b/packages/compare-images/typescript/build/rollup.browser.config.js
@@ -0,0 +1,51 @@
+import { nodeResolve } from '@rollup/plugin-node-resolve'
+import copy from 'rollup-plugin-copy'
+import typescript from '@rollup/plugin-typescript'
+import commonjs from '@rollup/plugin-commonjs'
+import nodePolyfills from 'rollup-plugin-polyfill-node'
+import ignore from 'rollup-plugin-ignore'
+import terser from '@rollup/plugin-terser'
+import packageJson from '../package.json' assert { type: 'json' }
+import json from '@rollup/plugin-json'
+import path from 'path'
+
+const itkConfig = './src/itkConfig.js'
+const bundleName = path.basename(packageJson.name)
+
+export default {
+  input: './src/index.ts',
+  output: [
+    {
+      file: `./dist/bundles/${bundleName}.js`,
+      format: 'es',
+      sourcemap: true,
+      // plugins: [terser(),],
+    },
+  ],
+  plugins: [
+    copy({
+      targets: [
+        { src: 'node_modules/itk-wasm/dist/web-workers/bundles/pipeline.worker.js', dest: 'dist/web-workers/' },
+      ],
+      hook: 'writeBundle'
+    }),
+    ignore(['crypto']),
+    nodeResolve({
+      preferBuiltins: false,
+      browser: true,
+    }),
+    commonjs({
+      transformMixedEsModules: true
+    }),
+    nodePolyfills(),
+    typescript(),
+    json(),
+  ],
+  resolve: {
+    // where itk-wasm code has 'import ../itkConfig.js` point to the path of itkConfig
+    alias: {
+      '../itkConfig.js': itkConfig,
+      '../../itkConfig.js': itkConfig
+    }
+  }
+}
diff --git a/packages/compare-images/typescript/build/rollup.node.config.js b/packages/compare-images/typescript/build/rollup.node.config.js
new file mode 100644
index 000000000..431b29d3e
--- /dev/null
+++ b/packages/compare-images/typescript/build/rollup.node.config.js
@@ -0,0 +1,32 @@
+import { nodeResolve } from '@rollup/plugin-node-resolve'
+import typescript from '@rollup/plugin-typescript'
+import commonjs from '@rollup/plugin-commonjs'
+import json from '@rollup/plugin-json'
+import terser from '@rollup/plugin-terser'
+import packageJson from '../package.json' assert { type: 'json' }
+import path from 'path'
+
+const bundleName = path.basename(packageJson.name)
+
+export default {
+  input: './src/index-node.ts',
+  output: [
+    {
+      file: `./dist/bundles/${bundleName}-node.js`,
+      format: 'es',
+      sourcemap: true,
+      // plugins: [terser(),],
+    },
+  ],
+  plugins: [
+    commonjs({
+      transformMixedEsModules: true
+    }),
+    nodeResolve({
+      preferBuiltins: true,
+      browser: false,
+    }),
+    typescript(),
+    json(),
+  ],
+}
diff --git a/packages/compare-images/typescript/build/vite.config.js b/packages/compare-images/typescript/build/vite.config.js
new file mode 100644
index 000000000..f40d37493
--- /dev/null
+++ b/packages/compare-images/typescript/build/vite.config.js
@@ -0,0 +1,20 @@
+import { defineConfig } from 'vite'
+import { viteStaticCopy } from 'vite-plugin-static-copy'
+import path from 'path'
+
+export default defineConfig({
+  root: path.join('test', 'browser', 'demo-app'),
+  build: {
+    outDir: '../../../demo-app',
+    emptyOutDir: true,
+  },
+  plugins: [
+    // put lazy loaded JavaScript and Wasm bundles in dist directory
+    viteStaticCopy({
+      targets: [
+        { src: '../../../dist/pipelines/*', dest: 'pipelines' },
+        { src: '../../../dist/web-workers/*', dest: 'web-workers' },
+      ],
+    })
+  ],
+})
diff --git a/packages/compare-images/typescript/cypress.config.ts b/packages/compare-images/typescript/cypress.config.ts
new file mode 100644
index 000000000..8d8aed37f
--- /dev/null
+++ b/packages/compare-images/typescript/cypress.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "cypress";
+
+export default defineConfig({
+  e2e: {
+    defaultCommandTimeout: 5000,
+    setupNodeEvents(on, config) {
+      // implement node event listeners here
+    },
+  },
+});
diff --git a/packages/compare-images/typescript/cypress/support/commands.ts b/packages/compare-images/typescript/cypress/support/commands.ts
new file mode 100644
index 000000000..698b01a42
--- /dev/null
+++ b/packages/compare-images/typescript/cypress/support/commands.ts
@@ -0,0 +1,37 @@
+/// <reference types="cypress" />
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+//   namespace Cypress {
+//     interface Chainable {
+//       login(email: string, password: string): Chainable<void>
+//       drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
+//       dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
+//       visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
+//     }
+//   }
+// }
\ No newline at end of file
diff --git a/packages/compare-images/typescript/cypress/tsconfig.json b/packages/compare-images/typescript/cypress/tsconfig.json
new file mode 100644
index 000000000..212405809
--- /dev/null
+++ b/packages/compare-images/typescript/cypress/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "extends": "../tsconfig.json",
+  "include": [
+    "**/*.ts"
+  ],
+  "compilerOptions": {
+    "noEmit": false,
+    "sourceMap": false,
+    "inlineSourceMap": true,
+    "types": ["cypress"]
+  },
+}
diff --git a/packages/compare-images/typescript/index.html b/packages/compare-images/typescript/index.html
new file mode 100644
index 000000000..c4b180982
--- /dev/null
+++ b/packages/compare-images/typescript/index.html
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <title>@itk-wasm/compare-images</title>
+  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
+  <meta name="description" content="Compare images with a tolerance for regression testing.">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
+  <link rel="stylesheet" media="(prefers-color-scheme: dark)" href="https://cdn.jsdelivr.net/npm/docsify-themeable@0/dist/css/theme-simple-dark.css">
+   <link rel="stylesheet" media="(prefers-color-scheme: light)" href="https://cdn.jsdelivr.net/npm/docsify-themeable@0/dist/css/theme-simple.css">
+   <style>
+    :root {
+      --base-font-size: 16px;
+      --theme-color   : lightblue;
+      --code-theme-text : #afcf7f;
+      --code-font-weight : 750;
+      --code-inline-background: hsl(var(--mono-hue), var(--mono-saturation), 35%);
+      --mono-hue        : 213;
+      --mono-saturation : 0%;
+    }
+  </style>
+</head>
+<body>
+  <div id="app">Loading...</div>
+  <script>
+    window.$docsify = {
+      logo: 'test/browser/logo.svg',
+      name: '@itk-wasm/compare-images',
+      repo: ''
+    }
+  </script>
+  <!-- Docsify v4 -->
+  <script src="https://cdn.jsdelivr.net/npm/docsify@4"></script>
+  <script src="https://cdn.jsdelivr.net/npm/docsify-copy-code/dist/docsify-copy-code.min.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-typescript.min.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-c.min.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-cpp.min.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-python.min.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/docsify-themeable@0/dist/js/docsify-themeable.min.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/docsify@4/lib/plugins/zoom-image.min.js"></script>
+</body>
+</html>
diff --git a/packages/compare-images/typescript/package.json b/packages/compare-images/typescript/package.json
new file mode 100644
index 000000000..704707319
--- /dev/null
+++ b/packages/compare-images/typescript/package.json
@@ -0,0 +1,71 @@
+{
+  "name": "@itk-wasm/compare-images",
+  "version": "1.0.0",
+  "description": "Compare images with a tolerance for regression testing.",
+  "type": "module",
+  "module": "./dist/bundles/compare-images.js",
+  "types": "./dist/src/index.d.ts",
+  "exports": {
+    ".": {
+      "browser": "./dist/bundles/compare-images.js",
+      "node": "./dist/bundles/compare-images.node.js",
+      "default": "./dist/bundles/compare-images.js"
+    }
+  },
+  "scripts": {
+    "start": "npm run copyShoelaceAssets && vite -c build/vite.config.js",
+    "test": "npm run test:node && npm run test:browser",
+    "test:node": "ava test/node/*.js",
+    "test:browser": "npm run test:browser:chrome && npm run test:browser:firefox",
+    "test:browser:firefox": "start-server-and-test start http-get://localhost:5173 cypress:runFirefox",
+    "test:browser:chrome": "start-server-and-test start http-get://localhost:5173 cypress:runChrome",
+    "test:browser:debug": "start-server-and-test start http-get://localhost:5173 cypress:open",
+    "cypress:open": "npx cypress open",
+    "cypress:runChrome": "npx cypress run --browser chrome",
+    "cypress:runFirefox": "npx cypress run --browser firefox",
+    "build": "npm run build:tsc && npm run build:node && npm run build:browser && npm run build:demo",
+    "build:node": "rollup -c ./build/rollup.node.config.js",
+    "build:browser": "rollup -c ./build/rollup.browser.config.js",
+    "build:tsc": "tsc --pretty",
+    "copyShoelaceAssets": "shx mkdir -p test/browser/demo-app/public && shx cp -r node_modules/@shoelace-style/shoelace/dist/assets test/browser/demo-app/public/",
+    "build:demo": "npm run copyShoelaceAssets && vite -c build/vite.config.js build"
+  },
+  "keywords": [
+    "itk",
+    "wasm",
+    "webassembly",
+    "wasi"
+  ],
+  "author": "",
+  "license": "Apache-2.0",
+  "dependencies": {
+    "itk-wasm": "^1.0.0-b.127"
+  },
+  "devDependencies": {
+    "@rollup/plugin-commonjs": "^24.0.0",
+    "@rollup/plugin-json": "^6.0.0",
+    "@rollup/plugin-node-resolve": "^15.1.0",
+    "@rollup/plugin-terser": "^0.4.0",
+    "@rollup/plugin-typescript": "^11.1.1",
+    "@shoelace-style/shoelace": "^2.5.2",
+    "@types/node": "^20.2.5",
+    "ava": "^5.3.1",
+    "cypress": "^12.17.2",
+    "debug": "^4.3.4",
+    "itk-image-io": "^1.0.0-b.127",
+    "rollup": "^3.9.0",
+    "rollup-plugin-copy": "^3.4.0",
+    "rollup-plugin-ignore": "^1.0.10",
+    "rollup-plugin-polyfill-node": "^0.12.0",
+    "shx": "^0.3.4",
+    "supports-color": "^9.3.1",
+    "tslib": "^2.5.2",
+    "typescript": "^5.0.4",
+    "vite": "^4.3.3",
+    "vite-plugin-static-copy": "^0.14.0"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/InsightSoftwareConsortium/itk-wasm"
+  }
+}
\ No newline at end of file
diff --git a/packages/compare-images/typescript/src/compare-double-images-node-result.ts b/packages/compare-images/typescript/src/compare-double-images-node-result.ts
new file mode 100644
index 000000000..6f759ee1c
--- /dev/null
+++ b/packages/compare-images/typescript/src/compare-double-images-node-result.ts
@@ -0,0 +1,17 @@
+import CompareImagesMetric from './compare-images-metric.js'
+
+import { Image } from 'itk-wasm'
+
+interface CompareDoubleImagesNodeResult {
+  /** Metrics for the baseline with the fewest number of pixels outside the tolerances. */
+  metrics: CompareImagesMetric
+
+  /** Absolute difference image */
+  differenceImage: Image
+
+  /** Unsigned char, 2D difference image for rendering */
+  differenceUchar2dImage: Image
+
+}
+
+export default CompareDoubleImagesNodeResult
diff --git a/packages/compare-images/typescript/src/compare-double-images-node.ts b/packages/compare-images/typescript/src/compare-double-images-node.ts
new file mode 100644
index 000000000..95dc5ad6e
--- /dev/null
+++ b/packages/compare-images/typescript/src/compare-double-images-node.ts
@@ -0,0 +1,104 @@
+import {
+  Image,
+  JsonObject,
+  InterfaceTypes,
+  PipelineOutput,
+  PipelineInput,
+  runPipelineNode
+} from 'itk-wasm'
+
+import CompareDoubleImagesOptions from './compare-double-images-options.js'
+import CompareDoubleImagesNodeResult from './compare-double-images-node-result.js'
+import CompareImagesMetric from './compare-images-metric.js'
+
+import path from 'path'
+
+/**
+ * Compare double pixel type images with a tolerance for regression testing.
+ *
+ * @param {Image} testImage - The input test image
+ * @param {CompareDoubleImagesOptions} options - options object
+ *
+ * @returns {Promise<CompareDoubleImagesNodeResult>} - result object
+ */
+async function compareDoubleImagesNode(
+  testImage: Image,
+  options: CompareDoubleImagesOptions = { baselineImages: [] as Image[], }
+) : Promise<CompareDoubleImagesNodeResult> {
+
+  const desiredOutputs: Array<PipelineOutput> = [
+    { type: InterfaceTypes.JsonObject },
+    { type: InterfaceTypes.Image },
+    { type: InterfaceTypes.Image },
+  ]
+
+  const inputs: Array<PipelineInput> = [
+    { type: InterfaceTypes.Image, data: testImage },
+  ]
+
+  const args = []
+  // Inputs
+  const testImageName = '0'
+  args.push(testImageName as string)
+
+  // Outputs
+  const metricsName = '0'
+  args.push(metricsName)
+
+  const differenceImageName = '1'
+  args.push(differenceImageName)
+
+  const differenceUchar2dImageName = '2'
+  args.push(differenceUchar2dImageName)
+
+  // Options
+  args.push('--memory-io')
+  if (typeof options.baselineImages !== "undefined") {
+    if(options.baselineImages.length < 1) {
+      throw new Error('"baseline-images" option must have a length > 1')
+    }
+    args.push('--baseline-images')
+
+    options.baselineImages.forEach((value) => {
+      const inputCountString = inputs.length.toString()
+      inputs.push({ type: InterfaceTypes.Image, data: value as Image })
+      args.push(inputCountString)
+
+    })
+  }
+  if (typeof options.differenceThreshold !== "undefined") {
+    args.push('--difference-threshold', options.differenceThreshold.toString())
+
+  }
+  if (typeof options.radiusTolerance !== "undefined") {
+    args.push('--radius-tolerance', options.radiusTolerance.toString())
+
+  }
+  if (typeof options.numberOfPixelsTolerance !== "undefined") {
+    args.push('--number-of-pixels-tolerance', options.numberOfPixelsTolerance.toString())
+
+  }
+  if (typeof options.ignoreBoundaryPixels !== "undefined") {
+    options.ignoreBoundaryPixels && args.push('--ignore-boundary-pixels')
+  }
+
+  const pipelinePath = path.join(path.dirname(import.meta.url.substring(7)), '..', 'pipelines', 'compare-double-images')
+
+  const {
+    returnValue,
+    stderr,
+    outputs
+  } = await runPipelineNode(pipelinePath, args, desiredOutputs, inputs)
+  if (returnValue !== 0) {
+    throw new Error(stderr)
+  }
+
+  const result = {
+    metrics: (outputs[0].data as JsonObject).data as CompareImagesMetric,
+    differenceImage: outputs[1].data as Image,
+    differenceUchar2dImage: outputs[2].data as Image,
+  }
+  return result
+}
+
+export default compareDoubleImagesNode
diff --git a/packages/compare-images/typescript/src/compare-double-images-options.ts b/packages/compare-images/typescript/src/compare-double-images-options.ts
new file mode 100644
index 000000000..5f00f3d7c
--- /dev/null
+++ b/packages/compare-images/typescript/src/compare-double-images-options.ts
@@ -0,0 +1,23 @@
+// Generated file. To retain edits, remove this comment.
+
+import { Image } from 'itk-wasm'
+
+interface CompareDoubleImagesOptions {
+  /** Baseline images compare against */
+  baselineImages: Image[]
+
+  /** Intensity difference for pixels to be considered different. */
+  differenceThreshold?: number
+
+  /** Radius of the neighborhood around a pixel to search for similar intensity values. */
+  radiusTolerance?: number
+
+  /** Number of pixels that can be different before the test fails. */
+  numberOfPixelsTolerance?: number
+
+  /** Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge. */
+  ignoreBoundaryPixels?: boolean
+
+}
+
+export default CompareDoubleImagesOptions
diff --git a/packages/compare-images/typescript/src/compare-double-images-result.ts b/packages/compare-images/typescript/src/compare-double-images-result.ts
new file mode 100644
index 000000000..6c0e80458
--- /dev/null
+++ b/packages/compare-images/typescript/src/compare-double-images-result.ts
@@ -0,0 +1,19 @@
+import CompareImagesMetric from './compare-images-metric.js'
+
+import { Image } from 'itk-wasm'
+
+interface CompareDoubleImagesResult {
+  /** WebWorker used for computation */
+  webWorker: Worker | null
+
+  /** Metrics for the baseline with the fewest number of pixels outside the tolerances. */
+  metrics: CompareImagesMetric
+
+  /** Absolute difference image */
+  differenceImage: Image
+
+  /** Unsigned char, 2D difference image for rendering */
+  differenceUchar2dImage: Image
+}
+
+export default CompareDoubleImagesResult
diff --git a/packages/compare-images/typescript/src/compare-double-images.ts b/packages/compare-images/typescript/src/compare-double-images.ts
new file mode 100644
index 000000000..cbc065367
--- /dev/null
+++ b/packages/compare-images/typescript/src/compare-double-images.ts
@@ -0,0 +1,109 @@
+import {
+  Image,
+  JsonObject,
+  InterfaceTypes,
+  PipelineOutput,
+  PipelineInput,
+  runPipeline
+} from 'itk-wasm'
+
+import CompareDoubleImagesOptions from './compare-double-images-options.js'
+import CompareDoubleImagesResult from './compare-double-images-result.js'
+import CompareImagesMetric from './compare-images-metric.js'
+
+
+import { getPipelinesBaseUrl } from './pipelines-base-url.js'
+import { getPipelineWorkerUrl } from './pipeline-worker-url.js'
+
+/**
+ * Compare double pixel type images with a tolerance for regression testing.
+ *
+ * @param {Image} testImage - The input test image
+ * @param {CompareDoubleImagesOptions} options - options object
+ *
+ * @returns {Promise<CompareDoubleImagesResult>} - result object
+ */
+async function compareDoubleImages(
+  webWorker: null | Worker,
+  testImage: Image,
+  options: CompareDoubleImagesOptions = { baselineImages: [] as Image[], }
+) : Promise<CompareDoubleImagesResult> {
+
+  const desiredOutputs: Array<PipelineOutput> = [
+    { type: InterfaceTypes.JsonObject },
+    { type: InterfaceTypes.Image },
+    { type: InterfaceTypes.Image },
+  ]
+
+  const inputs: Array<PipelineInput> = [
+    { type: InterfaceTypes.Image, data: testImage },
+  ]
+
+  const args = []
+  // Inputs
+  const testImageName = '0'
+  args.push(testImageName as string)
+
+  // Outputs
+  const metricsName = '0'
+  args.push(metricsName)
+
+  const differenceImageName = '1'
+  args.push(differenceImageName)
+
+  const differenceUchar2dImageName = '2'
+  args.push(differenceUchar2dImageName)
+
+  // Options
+  args.push('--memory-io')
+  if (typeof options.baselineImages !== "undefined") {
+    if(options.baselineImages.length < 1) {
+      throw new Error('"baseline-images" option must have a length > 1')
+    }
+    args.push('--baseline-images')
+
+    await Promise.all(options.baselineImages.map(async (value) => {
+      const inputCountString = inputs.length.toString()
+      inputs.push({ type: InterfaceTypes.Image, data: value as Image })
+      args.push(inputCountString)
+
+    }))
+  }
+  if (typeof options.differenceThreshold !== "undefined") {
+    args.push('--difference-threshold', options.differenceThreshold.toString())
+
+  }
+  if (typeof options.radiusTolerance !== "undefined") {
+    args.push('--radius-tolerance', options.radiusTolerance.toString())
+
+  }
+  if (typeof options.numberOfPixelsTolerance !== "undefined") {
+    args.push('--number-of-pixels-tolerance', options.numberOfPixelsTolerance.toString())
+
+  }
+  if (typeof options.ignoreBoundaryPixels !== "undefined") {
+    options.ignoreBoundaryPixels && args.push('--ignore-boundary-pixels')
+  }
+
+  const pipelinePath = 'compare-double-images'
+
+  const {
+    webWorker: usedWebWorker,
+    returnValue,
+    stderr,
+    outputs
+  } = await runPipeline(webWorker, pipelinePath, args, desiredOutputs, inputs, { pipelineBaseUrl: getPipelinesBaseUrl(), pipelineWorkerUrl: getPipelineWorkerUrl() })
+  if (returnValue !== 0) {
+    throw new Error(stderr)
+  }
+
+  const result = {
+    webWorker: usedWebWorker as Worker,
+    metrics: (outputs[0].data as JsonObject).data as CompareImagesMetric,
+    differenceImage: outputs[1].data as Image,
+    differenceUchar2dImage: outputs[2].data as Image,
+  }
+  return result
+}
+
+export default compareDoubleImages
diff --git a/packages/compare-images/typescript/src/compare-images-metric.ts b/packages/compare-images/typescript/src/compare-images-metric.ts
new file mode 100644
index 000000000..abfe05eed
--- /dev/null
+++ b/packages/compare-images/typescript/src/compare-images-metric.ts
@@ -0,0 +1,21 @@
+interface CompareImagesMetric {
+    /** Whether the test image and one of the baseline images are equal given specified tolerances. */
+    almostEqual: boolean;
+
+    /** The number of pixels that are different. */
+    numberOfPixelsDifferent: number;
+
+    /** Minimum pixel intensity difference. */
+    minimumDifference: number;
+
+    /** Maximum pixel intensity difference. */
+    maximumDifference: number;
+
+    /** Mean pixel intensity difference. */
+    meanDifference: number;
+
+    /** Total pixel intensity difference. */
+    totalDifference: number;
+}
+
+export default CompareImagesMetric
diff --git a/packages/compare-images/typescript/src/compare-images-node.ts b/packages/compare-images/typescript/src/compare-images-node.ts
new file mode 100644
index 000000000..edad51866
--- /dev/null
+++ b/packages/compare-images/typescript/src/compare-images-node.ts
@@ -0,0 +1,39 @@
+import {
+  Image,
+} from 'itk-wasm'
+
+import CompareDoubleImagesOptions from './compare-double-images-options.js'
+import CompareDoubleImagesNodeResult from './compare-double-images-node-result.js'
+import compareDoubleImagesNode from './compare-double-images-node.js'
+
+import toScalarDouble from './to-scalar-double.js'
+import vectorMagnitudeNode from './vector-magnitude-node.js'
+
+/**
+ * Compare images with a tolerance for regression testing.
+ *
+ * For multi-component images, the intensity difference threshold
+ * is based on the pixel vector magnitude.
+ *
+ * @param {Image} testImage - The input test image
+ * @param {CompareDoubleImagesOptions} options - options object
+ *
+ * @returns {Promise<CompareDoubleImagesNodeResult>} - result object
+ */
+async function compareImagesNode(
+  testImage: Image,
+  options: CompareDoubleImagesOptions = { baselineImages: [] as Image[], }
+) : Promise<CompareDoubleImagesNodeResult> {
+
+  const testImageDouble = await toScalarDouble(vectorMagnitudeNode, testImage)
+  const baselineImagesDouble = await Promise.all(options.baselineImages.map(async (image) => {
+    return await toScalarDouble(vectorMagnitudeNode, image)
+  }))
+
+  const otherOptions = { ...options }
+  otherOptions.baselineImages = baselineImagesDouble
+
+  return compareDoubleImagesNode(testImageDouble, otherOptions)
+}
+
+export default compareImagesNode
diff --git a/packages/compare-images/typescript/src/compare-images.ts b/packages/compare-images/typescript/src/compare-images.ts
new file mode 100644
index 000000000..cbd0f6abd
--- /dev/null
+++ b/packages/compare-images/typescript/src/compare-images.ts
@@ -0,0 +1,46 @@
+import {
+  Image,
+} from 'itk-wasm'
+
+import CompareDoubleImagesOptions from './compare-double-images-options.js'
+import CompareDoubleImagesResult from './compare-double-images-result.js'
+import compareDoubleImages from './compare-double-images.js'
+
+import toScalarDouble from './to-scalar-double.js'
+import vectorMagnitude from './vector-magnitude.js'
+
+/**
+ * Compare images with a tolerance for regression testing.
+ *
+ * For multi-component images, the intensity difference threshold
+ * is based on the pixel vector magnitude.
+ *
+ * @param {Image} testImage - The input test image
+ * @param {CompareDoubleImagesOptions} options - options object
+ *
+ * @returns {Promise<CompareDoubleImagesResult>} - result object
+ */
+async function compareImages(
+  webWorker: null | Worker,
+  testImage: Image,
+  options: CompareDoubleImagesOptions = { baselineImages: [] as Image[], }
+) : Promise<CompareDoubleImagesResult> {
+
+  async function vectorMagnitudeWorker(image: Image) {
+    const { webWorker: usedWebWorker, magnitudeImage } = await vectorMagnitude(null, image)
+    usedWebWorker?.terminate()
+    return { magnitudeImage }
+  }
+
+  const testImageDouble = await toScalarDouble(vectorMagnitudeWorker, testImage)
+  const baselineImagesDouble = await Promise.all(options.baselineImages.map(async (image) => {
+    return await toScalarDouble(vectorMagnitudeWorker, image)
+  }))
+
+  const otherOptions = { ...options }
+  otherOptions.baselineImages = baselineImagesDouble
+
+  return compareDoubleImages(webWorker, testImageDouble, otherOptions)
+}
+
+export default compareImages
diff --git a/packages/compare-images/typescript/src/index-node.ts b/packages/compare-images/typescript/src/index-node.ts
new file mode 100644
index 000000000..4ff7dfc95
--- /dev/null
+++ b/packages/compare-images/typescript/src/index-node.ts
@@ -0,0 +1,14 @@
+import CompareImagesMetric from './compare-images-metric.js'
+export type { CompareImagesMetric }
+
+import CompareDoubleImagesNodeResult from './compare-double-images-node-result.js'
+export type { CompareDoubleImagesNodeResult }
+
+import CompareDoubleImagesOptions from './compare-double-images-options.js'
+export type { CompareDoubleImagesOptions }
+
+import compareDoubleImagesNode from './compare-double-images-node.js'
+export { compareDoubleImagesNode }
+
+import compareImagesNode from './compare-images-node.js'
+export { compareImagesNode }
\ No newline at end of file
diff --git a/packages/compare-images/typescript/src/index.ts b/packages/compare-images/typescript/src/index.ts
new file mode 100644
index 000000000..1e59d0e4d
--- /dev/null
+++ b/packages/compare-images/typescript/src/index.ts
@@ -0,0 +1,17 @@
+export * from './pipelines-base-url.js'
+export * from './pipeline-worker-url.js'
+
+import CompareImagesMetric from './compare-images-metric.js'
+export type { CompareImagesMetric }
+
+import CompareDoubleImagesResult from './compare-double-images-result.js'
+export type { CompareDoubleImagesResult }
+
+import CompareDoubleImagesOptions from './compare-double-images-options.js'
+export type { CompareDoubleImagesOptions }
+
+import compareDoubleImages from './compare-double-images.js'
+export { compareDoubleImages }
+
+import compareImages from './compare-images.js'
+export { compareImages }
\ No newline at end of file
diff --git a/packages/compare-images/typescript/src/itkConfig.js b/packages/compare-images/typescript/src/itkConfig.js
new file mode 100644
index 000000000..be9beaf3f
--- /dev/null
+++ b/packages/compare-images/typescript/src/itkConfig.js
@@ -0,0 +1,11 @@
+import version from 'itk-wasm'
+
+const itkConfig = {
+  // Use the worker bundled by vite or webpack
+  pipelineWorkerUrl: null,
+  imageIOUrl: `https://cdn.jsdelivr.net/npm/itk-image-io@${version}`,
+  meshIOUrl: `https://cdn.jsdelivr.net/npm/itk-mesh-io@${version}`,
+  pipelinesUrl: '/pipelines'
+}
+
+export default itkConfig
diff --git a/packages/compare-images/typescript/src/pipeline-worker-url.ts b/packages/compare-images/typescript/src/pipeline-worker-url.ts
new file mode 100644
index 000000000..ec82af9e1
--- /dev/null
+++ b/packages/compare-images/typescript/src/pipeline-worker-url.ts
@@ -0,0 +1,20 @@
+import { getPipelineWorkerUrl as itkWasmGetPipelineWorkerUrl } from 'itk-wasm'
+import packageJson from '../package.json'
+
+let pipelineWorkerUrl: string | URL | null | undefined
+const defaultPipelineWorkerUrl = `https://cdn.jsdelivr.net/npm/@itk-wasm/compare-images@${packageJson.version}/dist/web-workers/pipeline.worker.js`
+
+export function setPipelineWorkerUrl (workerUrl: string | URL | null): void {
+  pipelineWorkerUrl = workerUrl
+}
+
+export function getPipelineWorkerUrl (): string | URL | null {
+  if (typeof pipelineWorkerUrl !== 'undefined') {
+    return pipelineWorkerUrl
+  }
+  const itkWasmPipelineWorkerUrl = itkWasmGetPipelineWorkerUrl()
+  if (typeof itkWasmPipelineWorkerUrl !== 'undefined') {
+    return itkWasmPipelineWorkerUrl
+  }
+  return defaultPipelineWorkerUrl
+}
diff --git a/packages/compare-images/typescript/src/pipelines-base-url.ts b/packages/compare-images/typescript/src/pipelines-base-url.ts
new file mode 100644
index 000000000..f7f222511
--- /dev/null
+++ b/packages/compare-images/typescript/src/pipelines-base-url.ts
@@ -0,0 +1,20 @@
+import { getPipelinesBaseUrl as itkWasmGetPipelinesBaseUrl } from 'itk-wasm'
+import packageJson from '../package.json'
+
+let pipelinesBaseUrl: string | URL | undefined
+let defaultPipelinesBaseUrl: string | URL = `https://cdn.jsdelivr.net/npm/@itk-wasm/compare-images@${packageJson.version}/dist/pipelines`
+
+export function setPipelinesBaseUrl (baseUrl: string | URL): void {
+  pipelinesBaseUrl = baseUrl
+}
+
+export function getPipelinesBaseUrl (): string | URL {
+  if (typeof pipelinesBaseUrl !== 'undefined') {
+    return pipelinesBaseUrl
+  }
+  const itkWasmPipelinesBaseUrl = itkWasmGetPipelinesBaseUrl()
+  if (typeof itkWasmPipelinesBaseUrl !== 'undefined') {
+    return itkWasmPipelinesBaseUrl
+  }
+  return defaultPipelinesBaseUrl
+}
diff --git a/packages/compare-images/typescript/src/to-scalar-double.ts b/packages/compare-images/typescript/src/to-scalar-double.ts
new file mode 100644
index 000000000..c2a0bee5f
--- /dev/null
+++ b/packages/compare-images/typescript/src/to-scalar-double.ts
@@ -0,0 +1,34 @@
+import {
+  Image,
+  FloatTypes,
+  PixelTypes,
+  castImage,
+} from 'itk-wasm'
+
+import VectorMagnitudeNodeResult from './vector-magnitude-node-result'
+
+async function toScalarDouble(vectorMagnitudeFn: (img: Image) => Promise<VectorMagnitudeNodeResult>, image: Image): Promise<Image> {
+  let scalarDouble = image
+
+  if (scalarDouble.imageType.componentType !== FloatTypes.Float64) {
+    let pixelType = undefined
+    if (image.imageType.pixelType !== PixelTypes.Scalar && image.imageType.pixelType !== PixelTypes.VariableLengthVector) {
+      pixelType = PixelTypes.VariableLengthVector
+    }
+    scalarDouble = castImage(image, { componentType: FloatTypes.Float64, pixelType })
+  } else {
+    if (image.imageType.pixelType !== PixelTypes.Scalar && image.imageType.pixelType !== PixelTypes.VariableLengthVector) {
+      const pixelType = PixelTypes.VariableLengthVector
+      scalarDouble = castImage(image, { pixelType })
+    }
+  }
+
+  if (scalarDouble.imageType.pixelType === PixelTypes.VariableLengthVector) {
+    const magnitude = await vectorMagnitudeFn(scalarDouble)
+    scalarDouble = magnitude.magnitudeImage
+  }
+
+  return scalarDouble
+}
+
+export default toScalarDouble
diff --git a/packages/compare-images/typescript/src/vector-magnitude-node-result.ts b/packages/compare-images/typescript/src/vector-magnitude-node-result.ts
new file mode 100644
index 000000000..3328335fa
--- /dev/null
+++ b/packages/compare-images/typescript/src/vector-magnitude-node-result.ts
@@ -0,0 +1,11 @@
+// Generated file. To retain edits, remove this comment.
+
+import { Image } from 'itk-wasm'
+
+interface VectorMagnitudeNodeResult {
+  /** Output magnitude image */
+  magnitudeImage: Image
+
+}
+
+export default VectorMagnitudeNodeResult
diff --git a/packages/compare-images/typescript/src/vector-magnitude-node.ts b/packages/compare-images/typescript/src/vector-magnitude-node.ts
new file mode 100644
index 000000000..2af2d542b
--- /dev/null
+++ b/packages/compare-images/typescript/src/vector-magnitude-node.ts
@@ -0,0 +1,65 @@
+// Generated file. To retain edits, remove this comment.
+
+import {
+  Image,
+  InterfaceTypes,
+  PipelineOutput,
+  PipelineInput,
+  runPipelineNode
+} from 'itk-wasm'
+
+import VectorMagnitudeNodeResult from './vector-magnitude-node-result.js'
+
+
+import path from 'path'
+
+/**
+ * Generate a scalar magnitude image based on the input vector's norm.
+ *
+ * @param {Image} vectorImage - Input vector image
+ *
+ * @returns {Promise<VectorMagnitudeNodeResult>} - result object
+ */
+async function vectorMagnitudeNode(
+  vectorImage: Image
+
+) : Promise<VectorMagnitudeNodeResult> {
+
+  const desiredOutputs: Array<PipelineOutput> = [
+    { type: InterfaceTypes.Image },
+  ]
+
+  const inputs: Array<PipelineInput> = [
+    { type: InterfaceTypes.Image, data: vectorImage },
+  ]
+
+  const args = []
+  // Inputs
+  const vectorImageName = '0'
+  args.push(vectorImageName as string)
+
+  // Outputs
+  const magnitudeImageName = '0'
+  args.push(magnitudeImageName)
+
+  // Options
+  args.push('--memory-io')
+
+  const pipelinePath = path.join(path.dirname(import.meta.url.substring(7)), '..', 'pipelines', 'vector-magnitude')
+
+  const {
+    returnValue,
+    stderr,
+    outputs
+  } = await runPipelineNode(pipelinePath, args, desiredOutputs, inputs)
+  if (returnValue !== 0) {
+    throw new Error(stderr)
+  }
+
+  const result = {
+    magnitudeImage: outputs[0].data as Image,
+  }
+  return result
+}
+
+export default vectorMagnitudeNode
diff --git a/packages/compare-images/typescript/src/vector-magnitude-result.ts b/packages/compare-images/typescript/src/vector-magnitude-result.ts
new file mode 100644
index 000000000..e3c120952
--- /dev/null
+++ b/packages/compare-images/typescript/src/vector-magnitude-result.ts
@@ -0,0 +1,14 @@
+// Generated file. To retain edits, remove this comment.
+
+import { Image } from 'itk-wasm'
+
+interface VectorMagnitudeResult {
+  /** WebWorker used for computation */
+  webWorker: Worker | null
+
+  /** Output magnitude image */
+  magnitudeImage: Image
+
+}
+
+export default VectorMagnitudeResult
diff --git a/packages/compare-images/typescript/src/vector-magnitude.ts b/packages/compare-images/typescript/src/vector-magnitude.ts
new file mode 100644
index 000000000..b4748fa7b
--- /dev/null
+++ b/packages/compare-images/typescript/src/vector-magnitude.ts
@@ -0,0 +1,69 @@
+// Generated file. To retain edits, remove this comment.
+
+import {
+  Image,
+  InterfaceTypes,
+  PipelineOutput,
+  PipelineInput,
+  runPipeline
+} from 'itk-wasm'
+
+import VectorMagnitudeResult from './vector-magnitude-result.js'
+
+
+import { getPipelinesBaseUrl } from './pipelines-base-url.js'
+import { getPipelineWorkerUrl } from './pipeline-worker-url.js'
+
+/**
+ * Generate a scalar magnitude image based on the input vector's norm.
+ *
+ * @param {Image} vectorImage - Input vector image
+ *
+ * @returns {Promise<VectorMagnitudeResult>} - result object
+ */
+async function vectorMagnitude(
+  webWorker: null | Worker,
+  vectorImage: Image
+
+) : Promise<VectorMagnitudeResult> {
+
+  const desiredOutputs: Array<PipelineOutput> = [
+    { type: InterfaceTypes.Image },
+  ]
+
+  const inputs: Array<PipelineInput> = [
+    { type: InterfaceTypes.Image, data: vectorImage },
+  ]
+
+  const args = []
+  // Inputs
+  const vectorImageName = '0'
+  args.push(vectorImageName as string)
+
+  // Outputs
+  const magnitudeImageName = '0'
+  args.push(magnitudeImageName)
+
+  // Options
+  args.push('--memory-io')
+
+  const pipelinePath = 'vector-magnitude'
+
+  const {
+    webWorker: usedWebWorker,
+    returnValue,
+    stderr,
+    outputs
+  } = await runPipeline(webWorker, pipelinePath, args, desiredOutputs, inputs, { pipelineBaseUrl: getPipelinesBaseUrl(), pipelineWorkerUrl: getPipelineWorkerUrl() })
+  if (returnValue !== 0) {
+    throw new Error(stderr)
+  }
+
+  const result = {
+    webWorker: usedWebWorker as Worker,
+    magnitudeImage: outputs[0].data as Image,
+  }
+  return result
+}
+
+export default vectorMagnitude
diff --git a/packages/compare-images/typescript/tsconfig.json b/packages/compare-images/typescript/tsconfig.json
new file mode 100644
index 000000000..4eba4b8ec
--- /dev/null
+++ b/packages/compare-images/typescript/tsconfig.json
@@ -0,0 +1,23 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ESNext", "DOM"],
+    "moduleResolution": "Node",
+    "strict": true,
+    "sourceMap": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "esModuleInterop": true,
+    "noEmit": false,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noImplicitReturns": true,
+    "skipLibCheck": true,
+    "declaration": true,
+    "emitDeclarationOnly": true,
+    "declarationDir": "dist/"
+  },
+  "include": ["src/*.ts"]
+}
diff --git a/packages/compare-images/vector-magnitude.cxx b/packages/compare-images/vector-magnitude.cxx
new file mode 100644
index 000000000..7b583126f
--- /dev/null
+++ b/packages/compare-images/vector-magnitude.cxx
@@ -0,0 +1,67 @@
+/*=========================================================================
+
+ *  Copyright NumFOCUS
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *         https://www.apache.org/licenses/LICENSE-2.0.txt
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ *=========================================================================*/
+
+#include "itkPipeline.h"
+#include "itkInputImage.h"
+#include "itkOutputImage.h"
+#include "itkSupportInputImageTypes.h"
+
+#include "itkImage.h"
+#include "itkVectorImage.h"
+#include "itkVectorMagnitudeImageFilter.h"
+
+template<typename TImage>
+class PipelineFunctor
+{
+public:
+  int operator()(itk::wasm::Pipeline & pipeline)
+  {
+    using ImageType = TImage;
+    using ScalarType = typename ImageType::PixelType::ValueType;
+    using ScalarImageType = itk::Image<ScalarType, ImageType::ImageDimension>;
+    using MagnitudeFilterType = itk::VectorMagnitudeImageFilter<ImageType, ScalarImageType>;
+
+    using InputImageType = itk::wasm::InputImage<ImageType>;
+    InputImageType vectorImage;
+    pipeline.add_option("vector-image", vectorImage, "Input vector image")->required()->type_name("INPUT_IMAGE");
+
+    using OutputImageType = itk::wasm::OutputImage<ScalarImageType>;
+    OutputImageType magnitudeImage;
+    pipeline.add_option("magnitude-image", magnitudeImage, "Output magnitude image")->required()->type_name("OUTPUT_IMAGE");
+
+    ITK_WASM_PARSE(pipeline);
+
+    auto magnitudeFilter = MagnitudeFilterType::New();
+    magnitudeFilter->SetInput(vectorImage.Get());
+    ITK_WASM_CATCH_EXCEPTION(pipeline, magnitudeFilter->UpdateLargestPossibleRegion());
+
+    typename ScalarImageType::ConstPointer magnitude = magnitudeFilter->GetOutput();
+    magnitudeImage.Set(magnitude);
+
+    return EXIT_SUCCESS;
+  }
+};
+
+int main(int argc, char * argv[])
+{
+  itk::wasm::Pipeline pipeline("vector-magnitude", "Generate a scalar magnitude image based on the input vector's norm.", argc, argv);
+
+  return itk::wasm::SupportInputImageTypes<PipelineFunctor,
+  itk::VariableLengthVector<double> >
+  ::Dimensions<2U,3U,4U,5U,6U>("vector-image", pipeline);
+}
\ No newline at end of file
diff --git a/packages/compress-stringify/python/itkwasm-compress-stringify-emscripten/pyproject.toml b/packages/compress-stringify/python/itkwasm-compress-stringify-emscripten/pyproject.toml
index 97154cc5c..6614e683e 100644
--- a/packages/compress-stringify/python/itkwasm-compress-stringify-emscripten/pyproject.toml
+++ b/packages/compress-stringify/python/itkwasm-compress-stringify-emscripten/pyproject.toml
@@ -32,7 +32,7 @@ keywords = [
 
 requires-python = ">=3.7"
 dependencies = [
-    "itkwasm >= 1.0.b105",
+    "itkwasm >= 1.0.b130",
 ]
 
 [project.urls]
diff --git a/packages/compress-stringify/python/itkwasm-compress-stringify-wasi/pyproject.toml b/packages/compress-stringify/python/itkwasm-compress-stringify-wasi/pyproject.toml
index 7cf12d76d..1627e69cc 100644
--- a/packages/compress-stringify/python/itkwasm-compress-stringify-wasi/pyproject.toml
+++ b/packages/compress-stringify/python/itkwasm-compress-stringify-wasi/pyproject.toml
@@ -32,7 +32,7 @@ keywords = [
 
 requires-python = ">=3.7"
 dependencies = [
-    "itkwasm >= 1.0.b105",
+    "itkwasm >= 1.0.b130",
     "importlib_resources",
 
 ]
diff --git a/packages/compress-stringify/python/itkwasm-compress-stringify/pyproject.toml b/packages/compress-stringify/python/itkwasm-compress-stringify/pyproject.toml
index 4a5f2aa4a..8644327b4 100644
--- a/packages/compress-stringify/python/itkwasm-compress-stringify/pyproject.toml
+++ b/packages/compress-stringify/python/itkwasm-compress-stringify/pyproject.toml
@@ -33,7 +33,7 @@ keywords = [
 
 requires-python = ">=3.7"
 dependencies = [
-    "itkwasm >= 1.0.b105",
+    "itkwasm >= 1.0.b130",
     "itkwasm-compress-stringify-wasi; sys_platform != \"emscripten\"",
     "itkwasm-compress-stringify-emscripten; sys_platform == \"emscripten\"",
 ]
diff --git a/packages/core/python/itkwasm/itkwasm/__init__.py b/packages/core/python/itkwasm/itkwasm/__init__.py
index 446487a3c..9ac1ae8f1 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.0b105"
+__version__ = "1.0b130"
 
 from .interface_types import InterfaceTypes
 from .image import Image, ImageType
@@ -19,6 +19,7 @@
 from .int_types import IntTypes
 from .pixel_types import PixelTypes
 from .environment_dispatch import environment_dispatch, function_factory
+from .cast_image import cast_image
 
 __all__ = [
   "InterfaceTypes",
@@ -43,4 +44,5 @@
   "PixelTypes",
   "environment_dispatch",
   "function_factory",
+  "cast_image",
   ]
diff --git a/packages/core/python/itkwasm/itkwasm/cast_image.py b/packages/core/python/itkwasm/itkwasm/cast_image.py
new file mode 100644
index 000000000..81f77ae20
--- /dev/null
+++ b/packages/core/python/itkwasm/itkwasm/cast_image.py
@@ -0,0 +1,63 @@
+from typing import Optional, Union
+from dataclasses import asdict
+import copy
+
+import numpy as np
+
+from .image import Image, ImageType
+from .pixel_types import PixelTypes
+from .int_types import IntTypes
+from .float_types import FloatTypes
+
+def cast_image(input_image: Image,
+               pixel_type: Optional[PixelTypes]=None,
+               component_type: Optional[Union[IntTypes, FloatTypes]]=None) -> Image:
+    """Cast an image to another pixel type and / or component type."""
+    output_image_type = ImageType(**asdict(input_image.imageType))
+
+    if pixel_type is not None:
+        output_image_type.pixelType = pixel_type
+        if pixel_type == PixelTypes.Scalar and output_image_type.components != 1:
+            raise ValueError("PixelType Scalar requires components == 1")
+
+    if component_type is not None and component_type != input_image.imageType.componentType:
+        output_image_type.componentType = component_type
+
+    output_image = Image(output_image_type)
+
+    output_image.name = input_image.name
+    output_image.origin = list(input_image.origin)
+    output_image.spacing = list(input_image.spacing)
+    output_image.direction = input_image.direction.copy()
+    output_image.size = list(input_image.size)
+    output_image.metadata = copy.deepcopy(input_image.metadata)
+
+    if input_image.data is not None:
+        if output_image_type.componentType == input_image.imageType.componentType:
+            output_image.data = input_image.data.copy()
+        else:
+            component_type = output_image_type.componentType
+            if component_type == IntTypes.UInt8:
+                output_image.data = input_image.data.astype(np.uint8)
+            elif component_type == IntTypes.Int8:
+                output_image.data = input_image.data.astype(np.int8)
+            elif component_type == IntTypes.UInt16:
+                output_image.data = input_image.data.astype(np.uint16)
+            elif component_type == IntTypes.Int16:
+                output_image.data = input_image.data.astype(np.int16)
+            elif component_type == IntTypes.UInt32:
+                output_image.data = input_image.data.astype(np.uint32)
+            elif component_type == IntTypes.Int32:
+                output_image.data = input_image.data.astype(np.int32)
+            elif component_type == IntTypes.UInt64:
+                output_image.data = input_image.data.astype(np.uint64)
+            elif component_type == IntTypes.Int64:
+                output_image.data = input_image.data.astype(np.int64)
+            elif component_type == FloatTypes.Float32:
+                output_image.data = input_image.data.astype(np.float32)
+            elif component_type == FloatTypes.Float64:
+                output_image.data = input_image.data.astype(np.float64)
+            else:
+                raise ValueError('Unsupported component type')
+
+    return output_image
diff --git a/packages/core/python/itkwasm/itkwasm/pyodide.py b/packages/core/python/itkwasm/itkwasm/pyodide.py
index bdb25880b..e2297db30 100644
--- a/packages/core/python/itkwasm/itkwasm/pyodide.py
+++ b/packages/core/python/itkwasm/itkwasm/pyodide.py
@@ -163,8 +163,10 @@ def to_js(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']:
+        if 'metadata' in image_dict:
             image_dict['metadata'] = pyodide.ffi.to_js(image_dict['metadata'], dict_converter=js.Map.new)
+        else:
+            image_dict['metadata'] = pyodide.ffi.to_js(dict(), 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)
diff --git a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/apply_presentation_state_to_image_async.py b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/apply_presentation_state_to_image_async.py
index 0f2d8c318..196f9e4ee 100644
--- a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/apply_presentation_state_to_image_async.py
+++ b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/apply_presentation_state_to_image_async.py
@@ -1,8 +1,6 @@
-# Generated file. Do not edit.
-
 from pathlib import Path
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from .js_package import js_package
 
diff --git a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/read_dicom_encapsulated_pdf_async.py b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/read_dicom_encapsulated_pdf_async.py
index 00bfab585..9ab05f49d 100644
--- a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/read_dicom_encapsulated_pdf_async.py
+++ b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/read_dicom_encapsulated_pdf_async.py
@@ -2,7 +2,7 @@
 
 from pathlib import Path
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from .js_package import js_package
 
diff --git a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/read_image_dicom_file_series_async.py b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/read_image_dicom_file_series_async.py
index b27a79d8e..759c0d5cd 100644
--- a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/read_image_dicom_file_series_async.py
+++ b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/read_image_dicom_file_series_async.py
@@ -1,5 +1,3 @@
-# Generated file. Do not edit.
-
 from pathlib import Path
 import os
 from typing import Dict, Tuple, Optional, List
@@ -20,7 +18,7 @@
 async def read_image_dicom_file_series_async(
     input_images: List[os.PathLike] = [],
     single_sorted_series: bool = False,
-) -> Tuple[Image, Dict]:
+) -> Tuple[Image, List[str]]:
     """Read a DICOM image series and return the associated image volume
 
     :param input_images: File names in the series
@@ -33,7 +31,7 @@ async def read_image_dicom_file_series_async(
     :rtype:  Image
 
     :return: Output sorted filenames
-    :rtype:  Dict
+    :rtype:  List[str]
     """
     js_module = await js_package.js_module
     web_worker = js_resources.web_worker
diff --git a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/structured_report_to_html_async.py b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/structured_report_to_html_async.py
index 75de25957..040438210 100644
--- a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/structured_report_to_html_async.py
+++ b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/structured_report_to_html_async.py
@@ -2,7 +2,7 @@
 
 from pathlib import Path
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from .js_package import js_package
 
diff --git a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/structured_report_to_text_async.py b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/structured_report_to_text_async.py
index a09a92a90..f6bc74a3b 100644
--- a/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/structured_report_to_text_async.py
+++ b/packages/dicom/python/itkwasm-dicom-emscripten/itkwasm_dicom_emscripten/structured_report_to_text_async.py
@@ -2,7 +2,7 @@
 
 from pathlib import Path
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from .js_package import js_package
 
diff --git a/packages/dicom/python/itkwasm-dicom-emscripten/pyproject.toml b/packages/dicom/python/itkwasm-dicom-emscripten/pyproject.toml
index f7e8c676e..d28aa52c8 100644
--- a/packages/dicom/python/itkwasm-dicom-emscripten/pyproject.toml
+++ b/packages/dicom/python/itkwasm-dicom-emscripten/pyproject.toml
@@ -32,7 +32,7 @@ keywords = [
 
 requires-python = ">=3.7"
 dependencies = [
-    "itkwasm >= 1.0.b105",
+    "itkwasm >= 1.0.b130",
 ]
 
 [tool.hatch.version]
diff --git a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/apply_presentation_state_to_image.py b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/apply_presentation_state_to_image.py
index 942775c9b..c125faa94 100644
--- a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/apply_presentation_state_to_image.py
+++ b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/apply_presentation_state_to_image.py
@@ -1,5 +1,3 @@
-# Generated file. Do not edit.
-
 from pathlib import Path, PurePosixPath
 import os
 from typing import Dict, Tuple, Optional, List
diff --git a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/read_dicom_encapsulated_pdf.py b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/read_dicom_encapsulated_pdf.py
index db09e03ec..0b1432c7c 100644
--- a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/read_dicom_encapsulated_pdf.py
+++ b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/read_dicom_encapsulated_pdf.py
@@ -2,7 +2,7 @@
 
 from pathlib import Path, PurePosixPath
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from importlib_resources import files as file_resources
 
diff --git a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/read_image_dicom_file_series.py b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/read_image_dicom_file_series.py
index 4a7b8fa5e..f86f3d152 100644
--- a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/read_image_dicom_file_series.py
+++ b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/read_image_dicom_file_series.py
@@ -1,5 +1,3 @@
-# Generated file. Do not edit.
-
 from pathlib import Path, PurePosixPath
 import os
 from typing import Dict, Tuple, Optional, List
@@ -20,7 +18,7 @@
 def read_image_dicom_file_series(
     input_images: List[os.PathLike] = [],
     single_sorted_series: bool = False,
-) -> Tuple[Image, Dict]:
+) -> Tuple[Image, List[str]]:
     """Read a DICOM image series and return the associated image volume
 
     :param input_images: File names in the series
@@ -33,7 +31,7 @@ def read_image_dicom_file_series(
     :rtype:  Image
 
     :return: Output sorted filenames
-    :rtype:  Dict
+    :rtype:  List[str]
     """
     global _pipeline
     if _pipeline is None:
@@ -53,10 +51,9 @@ def read_image_dicom_file_series(
     args.append('0')
     args.append('1')
     # Options
-    if len(input_images) > 1:
-        if len(input_images) < 1:
-            raise ValueError('"input-images" option must have a length > 1')
-
+    if len(input_images) < 1:
+       raise ValueError('"input-images" kwarg must have a length > 1')
+    if len(input_images) > 0:
         args.append('--input-images')
         for value in input_images:
             input_file = str(PurePosixPath(input_images))
diff --git a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/structured_report_to_html.py b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/structured_report_to_html.py
index 1edd9fc8e..3d1167535 100644
--- a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/structured_report_to_html.py
+++ b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/structured_report_to_html.py
@@ -2,7 +2,7 @@
 
 from pathlib import Path, PurePosixPath
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from importlib_resources import files as file_resources
 
diff --git a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/structured_report_to_text.py b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/structured_report_to_text.py
index a9cd04f18..173ee5e1c 100644
--- a/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/structured_report_to_text.py
+++ b/packages/dicom/python/itkwasm-dicom-wasi/itkwasm_dicom_wasi/structured_report_to_text.py
@@ -2,7 +2,7 @@
 
 from pathlib import Path, PurePosixPath
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from importlib_resources import files as file_resources
 
diff --git a/packages/dicom/python/itkwasm-dicom-wasi/pyproject.toml b/packages/dicom/python/itkwasm-dicom-wasi/pyproject.toml
index 0eadf9746..c23750982 100644
--- a/packages/dicom/python/itkwasm-dicom-wasi/pyproject.toml
+++ b/packages/dicom/python/itkwasm-dicom-wasi/pyproject.toml
@@ -32,7 +32,7 @@ keywords = [
 
 requires-python = ">=3.7"
 dependencies = [
-    "itkwasm >= 1.0.b105",
+    "itkwasm >= 1.0.b130",
     "importlib_resources",
 ]
 
diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/apply_presentation_state_to_image.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/apply_presentation_state_to_image.py
index 26fb0e121..30b572a52 100644
--- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/apply_presentation_state_to_image.py
+++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/apply_presentation_state_to_image.py
@@ -1,7 +1,7 @@
 # Generated file. Do not edit.
 
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from itkwasm import (
     environment_dispatch,
@@ -17,7 +17,7 @@ def apply_presentation_state_to_image(
     frame: int = 1,
     no_presentation_state_output: bool = False,
     no_bitmap_output: bool = False,
-) -> Tuple[Dict, Image]:
+) -> Tuple[Any, Image]:
     """Apply a presentation state to a given DICOM image and render output as bitmap, or dicom file.
 
     :param image_in: Input DICOM file
@@ -42,7 +42,7 @@ def apply_presentation_state_to_image(
     :type  no_bitmap_output: bool
 
     :return: Output overlay information
-    :rtype:  Dict
+    :rtype:  Any
 
     :return: Output image
     :rtype:  Image
diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/apply_presentation_state_to_image_async.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/apply_presentation_state_to_image_async.py
index 496c6d6e6..0244b908c 100644
--- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/apply_presentation_state_to_image_async.py
+++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/apply_presentation_state_to_image_async.py
@@ -1,7 +1,7 @@
 # Generated file. Do not edit.
 
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from itkwasm import (
     environment_dispatch,
@@ -17,7 +17,7 @@ async def apply_presentation_state_to_image_async(
     frame: int = 1,
     no_presentation_state_output: bool = False,
     no_bitmap_output: bool = False,
-) -> Tuple[Dict, Image]:
+) -> Tuple[Any, Image]:
     """Apply a presentation state to a given DICOM image and render output as bitmap, or dicom file.
 
     :param image_in: Input DICOM file
@@ -42,7 +42,7 @@ async def apply_presentation_state_to_image_async(
     :type  no_bitmap_output: bool
 
     :return: Output overlay information
-    :rtype:  Dict
+    :rtype:  Any
 
     :return: Output image
     :rtype:  Image
diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_dicom_encapsulated_pdf.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_dicom_encapsulated_pdf.py
index 945013615..a24bd2ca7 100644
--- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_dicom_encapsulated_pdf.py
+++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_dicom_encapsulated_pdf.py
@@ -1,7 +1,7 @@
 # Generated file. Do not edit.
 
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from itkwasm import (
     environment_dispatch,
diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_dicom_encapsulated_pdf_async.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_dicom_encapsulated_pdf_async.py
index 714bcf40a..9c49bc1d9 100644
--- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_dicom_encapsulated_pdf_async.py
+++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_dicom_encapsulated_pdf_async.py
@@ -1,7 +1,7 @@
 # Generated file. Do not edit.
 
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from itkwasm import (
     environment_dispatch,
diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_image_dicom_file_series.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_image_dicom_file_series.py
index 355feaa85..6bdaa757e 100644
--- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_image_dicom_file_series.py
+++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_image_dicom_file_series.py
@@ -1,7 +1,7 @@
 # Generated file. Do not edit.
 
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from itkwasm import (
     environment_dispatch,
@@ -12,7 +12,7 @@
 def read_image_dicom_file_series(
     input_images: List[os.PathLike] = [],
     single_sorted_series: bool = False,
-) -> Tuple[Image, Dict]:
+) -> Tuple[Image, Any]:
     """Read a DICOM image series and return the associated image volume
 
     :param input_images: File names in the series
@@ -25,7 +25,7 @@ def read_image_dicom_file_series(
     :rtype:  Image
 
     :return: Output sorted filenames
-    :rtype:  Dict
+    :rtype:  Any
     """
     func = environment_dispatch("itkwasm_dicom", "read_image_dicom_file_series")
     output = func(input_images=input_images, single_sorted_series=single_sorted_series)
diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_image_dicom_file_series_async.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_image_dicom_file_series_async.py
index 4a9169167..25083ddb8 100644
--- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_image_dicom_file_series_async.py
+++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/read_image_dicom_file_series_async.py
@@ -1,7 +1,7 @@
 # Generated file. Do not edit.
 
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from itkwasm import (
     environment_dispatch,
@@ -12,7 +12,7 @@
 async def read_image_dicom_file_series_async(
     input_images: List[os.PathLike] = [],
     single_sorted_series: bool = False,
-) -> Tuple[Image, Dict]:
+) -> Tuple[Image, Any]:
     """Read a DICOM image series and return the associated image volume
 
     :param input_images: File names in the series
@@ -25,7 +25,7 @@ async def read_image_dicom_file_series_async(
     :rtype:  Image
 
     :return: Output sorted filenames
-    :rtype:  Dict
+    :rtype:  Any
     """
     func = environment_dispatch("itkwasm_dicom", "read_image_dicom_file_series_async")
     output = await func(input_images=input_images, single_sorted_series=single_sorted_series)
diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_html.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_html.py
index 08aedf246..df78180b4 100644
--- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_html.py
+++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_html.py
@@ -1,7 +1,7 @@
 # Generated file. Do not edit.
 
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from itkwasm import (
     environment_dispatch,
diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_html_async.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_html_async.py
index 02e867840..2e648038b 100644
--- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_html_async.py
+++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_html_async.py
@@ -1,7 +1,7 @@
 # Generated file. Do not edit.
 
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from itkwasm import (
     environment_dispatch,
diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_text.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_text.py
index b6eb84687..1a432dc9d 100644
--- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_text.py
+++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_text.py
@@ -1,7 +1,7 @@
 # Generated file. Do not edit.
 
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from itkwasm import (
     environment_dispatch,
diff --git a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_text_async.py b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_text_async.py
index 08a35dc4e..fc06f2f43 100644
--- a/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_text_async.py
+++ b/packages/dicom/python/itkwasm-dicom/itkwasm_dicom/structured_report_to_text_async.py
@@ -1,7 +1,7 @@
 # Generated file. Do not edit.
 
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from itkwasm import (
     environment_dispatch,
diff --git a/packages/dicom/python/itkwasm-dicom/pyproject.toml b/packages/dicom/python/itkwasm-dicom/pyproject.toml
index 6dcce5c1f..e4d0049bc 100644
--- a/packages/dicom/python/itkwasm-dicom/pyproject.toml
+++ b/packages/dicom/python/itkwasm-dicom/pyproject.toml
@@ -33,7 +33,7 @@ keywords = [
 
 requires-python = ">=3.7"
 dependencies = [
-    "itkwasm >= 1.0.b105",
+    "itkwasm >= 1.0.b130",
     "itkwasm-dicom-wasi; sys_platform != \"emscripten\"",
     "itkwasm-dicom-emscripten; sys_platform == \"emscripten\"",
 
diff --git a/src/bindgen/python/dispatch-function-module.js b/src/bindgen/python/dispatch-function-module.js
index 2680ac737..34a6ed32a 100644
--- a/src/bindgen/python/dispatch-function-module.js
+++ b/src/bindgen/python/dispatch-function-module.js
@@ -12,7 +12,7 @@ function dispatchFunctionModule(interfaceJson, pypackage, modulePath) {
   let moduleContent = `# Generated file. Do not edit.
 
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from itkwasm import (
     environment_dispatch,`
diff --git a/src/bindgen/python/emscripten/emscripten-function-module.js b/src/bindgen/python/emscripten/emscripten-function-module.js
index ca74dd89e..bd2a1a59b 100644
--- a/src/bindgen/python/emscripten/emscripten-function-module.js
+++ b/src/bindgen/python/emscripten/emscripten-function-module.js
@@ -8,6 +8,7 @@ import functionModuleArgs from '../function-module-args.js'
 import functionModuleReturnType from '../function-module-return-type.js'
 import functionModuleDocstring from '../function-module-docstring.js'
 import interfaceJsonTypeToInterfaceType from '../../interface-json-type-to-interface-type.js'
+import writeIfOverrideNotPresent from '../../write-if-override-not-present.js'
 
 function emscriptenFunctionModule(interfaceJson, pypackage, modulePath) {
   const functionName = snakeCase(interfaceJson.name)
@@ -15,7 +16,7 @@ function emscriptenFunctionModule(interfaceJson, pypackage, modulePath) {
 
 from pathlib import Path
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from .js_package import js_package
 
@@ -99,7 +100,7 @@ ${addKwargs}
         return output_list[0]
     return tuple(output_list)
 `
-  fs.writeFileSync(modulePath, moduleContent)
+  writeIfOverrideNotPresent(modulePath, moduleContent, '#')
 }
 
 export default emscriptenFunctionModule
diff --git a/src/bindgen/python/interface-json-type-to-python-type.js b/src/bindgen/python/interface-json-type-to-python-type.js
index 2c6dac202..9e9027090 100644
--- a/src/bindgen/python/interface-json-type-to-python-type.js
+++ b/src/bindgen/python/interface-json-type-to-python-type.js
@@ -25,8 +25,8 @@ const interfaceJsonTypeToPythonType = new Map([
   ['UINT:UINT', 'int'],
   ['FLOAT', 'float'],
   ['FLOAT:FLOAT', 'float'],
-  ['INPUT_JSON', 'Dict'],
-  ['OUTPUT_JSON', 'Dict'],
+  ['INPUT_JSON', 'Any'],
+  ['OUTPUT_JSON', 'Any'],
 ])
 
 export default interfaceJsonTypeToPythonType
diff --git a/src/bindgen/python/package-dunder-init.js b/src/bindgen/python/package-dunder-init.js
index ca9b5cbf4..00aa36a37 100644
--- a/src/bindgen/python/package-dunder-init.js
+++ b/src/bindgen/python/package-dunder-init.js
@@ -1,9 +1,9 @@
-import fs from 'fs-extra'
 import path from 'path'
 
 import snakeCase from "../snake-case.js"
 
 import wasmBinaryInterfaceJson from "../wasm-binary-interface-json.js"
+import writeIfOverrideNotPresent from '../write-if-override-not-present.js'
 
 function packageDunderInit(outputDir, buildDir, wasmBinaries, packageName, packageDescription, packageDir, pypackage, async, sync) {
   const functionNames = []
@@ -26,7 +26,7 @@ ${functionImports}
 from ._version import __version__
 `
   const dunderInitPath = path.join(packageDir, pypackage, '__init__.py')
-  fs.writeFileSync(dunderInitPath, dunderInit)
+  writeIfOverrideNotPresent(dunderInitPath, dunderInit, "#")
 }
 
 export default packageDunderInit
diff --git a/src/bindgen/python/resources/template.pyproject.toml b/src/bindgen/python/resources/template.pyproject.toml
index 7e57c82ff..b4a24e9f1 100644
--- a/src/bindgen/python/resources/template.pyproject.toml
+++ b/src/bindgen/python/resources/template.pyproject.toml
@@ -31,7 +31,7 @@ keywords = [
 
 requires-python = ">=3.7"
 dependencies = [
-    "itkwasm >= 1.0.b105",@bindgenDependencies@
+    "itkwasm >= 1.0.b130",@bindgenDependencies@
 ]
 
 [tool.hatch.version]
diff --git a/src/bindgen/python/wasi/wasi-function-module.js b/src/bindgen/python/wasi/wasi-function-module.js
index 6ff4518cb..73850f2b7 100644
--- a/src/bindgen/python/wasi/wasi-function-module.js
+++ b/src/bindgen/python/wasi/wasi-function-module.js
@@ -9,6 +9,7 @@ import functionModuleDocstring from '../function-module-docstring.js'
 import functionModuleArgs from '../function-module-args.js'
 import interfaceJsonTypeToInterfaceType from '../../interface-json-type-to-interface-type.js'
 import interfaceJsonTypeToPythonType from '../interface-json-type-to-python-type.js'
+import writeIfOverrideNotPresent from '../../write-if-override-not-present.js'
 
 function wasiFunctionModule(interfaceJson, pypackage, modulePath) {
   const functionName = snakeCase(interfaceJson.name)
@@ -16,7 +17,7 @@ function wasiFunctionModule(interfaceJson, pypackage, modulePath) {
 
 from pathlib import Path, PurePosixPath
 import os
-from typing import Dict, Tuple, Optional, List
+from typing import Dict, Tuple, Optional, List, Any
 
 from importlib_resources import files as file_resources
 
@@ -111,10 +112,9 @@ from itkwasm import (
       args += `    if ${snake}:\n`
       args += `        args.append('--${parameter.name}')\n`
     } else if (parameter.itemsExpectedMax > 1) {
-      args += `    if len(${snake}) > 1:\n`
-      args += `        if len(${snake}) < ${parameter.itemsExpectedMin}:\n`
-      args += `            raise ValueError('"${parameter.name}" option must have a length > ${parameter.itemsExpectedMin}')\n`
-      args += `\n`
+      args += `    if len(${snake}) < ${parameter.itemsExpectedMin}:\n`
+      args += `       raise ValueError('"${parameter.name}" kwarg must have a length > ${parameter.itemsExpectedMin}')\n`
+      args += `    if len(${snake}) > 0:\n`
       args += `        args.append('--${parameter.name}')\n`
       args += `        for value in ${snake}:\n`
       if (interfaceJsonTypeToInterfaceType.has(parameter.type)) {
@@ -132,7 +132,7 @@ from itkwasm import (
         } else {
           // Image, Mesh, PolyData, JsonObject
           args += `            input_count_string = str(len(pipeline_inputs))\n`
-          args += `            pipeline_inputs.push(PipelineInput(InterfaceTypes.${interfaceType}, value))\n`
+          args += `            pipeline_inputs.append(PipelineInput(InterfaceTypes.${interfaceType}, value))\n`
           args += `            args.append(input_count_string)\n`
         }
       } else {
@@ -191,7 +191,7 @@ from itkwasm import (
         return `bool(${value})`
       case "float":
         return `float(${value})`
-      case "Dict":
+      case "Any":
         return `${value}.data.data`
       default:
         return `${value}.data`
@@ -229,7 +229,7 @@ ${args}
 
 ${postOutput}
 `
-  fs.writeFileSync(modulePath, moduleContent)
+  writeIfOverrideNotPresent(modulePath, moduleContent, '#')
 }
 
 export default wasiFunctionModule
diff --git a/src/bindgen/typescript/demo/all-demo-types-supported.js b/src/bindgen/typescript/demo/all-demo-types-supported.js
index caf810107..5f374779d 100644
--- a/src/bindgen/typescript/demo/all-demo-types-supported.js
+++ b/src/bindgen/typescript/demo/all-demo-types-supported.js
@@ -9,6 +9,7 @@ const demoSupportedInputTypes = new Set([
   'TEXT',
   'INT',
   'UINT',
+  'FLOAT',
   'BOOL',
   'INPUT_JSON',
   'INPUT_IMAGE',
diff --git a/src/bindgen/typescript/demo/input-parameters-demo-html.js b/src/bindgen/typescript/demo/input-parameters-demo-html.js
index f60d65c49..63e82c894 100644
--- a/src/bindgen/typescript/demo/input-parameters-demo-html.js
+++ b/src/bindgen/typescript/demo/input-parameters-demo-html.js
@@ -14,7 +14,7 @@ function inputParametersDemoHtml (functionName, prefix, indent, parameter, requi
       result += `${prefix}${indent}<sl-tooltip ${tooltipContent}><sl-details id="${functionName}-${parameter.name}-details"  summary="${label}: ${description}" disabled></sl-details></sl-tooltip>\n`
       const multiple = parameter.itemsExpectedMax > 1 ? 'multiple ' : ''
       result += `${prefix}${indent}<label for="${parameter.name}-file"><sl-button name="${parameter.name}-file-button" variant="primary" outline onclick="this.parentElement.nextElementSibling.click()">Upload</sp-button></label><input type="file" ${multiple} name="${parameter.name}-file" style="display: none"/>\n`
-      result += `<br /><br />\n`
+      result += '<br /><br />\n'
     }
       break
     case 'INPUT_BINARY_FILE':
@@ -23,31 +23,42 @@ function inputParametersDemoHtml (functionName, prefix, indent, parameter, requi
       result += `${prefix}${indent}<sl-tooltip ${tooltipContent}><sl-details id="${functionName}-${parameter.name}-details" summary="${label}: ${description}" disabled></sl-details></sl-tooltip>\n`
       const multiple = parameter.itemsExpectedMax > 1 ? 'multiple ' : ''
       result += `${prefix}${indent}<label for="${parameter.name}-file"><sl-button name="${parameter.name}-file-button" ${requiredAttr}variant="primary" outline onclick="this.parentElement.nextElementSibling.click()">Upload</sl-button></label><input type="file" ${multiple} name="${parameter.name}-file" style="display: none"/>\n`
-      result += `<br /><br />\n`
+      result += '<br /><br />\n'
     }
       break
     case 'TEXT':
       result += `${prefix}${indent}<sl-input ${requiredAttr}name="${parameter.name}" type="text" label="${label}" help-text="${description}"></sl-input>\n`
       break
     case 'INT':
+    case 'UINT':
+    case 'FLOAT': {
       if (parameter.itemsExpected !== 1 || parameter.itemsExpectedMin !== 1 || parameter.itemsExpectedMax !== 1) {
         // TODO
-        console.error(`INT items != 1 are currently not supported`)
+        console.error('items != 1 are currently not supported')
         process.exit(1)
       }
-      result += `${prefix}${indent}<sl-input ${requiredAttr}name="${parameter.name}" type="number" value="${parameter.default}" label="${label}" help-text="${description}"></sl-input>\n`
-      result += `<br />\n`
+      let constraints = ''
+      if (parameter.type === 'INT') {
+        constraints = 'step="1"'
+      } else if (parameter.type === 'UINT') {
+        constraints = 'min="0" step="1"'
+      } else if (parameter.type === 'FLOAT') {
+        constraints = 'step="any"'
+      }
+      result += `${prefix}${indent}<sl-input ${requiredAttr}name="${parameter.name}" type="number" value="${parameter.default}" ${constraints} label="${label}" help-text="${description}"></sl-input>\n`
+      result += '<br />\n'
+    }
       break
     case 'BOOL':
       result += `${prefix}${indent}<sl-checkbox name="${parameter.name}">${label} - <i>${description}</i></sl-checkbox>\n`
-      result += `<br /><br />\n`
+      result += '<br /><br />\n'
       break
     case 'INPUT_JSON':
     case 'INPUT_IMAGE':
     case 'INPUT_MESH':
       result += `${prefix}${indent}<sl-tooltip ${tooltipContent}><sl-details id="${functionName}-${parameter.name}-details" summary="${label}: ${description}" disabled></sl-details></sl-tooltip>\n`
       result += `${prefix}${indent}<label for="${parameter.name}-file"><sl-button name="${parameter.name}-file-button" variant="primary" outline onclick="this.parentElement.nextElementSibling.click()">Upload</sp-button></label><input type="file" name="${parameter.name}-file" style="display: none"/>\n`
-      result += `<br /><br />\n`
+      result += '<br /><br />\n'
       break
     default:
       console.error(`Unexpected interface type: ${parameter.type}`)
diff --git a/src/bindgen/typescript/demo/input-parameters-demo-typescript.js b/src/bindgen/typescript/demo/input-parameters-demo-typescript.js
index d706f6641..c6323c869 100644
--- a/src/bindgen/typescript/demo/input-parameters-demo-typescript.js
+++ b/src/bindgen/typescript/demo/input-parameters-demo-typescript.js
@@ -7,7 +7,7 @@ function inputParametersDemoTypeScript(functionName, indent, parameter, required
   switch (parameter.type) {
     case 'INPUT_TEXT_FILE':
     case 'INPUT_TEXT_FILE:FILE':
-    case 'INPUT_TEXT_STREAM': {
+    case 'INPUT_TEXT_STREAM':
       result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs input[name=${parameter.name}-file]')\n`
       result += `${indent}${inputIdentifier}.addEventListener('change', async (event) => {\n`
       result += `${indent}${indent}const dataTransfer = event.dataTransfer\n`
@@ -28,11 +28,10 @@ function inputParametersDemoTypeScript(functionName, indent, parameter, required
         result += `${indent}${indent}details.disabled = false\n`
       }
       result += `${indent}})\n\n`
-    }
       break
     case 'INPUT_BINARY_FILE':
     case 'INPUT_BINARY_FILE:FILE':
-    case 'INPUT_BINARY_STREAM': {
+    case 'INPUT_BINARY_STREAM':
       result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs input[name=${parameter.name}-file]')\n`
       result += `${indent}${inputIdentifier}.addEventListener('change', async (event) => {\n`
       result += `${indent}${indent}const dataTransfer = event.dataTransfer\n`
@@ -54,7 +53,6 @@ function inputParametersDemoTypeScript(functionName, indent, parameter, required
         result += `${indent}${indent}details.disabled = false\n`
       }
       result += `${indent}})\n\n`
-    }
       break
     case 'TEXT':
       result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs sl-input[name=${parameter.name}]')\n`
@@ -70,46 +68,63 @@ function inputParametersDemoTypeScript(functionName, indent, parameter, required
       break
     case 'INT':
     case 'UINT':
+    case 'FLOAT':
       result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs sl-input[name=${parameter.name}]')\n`
       result += `${indent}${inputIdentifier}.addEventListener('sl-change', (event) => {\n`
-      result += `${indent}${indent}model.${modelProperty}.set("${parameterName}", parseInt(${inputIdentifier}.value))\n`
+      if (parameter.type === 'FLOAT') {
+        result += `${indent}${indent}model.${modelProperty}.set("${parameterName}", parseFloat(${inputIdentifier}.value))\n`
+      } else {
+        result += `${indent}${indent}model.${modelProperty}.set("${parameterName}", parseInt(${inputIdentifier}.value))\n`
+      }
       result += `${indent}})\n\n`
       break
     case 'INPUT_JSON':
-      result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs input[name=${parameter.name}-file]')\n`
-      result += `${indent}${inputIdentifier}.addEventListener('change', async (event) => {\n`
-      result += `${indent}${indent}const dataTransfer = event.dataTransfer\n`
-      result += `${indent}${indent}const files = event.target.files || dataTransfer.files\n\n`
-      result += `${indent}${indent}const arrayBuffer = await files[0].arrayBuffer()\n`
-      result += `${indent}${indent}model.${modelProperty}.set("${parameterName}", JSON.parse(new TextDecoder().decode(new Uint8Array(arrayBuffer))))\n`
-      result += `${indent}${indent}const details = document.getElementById("${functionName}-${parameter.name}-details")\n`
-      result += `${indent}${indent}details.innerHTML = \`<pre>$\{globalThis.escapeHtml(JSON.stringify(model.${modelProperty}.get("${parameterName}"), globalThis.interfaceTypeJsonReplacer, 2))}</pre>\`\n`
-      result += `${indent}${indent}details.disabled = false\n`
-      result += `${indent}})\n\n`
-      break
     case 'INPUT_IMAGE':
-      result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs input[name=${parameter.name}-file]')\n`
-      result += `${indent}${inputIdentifier}.addEventListener('change', async (event) => {\n`
-      result += `${indent}${indent}const dataTransfer = event.dataTransfer\n`
-      result += `${indent}${indent}const files = event.target.files || dataTransfer.files\n\n`
-      result += `${indent}${indent}const { image, webWorker } = await readImageFile(null, files[0])\n`
-      result += `${indent}${indent}webWorker.terminate()\n`
-      result += `${indent}${indent}model.${modelProperty}.set("${parameterName}", image)\n`
-      result += `${indent}${indent}const details = document.getElementById("${functionName}-${parameter.name}-details")\n`
-      result += `${indent}${indent}details.innerHTML = \`<pre>$\{globalThis.escapeHtml(JSON.stringify(image, globalThis.interfaceTypeJsonReplacer, 2))}</pre>\`\n`
-      result += `${indent}${indent}details.disabled = false\n`
-      result += `${indent}})\n\n`
-      break
     case 'INPUT_MESH':
       result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs input[name=${parameter.name}-file]')\n`
       result += `${indent}${inputIdentifier}.addEventListener('change', async (event) => {\n`
       result += `${indent}${indent}const dataTransfer = event.dataTransfer\n`
       result += `${indent}${indent}const files = event.target.files || dataTransfer.files\n\n`
-      result += `${indent}${indent}const { mesh, webWorker } = await readMeshFile(null, files[0])\n`
-      result += `${indent}${indent}webWorker.terminate()\n`
-      result += `${indent}${indent}model.${modelProperty}.set("${parameterName}", mesh)\n`
-      result += `${indent}${indent}const details = document.getElementById("${functionName}-${parameter.name}-details")\n`
-      result += `${indent}${indent}details.innerHTML = \`<pre>$\{globalThis.escapeHtml(JSON.stringify(mesh, globalThis.interfaceTypeJsonReplacer, 2))}</pre>\`\n`
+      if (parameter.type === 'INPUT_JSON') {
+        if (parameter.itemsExpectedMax > 1) {
+          console.error('items > 1 are currently not supported')
+          process.exit(1)
+        }
+        result += `${indent}${indent}const arrayBuffer = await files[0].arrayBuffer()\n`
+        result += `${indent}${indent}model.${modelProperty}.set("${parameterName}", JSON.parse(new TextDecoder().decode(new Uint8Array(arrayBuffer))))\n`
+        result += `${indent}${indent}const details = document.getElementById("${functionName}-${parameter.name}-details")\n`
+        result += `${indent}${indent}details.innerHTML = \`<pre>$\{globalThis.escapeHtml(JSON.stringify(model.${modelProperty}.get("${parameterName}"), globalThis.interfaceTypeJsonReplacer, 2))}</pre>\`\n`
+      } else if (parameter.type === 'INPUT_IMAGE') {
+        if (parameter.itemsExpectedMax > 1) {
+          result += `${indent}${indent}const readImages = await Promise.all(Array.from(files).map(async (file) => readImageFile(null, file)))\n`
+          result += `${indent}${indent}readImages.forEach(img => img.webWorker.terminate())\n`
+          result += `${indent}${indent}const inputImages = readImages.map(img => img.image)\n`
+          result += `${indent}${indent}model.${modelProperty}.set("${parameterName}", inputImages)\n`
+          result += `${indent}${indent}const details = document.getElementById("${functionName}-${parameter.name}-details")\n`
+          result += `${indent}${indent}details.innerHTML = \`<pre>$\{globalThis.escapeHtml(JSON.stringify(inputImages, globalThis.interfaceTypeJsonReplacer, 2))}</pre>\`\n`
+        } else {
+          result += `${indent}${indent}const { image, webWorker } = await readImageFile(null, files[0])\n`
+          result += `${indent}${indent}webWorker.terminate()\n`
+          result += `${indent}${indent}model.${modelProperty}.set("${parameterName}", image)\n`
+          result += `${indent}${indent}const details = document.getElementById("${functionName}-${parameter.name}-details")\n`
+          result += `${indent}${indent}details.innerHTML = \`<pre>$\{globalThis.escapeHtml(JSON.stringify(image, globalThis.interfaceTypeJsonReplacer, 2))}</pre>\`\n`
+        }
+      } else if (parameter.type === 'INPUT_MESH') {
+        if (parameter.itemsExpectedMax > 1) {
+          result += `${indent}${indent}const readMeshes = await Promise.all(Array.from(files).map(async (file) => readMeshFile(null, file)))\n`
+          result += `${indent}${indent}readMeshes.forEach(msh => msh.webWorker.terminate())\n`
+          result += `${indent}${indent}const inputMeshes = readMeshes.map(msh => msh.mesh)\n`
+          result += `${indent}${indent}model.${modelProperty}.set("${parameterName}", inputMeshes)\n`
+          result += `${indent}${indent}const details = document.getElementById("${functionName}-${parameter.name}-details")\n`
+          result += `${indent}${indent}details.innerHTML = \`<pre>$\{globalThis.escapeHtml(JSON.stringify(inputMeshes, globalThis.interfaceTypeJsonReplacer, 2))}</pre>\`\n`
+        } else {
+          result += `${indent}${indent}const { mesh, webWorker } = await readMeshFile(null, files[0])\n`
+          result += `${indent}${indent}webWorker.terminate()\n`
+          result += `${indent}${indent}model.${modelProperty}.set("${parameterName}", mesh)\n`
+          result += `${indent}${indent}const details = document.getElementById("${functionName}-${parameter.name}-details")\n`
+          result += `${indent}${indent}details.innerHTML = \`<pre>$\{globalThis.escapeHtml(JSON.stringify(mesh, globalThis.interfaceTypeJsonReplacer, 2))}</pre>\`\n`
+        }
+      }
       result += `${indent}${indent}details.disabled = false\n`
       result += `${indent}})\n\n`
       break
diff --git a/src/bindgen/typescript/function-module.js b/src/bindgen/typescript/function-module.js
index a3ebcd7d0..c982dea22 100644
--- a/src/bindgen/typescript/function-module.js
+++ b/src/bindgen/typescript/function-module.js
@@ -12,7 +12,6 @@ function readFileIfNotInterfaceType(forNode, interfaceType, varName, indent) {
     return `${indent}mountDirs.add(path.dirname(${varName} as string))\n`
   } else {
     if (interfaceType === 'TextFile') {
-
       return `${indent}let ${varName}File = ${varName}\n${indent}if (${varName} instanceof File) {\n${indent}  const ${varName}Buffer = await ${varName}.arrayBuffer()\n${indent}  ${varName}File = { path: ${varName}.name, data: new TextDecoder().decode(${varName}Buffer) }\n${indent}}\n`
     } else {
       return `${indent}let ${varName}File = ${varName}\n${indent}if (${varName} instanceof File) {\n${indent}  const ${varName}Buffer = await ${varName}.arrayBuffer()\n${indent}  ${varName}File = { path: ${varName}.name, data: new Uint8Array(${varName}Buffer) }\n${indent}}\n`
@@ -203,7 +202,7 @@ function functionModule (srcOutputDir, forNode, interfaceJson, modulePascalCase,
         if (interfaceType.includes('Stream')) {
           data = `{ data: ${camel} } `
         } else if (interfaceType === 'JsonObject') {
-          data = `{ data: ${camel} as Object } `
+          data = `{ data: ${camel} as any } `
         }
         functionContent += `    { type: InterfaceTypes.${interfaceType}, data: ${data} },\n`
       }
diff --git a/src/bindgen/typescript/interface-json-type-to-typescript-type.js b/src/bindgen/typescript/interface-json-type-to-typescript-type.js
index 35c7cc383..0f91ff2b2 100644
--- a/src/bindgen/typescript/interface-json-type-to-typescript-type.js
+++ b/src/bindgen/typescript/interface-json-type-to-typescript-type.js
@@ -25,8 +25,8 @@ const interfaceJsonTypeToTypeScriptType = new Map([
   ['UINT:UINT', 'number'],
   ['FLOAT', 'number'],
   ['FLOAT:FLOAT', 'number'],
-  ['INPUT_JSON', 'Object'],
-  ['OUTPUT_JSON', 'Object'],
+  ['INPUT_JSON', 'any'],
+  ['OUTPUT_JSON', 'any'],
 ])
 
 export default interfaceJsonTypeToTypeScriptType
\ No newline at end of file
diff --git a/src/core/JsonCompatible.ts b/src/core/JsonCompatible.ts
new file mode 100644
index 000000000..1952cee91
--- /dev/null
+++ b/src/core/JsonCompatible.ts
@@ -0,0 +1,8 @@
+type JsonCompatible =
+    | string
+    | number
+    | boolean
+    | { [x: string]: JsonCompatible }
+    | JsonCompatible[]
+
+export default JsonCompatible
diff --git a/src/core/JsonObject.ts b/src/core/JsonObject.ts
index bc8cc6f24..9d4168fa2 100644
--- a/src/core/JsonObject.ts
+++ b/src/core/JsonObject.ts
@@ -1,5 +1,7 @@
+import JsonCompatible from './JsonCompatible.js'
+
 interface JsonObject {
-  data: Object
+  data: JsonCompatible
 }
 
 export default JsonObject
diff --git a/src/core/castImage.ts b/src/core/castImage.ts
index bf83b04da..1dff17575 100644
--- a/src/core/castImage.ts
+++ b/src/core/castImage.ts
@@ -5,7 +5,7 @@ import IntTypes from './IntTypes.js'
 import FloatTypes from './FloatTypes.js'
 
 /**
- * Cast an image to another PixelType or ComponentType
+ * Cast an image to another PixelType and/or ComponentType
  *
  * @param {Image} image - The input image
  * @param {CastImageOptions} options - specify the componentType and/or pixelType of the output
diff --git a/src/core/index.ts b/src/core/index.ts
index 750f52c3a..977e959a1 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -10,6 +10,7 @@ export { default as BinaryStream } from './BinaryStream.js'
 export { default as TextFile } from './TextFile.js'
 export { default as BinaryFile } from './BinaryFile.js'
 export { default as JsonObject } from './JsonObject.js'
+export { default as JsonCompatible } from './JsonCompatible.js'
 
 export { default as TypedArray } from './TypedArray.js'
 export { default as IntTypes } from './IntTypes.js'
diff --git a/src/itk-wasm-cli.js b/src/itk-wasm-cli.js
index a23a7c982..d0d2d18c1 100755
--- a/src/itk-wasm-cli.js
+++ b/src/itk-wasm-cli.js
@@ -299,7 +299,7 @@ program
   .requiredOption('-p, --package-name <package-name>', 'Output a package configuration files with the given packages name')
   .requiredOption('-d, --package-description <package-description>', 'Description for package')
   .option('-v, --package-version <package-version>', 'Package version, e.g. "1.0.0"')
-  .addOption(new Option('--interface <interface>', 'interface to generate bindings for, defaults to "typescript"').choices(['typescript', 'python', 'python-web-demo']))
+  .addOption(new Option('--interface <interface>', 'interface to generate bindings for, defaults to "typescript". "python-web-demo" support is in progress.').choices(['typescript', 'python', 'python-web-demo']))
   .option('-r, --repository <repository-url>', 'Source code repository URL')
   .option('-j, --js-module-url <js-module-url>', 'URL for the default hosted itk-wasm bindgen JS ESM module bundle. A JsDeliver is assumed by default.')
   .usage('[options]')
diff --git a/src/pipeline/internal/runPipelineEmscripten.ts b/src/pipeline/internal/runPipelineEmscripten.ts
index c276f4fff..66f7aca92 100644
--- a/src/pipeline/internal/runPipelineEmscripten.ts
+++ b/src/pipeline/internal/runPipelineEmscripten.ts
@@ -124,6 +124,7 @@ function runPipelineEmscripten (pipelineModule: PipelineEmscriptenModule, args:
           const image = input.data as Image
           const dataPtr = setPipelineModuleInputArray(pipelineModule, image.data, index, 0)
           const directionPtr = setPipelineModuleInputArray(pipelineModule, image.direction, index, 1)
+          const metadata = typeof image.metadata?.entries !== 'undefined' ? JSON.stringify(Array.from(image.metadata.entries())) : '[]'
           const imageJSON = {
             imageType: image.imageType,
             name: image.name,
@@ -132,7 +133,7 @@ function runPipelineEmscripten (pipelineModule: PipelineEmscriptenModule, args:
             direction: `data:application/vnd.itk.address,0:${directionPtr}`,
             size: image.size,
             data: `data:application/vnd.itk.address,0:${dataPtr}`,
-            metadata: JSON.stringify(Array.from(image.metadata.entries()))
+            metadata
           }
           setPipelineModuleInputJSON(pipelineModule, imageJSON, index)
           break


Test patch:
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/test/__init__.py b/packages/compare-images/python/itkwasm-compare-images-emscripten/test/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/test/fixtures.py b/packages/compare-images/python/itkwasm-compare-images-emscripten/test/fixtures.py
new file mode 100644
index 000000000..42ca4943d
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/test/fixtures.py
@@ -0,0 +1,31 @@
+import pytest
+import sys
+import pickle
+
+if sys.version_info < (3,10):
+    pytest.skip("Skipping pyodide tests on older Python", allow_module_level=True)
+
+from itkwasm_compare_images_emscripten import __version__ as test_package_version
+
+@pytest.fixture
+def package_wheel():
+    return f"itkwasm_compare_images_emscripten-{test_package_version}-py3-none-any.whl"
+
+@pytest.fixture
+def input_data():
+    from pathlib import Path
+    input_base_path = Path('..', '..', 'test', 'data')
+    test_files = [
+        Path('input') / 'cake_easy.iwi.cbor',
+        Path('input') / 'cake_hard.iwi.cbor',
+        Path('input') / 'cake_easy.png',
+        Path('input') / 'cake_hard.png',
+        Path('input') / 'apple.jpg',
+        Path('input') / 'orange.jpg',
+    ]
+    data = {}
+    for f in test_files:
+        path = str(input_base_path / f) + '.pickle'
+        with open(path, 'rb') as fp:
+            data[str(f.name)] = pickle.load(fp)
+    return data
\ No newline at end of file
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/test/generate_image_pickle.py b/packages/compare-images/python/itkwasm-compare-images-emscripten/test/generate_image_pickle.py
new file mode 100755
index 000000000..93f8aff82
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/test/generate_image_pickle.py
@@ -0,0 +1,26 @@
+import pickle
+
+if __name__ == '__main__':
+    from pathlib import Path
+    input_base_path = Path('..', '..', 'test', 'data')
+    test_files = [
+        Path('input') / 'cake_easy.iwi.cbor',
+        Path('input') / 'cake_hard.iwi.cbor',
+        Path('input') / 'cake_easy.png',
+        Path('input') / 'cake_hard.png',
+        Path('input') / 'apple.jpg',
+        Path('input') / 'orange.jpg',
+    ]
+    data = {}
+    import itk
+    from itkwasm import Image
+    for f in test_files:
+        path = input_base_path / f
+        image = itk.imread(path)
+        image_dict = itk.dict_from_image(image)
+        image_dict['metadata'] = dict()
+        print(str(path))
+        with open(f'{str(path)}.pickle', 'wb') as fp:
+            # Pickle dict representation for use in pyodide test to avoid
+            # bootstrapping conflict with itkwasm_image_io
+            pickle.dump(image_dict, fp)
\ No newline at end of file
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/test/test_compare_images_async.py b/packages/compare-images/python/itkwasm-compare-images-emscripten/test/test_compare_images_async.py
new file mode 100644
index 000000000..9defcb277
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/test/test_compare_images_async.py
@@ -0,0 +1,99 @@
+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 .fixtures import package_wheel, input_data
+
+@run_in_pyodide(packages=['micropip', 'numpy'])
+async def test_compare_double_images_async(selenium, package_wheel, input_data):
+    import micropip
+    await micropip.install(package_wheel, 'numpy', 'itkwasm')
+
+    from itkwasm_compare_images_emscripten import compare_double_images_async
+    import numpy as np
+    from itkwasm import Image
+    from itkwasm.pyodide import to_js as itkwasm_to_js
+
+    test_image_file = 'cake_easy.iwi.cbor'
+    test_image = Image(**input_data[test_image_file])
+
+    baseline_image_file = 'cake_hard.iwi.cbor'
+    baseline_image = Image(**input_data[baseline_image_file])
+
+    metrics, difference_image, difference_image_rendering = await compare_double_images_async(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
+
+@run_in_pyodide(packages=['micropip', 'numpy'])
+async def test_compare_images_async(selenium, package_wheel, input_data):
+    import micropip
+    await micropip.install(package_wheel, 'numpy', 'itkwasm')
+
+    from itkwasm_compare_images_emscripten import compare_images_async
+    import numpy as np
+    from itkwasm import Image
+    from itkwasm.pyodide import to_js as itkwasm_to_js
+
+    test_image_file = 'cake_easy.iwi.cbor'
+    test_image = Image(**input_data[test_image_file])
+
+    baseline_image_file = 'cake_hard.iwi.cbor'
+    baseline_image = Image(**input_data[baseline_image_file])
+
+    metrics, difference_image, difference_image_rendering = await compare_images_async(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
+
+    test_image_file = 'cake_easy.png'
+    test_image = Image(**input_data[test_image_file])
+
+    baseline_image_file = 'cake_hard.png'
+    baseline_image = Image(**input_data[baseline_image_file])
+
+    metrics, difference_image, difference_image_rendering = await compare_images_async(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
+
+    test_image_file = 'apple.jpg'
+    test_image = Image(**input_data[test_image_file])
+
+    baseline_image_file = 'orange.jpg'
+    baseline_image = Image(**input_data[baseline_image_file])
+
+    metrics, difference_image, difference_image_rendering = await compare_images_async(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 26477
+    assert metrics['minimumDifference'] == 0.002273026683894841
+    assert metrics['maximumDifference'] == 312.2511648746159
+    assert metrics['totalDifference'] == 3121656.100202402
+    assert metrics['meanDifference'] == 117.90067228924735
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
diff --git a/packages/compare-images/python/itkwasm-compare-images-emscripten/test/test_itkwasm_compare_images.py b/packages/compare-images/python/itkwasm-compare-images-emscripten/test/test_itkwasm_compare_images.py
new file mode 100644
index 000000000..4b92204d8
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-emscripten/test/test_itkwasm_compare_images.py
@@ -0,0 +1,20 @@
+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_compare_images_emscripten import __version__ as test_package_version
+
+@pytest.fixture
+def package_wheel():
+    return f"itkwasm_compare_images_emscripten-{test_package_version}-py3-none-any.whl"
+
+@run_in_pyodide(packages=['micropip'])
+async def test_example(selenium, package_wheel):
+    import micropip
+    await micropip.install(package_wheel)
+
+    # Write your test code here
diff --git a/packages/compare-images/python/itkwasm-compare-images-wasi/test/test_compare_double_images.py b/packages/compare-images/python/itkwasm-compare-images-wasi/test/test_compare_double_images.py
new file mode 100644
index 000000000..3a90d9a49
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-wasi/test/test_compare_double_images.py
@@ -0,0 +1,30 @@
+from pathlib import Path
+import itk
+from itkwasm import Image
+
+def test_compare_double_images():
+    from itkwasm_compare_images_wasi import compare_double_images
+
+    test_image_file = 'cake_easy.iwi.cbor'
+    test_image_path = Path('..', '..', 'test', 'data', 'input', test_image_file)
+    test_image = itk.imread(test_image_path)
+    test_dict = itk.dict_from_image(test_image)
+    test_image = Image(**test_dict)
+
+    baseline_image_file = 'cake_hard.iwi.cbor'
+    baseline_image_path = Path('..', '..', 'test', 'data', 'input', baseline_image_file)
+    baseline_image = itk.imread(baseline_image_path)
+    baseline_dict = itk.dict_from_image(baseline_image)
+    baseline_image = Image(**baseline_dict)
+
+    metrics, difference_image, difference_image_rendering = compare_double_images(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
diff --git a/packages/compare-images/python/itkwasm-compare-images-wasi/test/test_compare_images.py b/packages/compare-images/python/itkwasm-compare-images-wasi/test/test_compare_images.py
new file mode 100644
index 000000000..aac721673
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images-wasi/test/test_compare_images.py
@@ -0,0 +1,84 @@
+from pathlib import Path
+import itk
+from itkwasm import Image
+
+def test_compare_double_images():
+    from itkwasm_compare_images_wasi import compare_images
+
+    test_image_file = 'cake_easy.iwi.cbor'
+    test_image_path = Path('..', '..', 'test', 'data', 'input', test_image_file)
+    test_image = itk.imread(test_image_path)
+    test_dict = itk.dict_from_image(test_image)
+    test_image = Image(**test_dict)
+
+    baseline_image_file = 'cake_hard.iwi.cbor'
+    baseline_image_path = Path('..', '..', 'test', 'data', 'input', baseline_image_file)
+    baseline_image = itk.imread(baseline_image_path)
+    baseline_dict = itk.dict_from_image(baseline_image)
+    baseline_image = Image(**baseline_dict)
+
+    metrics, difference_image, difference_image_rendering = compare_images(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
+
+def test_compare_uint8_images():
+    from itkwasm_compare_images_wasi import compare_images
+
+    test_image_file = 'cake_easy.png'
+    test_image_path = Path('..', '..', 'test', 'data', 'input', test_image_file)
+    test_image = itk.imread(test_image_path)
+    test_dict = itk.dict_from_image(test_image)
+    test_image = Image(**test_dict)
+
+    baseline_image_file = 'cake_hard.png'
+    baseline_image_path = Path('..', '..', 'test', 'data', 'input', baseline_image_file)
+    baseline_image = itk.imread(baseline_image_path)
+    baseline_dict = itk.dict_from_image(baseline_image)
+    baseline_image = Image(**baseline_dict)
+
+    metrics, difference_image, difference_image_rendering = compare_images(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
+
+def test_compare_rgb_images():
+    from itkwasm_compare_images_wasi import compare_images
+
+    test_image_file = 'apple.jpg'
+    test_image_path = Path('..', '..', 'test', 'data', 'input', test_image_file)
+    test_image = itk.imread(test_image_path)
+    test_dict = itk.dict_from_image(test_image)
+    test_image = Image(**test_dict)
+
+    baseline_image_file = 'orange.jpg'
+    baseline_image_path = Path('..', '..', 'test', 'data', 'input', baseline_image_file)
+    baseline_image = itk.imread(baseline_image_path)
+    baseline_dict = itk.dict_from_image(baseline_image)
+    baseline_image = Image(**baseline_dict)
+
+    metrics, difference_image, difference_image_rendering = compare_images(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 26477
+    assert metrics['minimumDifference'] == 0.002273026683894841
+    assert metrics['maximumDifference'] == 312.2511648746159
+    assert metrics['totalDifference'] == 3121656.100202402
+    assert metrics['meanDifference'] == 117.90067228924735
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
diff --git a/packages/compare-images/python/itkwasm-compare-images/test/__init__.py b/packages/compare-images/python/itkwasm-compare-images/test/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/compare-images/python/itkwasm-compare-images/test/fixtures.py b/packages/compare-images/python/itkwasm-compare-images/test/fixtures.py
new file mode 100644
index 000000000..deef3d21f
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/test/fixtures.py
@@ -0,0 +1,31 @@
+import pytest
+import sys
+import pickle
+
+if sys.version_info < (3,10):
+    pytest.skip("Skipping pyodide tests on older Python", allow_module_level=True)
+
+from itkwasm_compare_images import __version__ as test_package_version
+
+@pytest.fixture
+def package_wheel():
+    return f"itkwasm_compare_images-{test_package_version}-py3-none-any.whl"
+
+@pytest.fixture
+def input_data():
+    from pathlib import Path
+    input_base_path = Path('..', '..', 'test', 'data')
+    test_files = [
+        Path('input') / 'cake_easy.iwi.cbor',
+        Path('input') / 'cake_hard.iwi.cbor',
+        Path('input') / 'cake_easy.png',
+        Path('input') / 'cake_hard.png',
+        Path('input') / 'apple.jpg',
+        Path('input') / 'orange.jpg',
+    ]
+    data = {}
+    for f in test_files:
+        path = str(input_base_path / f) + '.pickle'
+        with open(path, 'rb') as fp:
+            data[str(f.name)] = pickle.load(fp)
+    return data
diff --git a/packages/compare-images/python/itkwasm-compare-images/test/test_compare_double_images.py b/packages/compare-images/python/itkwasm-compare-images/test/test_compare_double_images.py
new file mode 100644
index 000000000..3a90d9a49
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/test/test_compare_double_images.py
@@ -0,0 +1,30 @@
+from pathlib import Path
+import itk
+from itkwasm import Image
+
+def test_compare_double_images():
+    from itkwasm_compare_images_wasi import compare_double_images
+
+    test_image_file = 'cake_easy.iwi.cbor'
+    test_image_path = Path('..', '..', 'test', 'data', 'input', test_image_file)
+    test_image = itk.imread(test_image_path)
+    test_dict = itk.dict_from_image(test_image)
+    test_image = Image(**test_dict)
+
+    baseline_image_file = 'cake_hard.iwi.cbor'
+    baseline_image_path = Path('..', '..', 'test', 'data', 'input', baseline_image_file)
+    baseline_image = itk.imread(baseline_image_path)
+    baseline_dict = itk.dict_from_image(baseline_image)
+    baseline_image = Image(**baseline_dict)
+
+    metrics, difference_image, difference_image_rendering = compare_double_images(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
diff --git a/packages/compare-images/python/itkwasm-compare-images/test/test_compare_images.py b/packages/compare-images/python/itkwasm-compare-images/test/test_compare_images.py
new file mode 100644
index 000000000..ad4663ac7
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/test/test_compare_images.py
@@ -0,0 +1,84 @@
+from pathlib import Path
+import itk
+from itkwasm import Image
+
+def test_compare_double_images():
+    from itkwasm_compare_images import compare_images
+
+    test_image_file = 'cake_easy.iwi.cbor'
+    test_image_path = Path('..', '..', 'test', 'data', 'input', test_image_file)
+    test_image = itk.imread(test_image_path)
+    test_dict = itk.dict_from_image(test_image)
+    test_image = Image(**test_dict)
+
+    baseline_image_file = 'cake_hard.iwi.cbor'
+    baseline_image_path = Path('..', '..', 'test', 'data', 'input', baseline_image_file)
+    baseline_image = itk.imread(baseline_image_path)
+    baseline_dict = itk.dict_from_image(baseline_image)
+    baseline_image = Image(**baseline_dict)
+
+    metrics, difference_image, difference_image_rendering = compare_images(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
+
+def test_compare_uint8_images():
+    from itkwasm_compare_images import compare_images
+
+    test_image_file = 'cake_easy.png'
+    test_image_path = Path('..', '..', 'test', 'data', 'input', test_image_file)
+    test_image = itk.imread(test_image_path)
+    test_dict = itk.dict_from_image(test_image)
+    test_image = Image(**test_dict)
+
+    baseline_image_file = 'cake_hard.png'
+    baseline_image_path = Path('..', '..', 'test', 'data', 'input', baseline_image_file)
+    baseline_image = itk.imread(baseline_image_path)
+    baseline_dict = itk.dict_from_image(baseline_image)
+    baseline_image = Image(**baseline_dict)
+
+    metrics, difference_image, difference_image_rendering = compare_images(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
+
+def test_compare_rgb_images():
+    from itkwasm_compare_images import compare_images
+
+    test_image_file = 'apple.jpg'
+    test_image_path = Path('..', '..', 'test', 'data', 'input', test_image_file)
+    test_image = itk.imread(test_image_path)
+    test_dict = itk.dict_from_image(test_image)
+    test_image = Image(**test_dict)
+
+    baseline_image_file = 'orange.jpg'
+    baseline_image_path = Path('..', '..', 'test', 'data', 'input', baseline_image_file)
+    baseline_image = itk.imread(baseline_image_path)
+    baseline_dict = itk.dict_from_image(baseline_image)
+    baseline_image = Image(**baseline_dict)
+
+    metrics, difference_image, difference_image_rendering = compare_images(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 26477
+    assert metrics['minimumDifference'] == 0.002273026683894841
+    assert metrics['maximumDifference'] == 312.2511648746159
+    assert metrics['totalDifference'] == 3121656.100202402
+    assert metrics['meanDifference'] == 117.90067228924735
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
diff --git a/packages/compare-images/python/itkwasm-compare-images/test/test_compare_images_async.py b/packages/compare-images/python/itkwasm-compare-images/test/test_compare_images_async.py
new file mode 100644
index 000000000..28256b2e4
--- /dev/null
+++ b/packages/compare-images/python/itkwasm-compare-images/test/test_compare_images_async.py
@@ -0,0 +1,99 @@
+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 .fixtures import package_wheel, input_data
+
+@run_in_pyodide(packages=['micropip', 'numpy'])
+async def test_compare_double_images_async(selenium, package_wheel, input_data):
+    import micropip
+    await micropip.install(package_wheel, 'numpy', 'itkwasm')
+
+    from itkwasm_compare_images import compare_double_images_async
+    import numpy as np
+    from itkwasm import Image
+    from itkwasm.pyodide import to_js as itkwasm_to_js
+
+    test_image_file = 'cake_easy.iwi.cbor'
+    test_image = Image(**input_data[test_image_file])
+
+    baseline_image_file = 'cake_hard.iwi.cbor'
+    baseline_image = Image(**input_data[baseline_image_file])
+
+    metrics, difference_image, difference_image_rendering = await compare_double_images_async(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
+
+@run_in_pyodide(packages=['micropip', 'numpy'])
+async def test_compare_images_async(selenium, package_wheel, input_data):
+    import micropip
+    await micropip.install(package_wheel, 'numpy', 'itkwasm')
+
+    from itkwasm_compare_images import compare_images_async
+    import numpy as np
+    from itkwasm import Image
+    from itkwasm.pyodide import to_js as itkwasm_to_js
+
+    test_image_file = 'cake_easy.iwi.cbor'
+    test_image = Image(**input_data[test_image_file])
+
+    baseline_image_file = 'cake_hard.iwi.cbor'
+    baseline_image = Image(**input_data[baseline_image_file])
+
+    metrics, difference_image, difference_image_rendering = await compare_images_async(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
+
+    test_image_file = 'cake_easy.png'
+    test_image = Image(**input_data[test_image_file])
+
+    baseline_image_file = 'cake_hard.png'
+    baseline_image = Image(**input_data[baseline_image_file])
+
+    metrics, difference_image, difference_image_rendering = await compare_images_async(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 9915
+    assert metrics['minimumDifference'] == 1.0
+    assert metrics['maximumDifference'] == 107.0
+    assert metrics['totalDifference'] == 337334.0
+    assert metrics['meanDifference'] == 34.02259203227433
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
+
+    test_image_file = 'apple.jpg'
+    test_image = Image(**input_data[test_image_file])
+
+    baseline_image_file = 'orange.jpg'
+    baseline_image = Image(**input_data[baseline_image_file])
+
+    metrics, difference_image, difference_image_rendering = await compare_images_async(test_image, baseline_images=[baseline_image])
+
+    assert metrics['almostEqual'] == False
+    assert metrics['numberOfPixelsWithDifferences'] == 26477
+    assert metrics['minimumDifference'] == 0.002273026683894841
+    assert metrics['maximumDifference'] == 312.2511648746159
+    assert metrics['totalDifference'] == 3121656.100202402
+    assert metrics['meanDifference'] == 117.90067228924735
+
+    assert difference_image.imageType.componentType == 'float64'
+    assert difference_image_rendering.imageType.componentType == 'uint8'
diff --git a/packages/compare-images/test/data/input/apple.iwi.cbor b/packages/compare-images/test/data/input/apple.iwi.cbor
new file mode 100644
index 000000000..c2d985c26
Binary files /dev/null and b/packages/compare-images/test/data/input/apple.iwi.cbor differ
diff --git a/packages/compare-images/test/data/input/apple.jpg b/packages/compare-images/test/data/input/apple.jpg
new file mode 100644
index 000000000..8ac5ae2c9
Binary files /dev/null and b/packages/compare-images/test/data/input/apple.jpg differ
diff --git a/packages/compare-images/test/data/input/apple.jpg.pickle b/packages/compare-images/test/data/input/apple.jpg.pickle
new file mode 100644
index 000000000..450f7126d
Binary files /dev/null and b/packages/compare-images/test/data/input/apple.jpg.pickle differ
diff --git a/packages/compare-images/test/data/input/cake_easy.iwi.cbor b/packages/compare-images/test/data/input/cake_easy.iwi.cbor
new file mode 100644
index 000000000..463b46fbc
Binary files /dev/null and b/packages/compare-images/test/data/input/cake_easy.iwi.cbor differ
diff --git a/packages/compare-images/test/data/input/cake_easy.iwi.cbor.pickle b/packages/compare-images/test/data/input/cake_easy.iwi.cbor.pickle
new file mode 100644
index 000000000..aea98e756
Binary files /dev/null and b/packages/compare-images/test/data/input/cake_easy.iwi.cbor.pickle differ
diff --git a/packages/compare-images/test/data/input/cake_easy.png b/packages/compare-images/test/data/input/cake_easy.png
new file mode 100644
index 000000000..530a14c12
Binary files /dev/null and b/packages/compare-images/test/data/input/cake_easy.png differ
diff --git a/packages/compare-images/test/data/input/cake_easy.png.pickle b/packages/compare-images/test/data/input/cake_easy.png.pickle
new file mode 100644
index 000000000..bc8b57d81
Binary files /dev/null and b/packages/compare-images/test/data/input/cake_easy.png.pickle differ
diff --git a/packages/compare-images/test/data/input/cake_hard.iwi.cbor b/packages/compare-images/test/data/input/cake_hard.iwi.cbor
new file mode 100644
index 000000000..42f8dfeaa
Binary files /dev/null and b/packages/compare-images/test/data/input/cake_hard.iwi.cbor differ
diff --git a/packages/compare-images/test/data/input/cake_hard.iwi.cbor.pickle b/packages/compare-images/test/data/input/cake_hard.iwi.cbor.pickle
new file mode 100644
index 000000000..81841b708
Binary files /dev/null and b/packages/compare-images/test/data/input/cake_hard.iwi.cbor.pickle differ
diff --git a/packages/compare-images/test/data/input/cake_hard.png b/packages/compare-images/test/data/input/cake_hard.png
new file mode 100644
index 000000000..6db3f10e3
Binary files /dev/null and b/packages/compare-images/test/data/input/cake_hard.png differ
diff --git a/packages/compare-images/test/data/input/cake_hard.png.pickle b/packages/compare-images/test/data/input/cake_hard.png.pickle
new file mode 100644
index 000000000..0238c0c4f
Binary files /dev/null and b/packages/compare-images/test/data/input/cake_hard.png.pickle differ
diff --git a/packages/compare-images/test/data/input/orange.jpg b/packages/compare-images/test/data/input/orange.jpg
new file mode 100644
index 000000000..b4c3817a1
Binary files /dev/null and b/packages/compare-images/test/data/input/orange.jpg differ
diff --git a/packages/compare-images/test/data/input/orange.jpg.pickle b/packages/compare-images/test/data/input/orange.jpg.pickle
new file mode 100644
index 000000000..ecdc78dca
Binary files /dev/null and b/packages/compare-images/test/data/input/orange.jpg.pickle differ
diff --git a/packages/compare-images/typescript/cypress/e2e/common.ts b/packages/compare-images/typescript/cypress/e2e/common.ts
new file mode 100644
index 000000000..4f712993a
--- /dev/null
+++ b/packages/compare-images/typescript/cypress/e2e/common.ts
@@ -0,0 +1,1 @@
+export const demoServer = 'http://localhost:5173'
diff --git a/packages/compare-images/typescript/cypress/e2e/compare-images.cy.ts b/packages/compare-images/typescript/cypress/e2e/compare-images.cy.ts
new file mode 100644
index 000000000..ec3b9da4a
--- /dev/null
+++ b/packages/compare-images/typescript/cypress/e2e/compare-images.cy.ts
@@ -0,0 +1,69 @@
+import { demoServer } from './common.ts'
+
+describe('compareImages', () => {
+  beforeEach(function() {
+    cy.visit(demoServer)
+
+    const testPathPrefix = '../test/data/input/'
+
+    const testImageFiles = [
+      'cake_easy.iwi.cbor',
+      'cake_hard.iwi.cbor',
+      'cake_easy.png',
+      'cake_hard.png',
+      'apple.jpg',
+      'orange.jpg',
+    ]
+    testImageFiles.forEach((fileName) => {
+      cy.readFile(`${testPathPrefix}${fileName}`, null).as(fileName)
+    })
+  })
+
+  it('Runs on double images', function () {
+    cy.get('sl-tab[panel="compareImages-panel"]').click()
+
+    const testFile = { contents: new Uint8Array(this['cake_easy.iwi.cbor']), fileName: 'cake_easy.iwi.cbor' }
+    cy.get('#compareImagesInputs input[name="test-image-file"]').selectFile([testFile,], { force: true })
+    cy.get('#compareImages-test-image-details').should('contain', 'imageType')
+
+    const baselineFile = { contents: new Uint8Array(this['cake_hard.iwi.cbor']), fileName: 'cake_hard.iwi.cbor' }
+    cy.get('#compareImagesInputs input[name="baseline-images-file"]').selectFile([baselineFile,], { force: true })
+    cy.get('#compareImages-baseline-images-details').should('contain', 'imageType')
+
+    cy.get('#compareImagesInputs sl-button[name="run"]').click()
+
+    cy.get('#compareImages-metrics-details').should('contain', '"almostEqual": false')
+  })
+
+  it('Runs on uint8 images', function () {
+    cy.get('sl-tab[panel="compareImages-panel"]').click()
+
+    const testFile = { contents: new Uint8Array(this['cake_easy.png']), fileName: 'cake_easy.png' }
+    cy.get('#compareImagesInputs input[name="test-image-file"]').selectFile([testFile,], { force: true })
+    cy.get('#compareImages-test-image-details').should('contain', 'imageType')
+
+    const baselineFile = { contents: new Uint8Array(this['cake_hard.png']), fileName: 'cake_hard.png' }
+    cy.get('#compareImagesInputs input[name="baseline-images-file"]').selectFile([baselineFile,], { force: true })
+    cy.get('#compareImages-baseline-images-details').should('contain', 'imageType')
+
+    cy.get('#compareImagesInputs sl-button[name="run"]').click()
+
+    cy.get('#compareImages-metrics-details').should('contain', '"almostEqual": false')
+  })
+
+  it('Runs on RGB images', function () {
+    cy.get('sl-tab[panel="compareImages-panel"]').click()
+
+    const testFile = { contents: new Uint8Array(this['apple.jpg']), fileName: 'apple.jpg' }
+    cy.get('#compareImagesInputs input[name="test-image-file"]').selectFile([testFile,], { force: true })
+    cy.get('#compareImages-test-image-details').should('contain', 'imageType')
+
+    const baselineFile = { contents: new Uint8Array(this['orange.jpg']), fileName: 'orange.jpg' }
+    cy.get('#compareImagesInputs input[name="baseline-images-file"]').selectFile([baselineFile,], { force: true })
+    cy.get('#compareImages-baseline-images-details').should('contain', 'imageType')
+
+    cy.get('#compareImagesInputs sl-button[name="run"]').click()
+
+    cy.get('#compareImages-metrics-details').should('contain', '"almostEqual": false')
+  })
+})
diff --git a/packages/compare-images/typescript/cypress/support/e2e.ts b/packages/compare-images/typescript/cypress/support/e2e.ts
new file mode 100644
index 000000000..ca171c17d
--- /dev/null
+++ b/packages/compare-images/typescript/cypress/support/e2e.ts
@@ -0,0 +1,31 @@
+// ***********************************************************
+// This example support/e2e.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
+
+Cypress.on('uncaught:exception', (err, runnable) => {
+  // we expect a 3rd party library error with message 'list not defined'
+  // and don't want to fail the test so we return false
+  if (err.message.includes('ResizeObserver loop completed with undelivered notifications')) {
+    return false
+  }
+  // we still want to ensure there are no other unexpected
+  // errors, so we let them fail the test
+})
+
diff --git a/packages/compare-images/typescript/test/browser/demo-app/compare-double-images-controller.ts b/packages/compare-images/typescript/test/browser/demo-app/compare-double-images-controller.ts
new file mode 100644
index 000000000..cfbcbdc57
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/compare-double-images-controller.ts
@@ -0,0 +1,191 @@
+// Generated file. To retain edits, remove this comment.
+
+import { readImageFile } from 'itk-wasm'
+import { writeImageArrayBuffer, copyImage } from 'itk-wasm'
+import * as compareImages from '../../../dist/bundles/compare-images.js'
+import compareDoubleImagesLoadSampleInputs from "./compare-double-images-load-sample-inputs.js"
+
+class CompareDoubleImagesModel {
+
+  inputs: Map<string, any>
+  options: Map<string, any>
+  outputs: Map<string, any>
+
+  constructor() {
+    this.inputs = new Map()
+    this.options = new Map()
+    this.outputs = new Map()
+    }
+  }
+
+
+class CompareDoubleImagesController  {
+
+  constructor(loadSampleInputs) {
+    this.loadSampleInputs = loadSampleInputs
+
+    this.model = new CompareDoubleImagesModel()
+    const model = this.model
+
+    this.webWorker = null
+
+    if (loadSampleInputs) {
+      const loadSampleInputsButton = document.querySelector("#compareDoubleImagesInputs [name=loadSampleInputs]")
+      loadSampleInputsButton.setAttribute('style', 'display: block-inline;')
+      loadSampleInputsButton.addEventListener('click', async (event) => {
+        loadSampleInputsButton.loading = true
+        await loadSampleInputs(model)
+        loadSampleInputsButton.loading = false
+      })
+    }
+
+    // ----------------------------------------------
+    // Inputs
+    const testImageElement = document.querySelector('#compareDoubleImagesInputs input[name=test-image-file]')
+    testImageElement.addEventListener('change', async (event) => {
+        const dataTransfer = event.dataTransfer
+        const files = event.target.files || dataTransfer.files
+
+        const { image, webWorker } = await readImageFile(null, files[0])
+        webWorker.terminate()
+        model.inputs.set("testImage", image)
+        const details = document.getElementById("compareDoubleImages-test-image-details")
+        details.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(image, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        details.disabled = false
+    })
+
+    // ----------------------------------------------
+    // Options
+    const baselineImagesElement = document.querySelector('#compareDoubleImagesInputs input[name=baseline-images-file]')
+    baselineImagesElement.addEventListener('change', async (event) => {
+        const dataTransfer = event.dataTransfer
+        const files = event.target.files || dataTransfer.files
+
+        const readImages = await Promise.all(Array.from(files).map(async (file) => readImageFile(null, file)))
+        readImages.forEach(img => img.webWorker.terminate())
+        const inputImages = readImages.map(img => img.image)
+        model.options.set("baselineImages", inputImages)
+        const details = document.getElementById("compareDoubleImages-baseline-images-details")
+        details.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(inputImages, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        details.disabled = false
+    })
+
+    const differenceThresholdElement = document.querySelector('#compareDoubleImagesInputs sl-input[name=difference-threshold]')
+    differenceThresholdElement.addEventListener('sl-change', (event) => {
+        model.options.set("differenceThreshold", parseFloat(differenceThresholdElement.value))
+    })
+
+    const radiusToleranceElement = document.querySelector('#compareDoubleImagesInputs sl-input[name=radius-tolerance]')
+    radiusToleranceElement.addEventListener('sl-change', (event) => {
+        model.options.set("radiusTolerance", parseInt(radiusToleranceElement.value))
+    })
+
+    const numberOfPixelsToleranceElement = document.querySelector('#compareDoubleImagesInputs sl-input[name=number-of-pixels-tolerance]')
+    numberOfPixelsToleranceElement.addEventListener('sl-change', (event) => {
+        model.options.set("numberOfPixelsTolerance", parseInt(numberOfPixelsToleranceElement.value))
+    })
+
+    const ignoreBoundaryPixelsElement = document.querySelector('#compareDoubleImagesInputs sl-checkbox[name=ignore-boundary-pixels]')
+    ignoreBoundaryPixelsElement.addEventListener('sl-change', (event) => {
+        model.options.set("ignoreBoundaryPixels", ignoreBoundaryPixelsElement.checked)
+    })
+
+    // ----------------------------------------------
+    // Outputs
+    const metricsOutputDownload = document.querySelector('#compareDoubleImagesOutputs sl-button[name=metrics-download]')
+    metricsOutputDownload.addEventListener('click', async (event) => {
+        event.preventDefault()
+        event.stopPropagation()
+        if (model.outputs.has("metrics")) {
+            const fileName = `metrics.json`
+            globalThis.downloadFile(new TextEncoder().encode(JSON.stringify(model.outputs.get("metrics"))), fileName)
+        }
+    })
+
+    const differenceImageOutputDownload = document.querySelector('#compareDoubleImagesOutputs sl-button[name=difference-image-download]')
+    differenceImageOutputDownload.addEventListener('click', async (event) => {
+        event.preventDefault()
+        event.stopPropagation()
+        if (model.outputs.has("differenceImage")) {
+            const differenceImageDownloadFormat = document.getElementById('difference-image-output-format')
+            const downloadFormat = differenceImageDownloadFormat.value || 'nrrd'
+            const fileName = `differenceImage.${downloadFormat}`
+            const { webWorker, arrayBuffer } = await writeImageArrayBuffer(null, copyImage(model.outputs.get("differenceImage")), fileName)
+
+            webWorker.terminate()
+            globalThis.downloadFile(arrayBuffer, fileName)
+        }
+    })
+
+    const differenceUchar2dImageOutputDownload = document.querySelector('#compareDoubleImagesOutputs sl-button[name=difference-uchar-2d-image-download]')
+    differenceUchar2dImageOutputDownload.addEventListener('click', async (event) => {
+        event.preventDefault()
+        event.stopPropagation()
+        if (model.outputs.has("differenceUchar2dImage")) {
+            const differenceUchar2dImageDownloadFormat = document.getElementById('difference-uchar-2d-image-output-format')
+            const downloadFormat = differenceUchar2dImageDownloadFormat.value || 'nrrd'
+            const fileName = `differenceUchar2dImage.${downloadFormat}`
+            const { webWorker, arrayBuffer } = await writeImageArrayBuffer(null, copyImage(model.outputs.get("differenceUchar2dImage")), fileName)
+
+            webWorker.terminate()
+            globalThis.downloadFile(arrayBuffer, fileName)
+        }
+    })
+
+    const runButton = document.querySelector('#compareDoubleImagesInputs sl-button[name="run"]')
+    runButton.addEventListener('click', async (event) => {
+      event.preventDefault()
+
+      if(!model.inputs.has('testImage')) {
+        globalThis.notify("Required input not provided", "testImage", "danger", "exclamation-octagon")
+        return
+      }
+
+
+      try {
+        runButton.loading = true
+        const t0 = performance.now()
+
+        const { webWorker, metrics, differenceImage, differenceUchar2dImage, } = await compareImages.compareDoubleImages(this.webWorker,
+          model.inputs.get('testImage'),
+          Object.fromEntries(model.options.entries())
+        )
+
+        const t1 = performance.now()
+        globalThis.notify("compareDoubleImages successfully completed", `in ${t1 - t0} milliseconds.`, "success", "rocket-fill")
+        this.webWorker = webWorker
+
+        model.outputs.set("metrics", metrics)
+        metricsOutputDownload.variant = "success"
+        metricsOutputDownload.disabled = false
+        const metricsDetails = document.getElementById("compareDoubleImages-metrics-details")
+        metricsDetails.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(metrics, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        metricsDetails.disabled = false
+        const metricsOutput = document.getElementById("compareDoubleImages-metrics-details")
+
+        model.outputs.set("differenceImage", differenceImage)
+        differenceImageOutputDownload.variant = "success"
+        differenceImageOutputDownload.disabled = false
+        const differenceImageDetails = document.getElementById("compareDoubleImages-difference-image-details")
+        differenceImageDetails.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(differenceImage, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        differenceImageDetails.disabled = false
+        const differenceImageOutput = document.getElementById('compareDoubleImages-difference-image-details')
+
+        model.outputs.set("differenceUchar2dImage", differenceUchar2dImage)
+        differenceUchar2dImageOutputDownload.variant = "success"
+        differenceUchar2dImageOutputDownload.disabled = false
+        const differenceUchar2dImageDetails = document.getElementById("compareDoubleImages-difference-uchar-2d-image-details")
+        differenceUchar2dImageDetails.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(differenceUchar2dImage, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        differenceUchar2dImageDetails.disabled = false
+        const differenceUchar2dImageOutput = document.getElementById('compareDoubleImages-difference-uchar-2d-image-details')
+      } catch (error) {
+        globalThis.notify("Error while running pipeline", error.toString(), "danger", "exclamation-octagon")
+        throw error
+      } finally {
+        runButton.loading = false
+      }
+    })
+  }
+}
+
+const compareDoubleImagesController = new CompareDoubleImagesController(compareDoubleImagesLoadSampleInputs)
diff --git a/packages/compare-images/typescript/test/browser/demo-app/compare-double-images-load-sample-inputs.ts b/packages/compare-images/typescript/test/browser/demo-app/compare-double-images-load-sample-inputs.ts
new file mode 100644
index 000000000..187303a33
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/compare-double-images-load-sample-inputs.ts
@@ -0,0 +1,21 @@
+// Generated file. To retain edits, remove this comment.
+
+export default null
+// export default async function compareDoubleImagesLoadSampleInputs (model) {
+
+  // Load sample inputs for the compareDoubleImages function.
+  //
+  // This function should load sample inputs:
+  //
+  //  1) In the provided model map.
+  //  2) Into the corresponding HTML input elements.
+  //
+  // Example for an input named `exampleInput`:
+
+  // const exampleInput = 5
+  // model.inputs.set("exampleInput", exampleInput)
+  // const exampleElement = document.querySelector("#compareDoubleImagesInputs [name=example-input]")
+  // exampleElement.value = 5
+
+  // return model
+// }
diff --git a/packages/compare-images/typescript/test/browser/demo-app/compare-images-controller.ts b/packages/compare-images/typescript/test/browser/demo-app/compare-images-controller.ts
new file mode 100644
index 000000000..606715b7e
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/compare-images-controller.ts
@@ -0,0 +1,191 @@
+// Generated file. To retain edits, remove this comment.
+
+import { readImageFile } from 'itk-wasm'
+import { writeImageArrayBuffer, copyImage } from 'itk-wasm'
+import * as compareImages from '../../../dist/bundles/compare-images.js'
+import compareImagesLoadSampleInputs from "./compare-images-load-sample-inputs.js"
+
+class CompareImagesModel {
+
+  inputs: Map<string, any>
+  options: Map<string, any>
+  outputs: Map<string, any>
+
+  constructor() {
+    this.inputs = new Map()
+    this.options = new Map()
+    this.outputs = new Map()
+    }
+  }
+
+
+class CompareImagesController  {
+
+  constructor(loadSampleInputs) {
+    this.loadSampleInputs = loadSampleInputs
+
+    this.model = new CompareImagesModel()
+    const model = this.model
+
+    this.webWorker = null
+
+    if (loadSampleInputs) {
+      const loadSampleInputsButton = document.querySelector("#compareImagesInputs [name=loadSampleInputs]")
+      loadSampleInputsButton.setAttribute('style', 'display: block-inline;')
+      loadSampleInputsButton.addEventListener('click', async (event) => {
+        loadSampleInputsButton.loading = true
+        await loadSampleInputs(model)
+        loadSampleInputsButton.loading = false
+      })
+    }
+
+    // ----------------------------------------------
+    // Inputs
+    const testImageElement = document.querySelector('#compareImagesInputs input[name=test-image-file]')
+    testImageElement.addEventListener('change', async (event) => {
+        const dataTransfer = event.dataTransfer
+        const files = event.target.files || dataTransfer.files
+
+        const { image, webWorker } = await readImageFile(null, files[0])
+        webWorker.terminate()
+        model.inputs.set("testImage", image)
+        const details = document.getElementById("compareImages-test-image-details")
+        details.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(image, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        details.disabled = false
+    })
+
+    // ----------------------------------------------
+    // Options
+    const baselineImagesElement = document.querySelector('#compareImagesInputs input[name=baseline-images-file]')
+    baselineImagesElement.addEventListener('change', async (event) => {
+        const dataTransfer = event.dataTransfer
+        const files = event.target.files || dataTransfer.files
+
+        const readImages = await Promise.all(Array.from(files).map(async (file) => readImageFile(null, file)))
+        readImages.forEach(img => img.webWorker.terminate())
+        const inputImages = readImages.map(img => img.image)
+        model.options.set("baselineImages", inputImages)
+        const details = document.getElementById("compareImages-baseline-images-details")
+        details.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(inputImages, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        details.disabled = false
+    })
+
+    const differenceThresholdElement = document.querySelector('#compareImagesInputs sl-input[name=difference-threshold]')
+    differenceThresholdElement.addEventListener('sl-change', (event) => {
+        model.options.set("differenceThreshold", parseFloat(differenceThresholdElement.value))
+    })
+
+    const radiusToleranceElement = document.querySelector('#compareImagesInputs sl-input[name=radius-tolerance]')
+    radiusToleranceElement.addEventListener('sl-change', (event) => {
+        model.options.set("radiusTolerance", parseInt(radiusToleranceElement.value))
+    })
+
+    const numberOfPixelsToleranceElement = document.querySelector('#compareImagesInputs sl-input[name=number-of-pixels-tolerance]')
+    numberOfPixelsToleranceElement.addEventListener('sl-change', (event) => {
+        model.options.set("numberOfPixelsTolerance", parseInt(numberOfPixelsToleranceElement.value))
+    })
+
+    const ignoreBoundaryPixelsElement = document.querySelector('#compareImagesInputs sl-checkbox[name=ignore-boundary-pixels]')
+    ignoreBoundaryPixelsElement.addEventListener('sl-change', (event) => {
+        model.options.set("ignoreBoundaryPixels", ignoreBoundaryPixelsElement.checked)
+    })
+
+    // ----------------------------------------------
+    // Outputs
+    const metricsOutputDownload = document.querySelector('#compareImagesOutputs sl-button[name=metrics-download]')
+    metricsOutputDownload.addEventListener('click', async (event) => {
+        event.preventDefault()
+        event.stopPropagation()
+        if (model.outputs.has("metrics")) {
+            const fileName = `metrics.json`
+            globalThis.downloadFile(new TextEncoder().encode(JSON.stringify(model.outputs.get("metrics"))), fileName)
+        }
+    })
+
+    const differenceImageOutputDownload = document.querySelector('#compareImagesOutputs sl-button[name=difference-image-download]')
+    differenceImageOutputDownload.addEventListener('click', async (event) => {
+        event.preventDefault()
+        event.stopPropagation()
+        if (model.outputs.has("differenceImage")) {
+            const differenceImageDownloadFormat = document.getElementById('difference-image-output-format')
+            const downloadFormat = differenceImageDownloadFormat.value || 'nrrd'
+            const fileName = `differenceImage.${downloadFormat}`
+            const { webWorker, arrayBuffer } = await writeImageArrayBuffer(null, copyImage(model.outputs.get("differenceImage")), fileName)
+
+            webWorker.terminate()
+            globalThis.downloadFile(arrayBuffer, fileName)
+        }
+    })
+
+    const differenceUchar2dImageOutputDownload = document.querySelector('#compareImagesOutputs sl-button[name=difference-uchar-2d-image-download]')
+    differenceUchar2dImageOutputDownload.addEventListener('click', async (event) => {
+        event.preventDefault()
+        event.stopPropagation()
+        if (model.outputs.has("differenceUchar2dImage")) {
+            const differenceUchar2dImageDownloadFormat = document.getElementById('difference-uchar-2d-image-output-format')
+            const downloadFormat = differenceUchar2dImageDownloadFormat.value || 'nrrd'
+            const fileName = `differenceUchar2dImage.${downloadFormat}`
+            const { webWorker, arrayBuffer } = await writeImageArrayBuffer(null, copyImage(model.outputs.get("differenceUchar2dImage")), fileName)
+
+            webWorker.terminate()
+            globalThis.downloadFile(arrayBuffer, fileName)
+        }
+    })
+
+    const runButton = document.querySelector('#compareImagesInputs sl-button[name="run"]')
+    runButton.addEventListener('click', async (event) => {
+      event.preventDefault()
+
+      if(!model.inputs.has('testImage')) {
+        globalThis.notify("Required input not provided", "testImage", "danger", "exclamation-octagon")
+        return
+      }
+
+
+      try {
+        runButton.loading = true
+        const t0 = performance.now()
+
+        const { webWorker, metrics, differenceImage, differenceUchar2dImage, } = await compareImages.compareImages(this.webWorker,
+          model.inputs.get('testImage'),
+          Object.fromEntries(model.options.entries())
+        )
+
+        const t1 = performance.now()
+        globalThis.notify("compareImages successfully completed", `in ${t1 - t0} milliseconds.`, "success", "rocket-fill")
+        this.webWorker = webWorker
+
+        model.outputs.set("metrics", metrics)
+        metricsOutputDownload.variant = "success"
+        metricsOutputDownload.disabled = false
+        const metricsDetails = document.getElementById("compareImages-metrics-details")
+        metricsDetails.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(metrics, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        metricsDetails.disabled = false
+        const metricsOutput = document.getElementById("compareImages-metrics-details")
+
+        model.outputs.set("differenceImage", differenceImage)
+        differenceImageOutputDownload.variant = "success"
+        differenceImageOutputDownload.disabled = false
+        const differenceImageDetails = document.getElementById("compareImages-difference-image-details")
+        differenceImageDetails.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(differenceImage, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        differenceImageDetails.disabled = false
+        const differenceImageOutput = document.getElementById('compareImages-difference-image-details')
+
+        model.outputs.set("differenceUchar2dImage", differenceUchar2dImage)
+        differenceUchar2dImageOutputDownload.variant = "success"
+        differenceUchar2dImageOutputDownload.disabled = false
+        const differenceUchar2dImageDetails = document.getElementById("compareImages-difference-uchar-2d-image-details")
+        differenceUchar2dImageDetails.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(differenceUchar2dImage, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        differenceUchar2dImageDetails.disabled = false
+        const differenceUchar2dImageOutput = document.getElementById('compareImages-difference-uchar-2d-image-details')
+      } catch (error) {
+        globalThis.notify("Error while running pipeline", error.toString(), "danger", "exclamation-octagon")
+        throw error
+      } finally {
+        runButton.loading = false
+      }
+    })
+  }
+}
+
+const compareImagesController = new CompareImagesController(compareImagesLoadSampleInputs)
diff --git a/packages/compare-images/typescript/test/browser/demo-app/compare-images-load-sample-inputs.ts b/packages/compare-images/typescript/test/browser/demo-app/compare-images-load-sample-inputs.ts
new file mode 100644
index 000000000..946c14545
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/compare-images-load-sample-inputs.ts
@@ -0,0 +1,29 @@
+export default null
+
+// export default async function compareDoubleImagesLoadSampleInputs (model) {
+
+//   const testImageButton = document.querySelector('#compareImagesInputs sl-button[name=test-image-file-button]')
+//   testImageButton.loading = true
+
+//   const fileName = 'cake_easy.png'
+//   const dataUrl = 'https://bafybeiffac7x3l25hbvne37tiq5sb76oyi6pogg3tm4hztwwoj2d7dlf7q.ipfs.w3s.link/ipfs/bafybeiffac7x3l25hbvne37tiq5sb76oyi6pogg3tm4hztwwoj2d7dlf7q/data/input'
+//   const testImageReponse = await fetch(`${dataUrl}/${fileName}`)
+//   // todo read Image with @itk-wasm/image-io
+//   const dicomData = new Uint8Array(await dicomReponse.arrayBuffer())
+//   inputFiles.push({ data: dicomData, path: fileName })
+//   model.options.set('inputImages', inputFiles)
+
+//   const dicomElement = document.getElementById('readImageDicomFileSeries-input-images-details')
+//   dicomElement.innerHTML = `<pre>${globalThis.escapeHtml(inputFiles.map((file) => file.path).toString())}</pre>`
+//   dicomElement.disabled = false
+
+//   dicomButton.loading = false
+
+
+//   // const baselineImagesButton = document.querySelector('#compareImagesInputs sl-button[name=baseline-images-file-button]')
+//   // baselineImagesButton.loading = true
+
+//   // todo
+
+//   return model
+// }
diff --git a/packages/compare-images/typescript/test/browser/demo-app/index.html b/packages/compare-images/typescript/test/browser/demo-app/index.html
new file mode 100644
index 000000000..d701df4f3
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/index.html
@@ -0,0 +1,154 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <link rel="icon" type="image/svg+xml" href="./logo.svg" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <link rel="stylesheet" href="./style.css" />
+
+<script type="module">
+  import '@shoelace-style/shoelace/dist/themes/light.css';
+  import '@shoelace-style/shoelace/dist/themes/dark.css';
+  import '@shoelace-style/shoelace/dist/components/button/button.js';
+  import '@shoelace-style/shoelace/dist/components/tab-group/tab-group.js';
+  import '@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js';
+  import '@shoelace-style/shoelace/dist/components/tab/tab.js';
+  import '@shoelace-style/shoelace/dist/components/input/input.js';
+  import '@shoelace-style/shoelace/dist/components/checkbox/checkbox.js';
+  import '@shoelace-style/shoelace/dist/components/textarea/textarea.js';
+  import '@shoelace-style/shoelace/dist/components/alert/alert.js';
+  import '@shoelace-style/shoelace/dist/components/icon/icon.js';
+  import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js';
+  import '@shoelace-style/shoelace/dist/components/divider/divider.js';
+  import '@shoelace-style/shoelace/dist/components/details/details.js';
+  import '@shoelace-style/shoelace/dist/components/popup/popup.js';
+  import '@shoelace-style/shoelace/dist/components/tag/tag.js';
+  import '@shoelace-style/shoelace/dist/components/select/select.js';
+  import '@shoelace-style/shoelace/dist/components/option/option.js';
+  import '@shoelace-style/shoelace/dist/components/tooltip/tooltip.js';
+
+  import { setBasePath } from '@shoelace-style/shoelace/dist/utilities/base-path';
+  setBasePath('/');
+
+  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
+    // dark mode
+    document.documentElement.classList.add('sl-theme-dark');
+  }
+</script>
+
+  <title>@itk-wasm/compare-images<img src="./javascript-logo.svg" alt="JavaScript logo" class="language-logo"/><img src="./typescript-logo.svg" alt="TypeScript logo" class="language-logo"/></title>
+</head>
+<body>
+
+<script type="module" src="/utilities.js"></script>
+
+<!-- https://tholman.com/github-corners/ -->
+<a href="https://github.com/InsightSoftwareConsortium/itk-wasm" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:#151513; color:#fff; position: absolute; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a>
+
+<div id="app">
+  <a href="https://wasm.itk.org/" target="_blank">
+    <img src="./logo.svg" class="logo vanilla" alt="ITK Wasm logo" />
+  </a>
+
+  <h2>@itk-wasm/compare-images<img src="./javascript-logo.svg" alt="JavaScript logo" class="language-logo"/><img src="./typescript-logo.svg" alt="TypeScript logo" class="language-logo"/></h2>
+  <i>Compare images with a tolerance for regression testing.</i>
+  <h3>👨‍💻 Live API Demo ✨</h3>
+  <br /><br />
+
+  <sl-tab-group>
+    <sl-tab slot="nav" panel="compareImages-panel">compareImages</sl-tab>
+
+
+    <sl-tab-panel name="compareImages-panel">
+
+    <small><i>Compare images with a tolerance for regression testing.</i></small><br /><br />
+
+    <div id="compareImagesInputs"><form action="">
+      <sl-tooltip content="Use the Upload button to provide the testImage"><sl-details id="compareImages-test-image-details" summary="testImage: The input test image" disabled></sl-details></sl-tooltip>
+      <label for="test-image-file"><sl-button name="test-image-file-button" variant="primary" outline onclick="this.parentElement.nextElementSibling.click()">Upload</sp-button></label><input type="file" name="test-image-file" style="display: none"/>
+<br /><br />
+      <sl-tooltip content="Use the Upload button to provide the baselineImages"><sl-details id="compareImages-baseline-images-details" summary="baselineImages: Baseline images compare against" disabled></sl-details></sl-tooltip>
+      <label for="baseline-images-file"><sl-button name="baseline-images-file-button" variant="primary" outline onclick="this.parentElement.nextElementSibling.click()">Upload</sp-button></label><input type="file" name="baseline-images-file" style="display: none"/>
+<br /><br />
+      <sl-input name="difference-threshold" type="number" value="0" step="any" label="differenceThreshold" help-text="Intensity difference for pixels to be considered different."></sl-input>
+<br />
+      <sl-input name="radius-tolerance" type="number" value="0" min="0" step="1" label="radiusTolerance" help-text="Radius of the neighborhood around a pixel to search for similar intensity values."></sl-input>
+<br />
+      <sl-input name="number-of-pixels-tolerance" type="number" value="0" min="0" step="1" label="numberOfPixelsTolerance" help-text="Number of pixels that can be different before the test fails."></sl-input>
+<br />
+      <sl-checkbox name="ignore-boundary-pixels">ignoreBoundaryPixels - <i>Ignore boundary pixels. Useful when resampling may have introduced difference pixel values along the image edge.</i></sl-checkbox>
+<br /><br />
+    <sl-divider></sl-divider>
+      <br /><sl-button name="loadSampleInputs" variant="default" style="display: none;">Load sample inputs</sl-button>
+      <sl-button type="button" variant="success" name="run">Run</sl-button><br /><br />
+
+    </form></div>
+    <sl-divider></sl-divider>
+
+    <div id="compareImagesOutputs">
+      <sl-details disabled id="compareImages-metrics-details" summary="metrics: Metrics for the baseline with the fewest number of pixels outside the tolerances."></sl-details>
+      <sl-button variant="neutral" outline name="metrics-download" disabled>Download</sl-button>
+<br /><br />
+      <sl-details disabled id="compareImages-difference-image-details" summary="differenceImage: Absolute difference image"></sl-details>
+      <sl-select id="difference-image-output-format" placeholder="Format">
+        <sl-option value="bmp">bmp</sl-option>
+        <sl-option value="dcm">dcm</sl-option>
+        <sl-option value="gipl">gipl</sl-option>
+        <sl-option value="hdf5">hdf5</sl-option>
+        <sl-option value="jpg">jpg</sl-option>
+        <sl-option value="lsm">lsm</sl-option>
+        <sl-option value="mnc">mnc</sl-option>
+        <sl-option value="mnc.gz">mnc.gz</sl-option>
+        <sl-option value="mgh">mgh</sl-option>
+        <sl-option value="mha">mha</sl-option>
+        <sl-option value="mrc">mrc</sl-option>
+        <sl-option value="nii">nii</sl-option>
+        <sl-option value="nii.gz">nii.gz</sl-option>
+        <sl-option value="png">png</sl-option>
+        <sl-option value="nrrd">nrrd</sl-option>
+        <sl-option value="png">png</sl-option>
+        <sl-option value="pic">pic</sl-option>
+        <sl-option value="tif">tif</sl-option>
+        <sl-option value="isq">isq</sl-option>
+        <sl-option value="fdf">fdf</sl-option>
+        <sl-option value="vtk">vtk</sl-option>
+      </sl-select>
+      <sl-button variant="neutral" outline name="difference-image-download" disabled>Download</sl-button>
+<br /><br />
+      <sl-details disabled id="compareImages-difference-uchar-2d-image-details" summary="differenceUchar2dImage: Unsigned char, 2D difference image for rendering"></sl-details>
+      <sl-select id="difference-uchar-2d-image-output-format" placeholder="Format">
+        <sl-option value="bmp">bmp</sl-option>
+        <sl-option value="dcm">dcm</sl-option>
+        <sl-option value="gipl">gipl</sl-option>
+        <sl-option value="hdf5">hdf5</sl-option>
+        <sl-option value="jpg">jpg</sl-option>
+        <sl-option value="lsm">lsm</sl-option>
+        <sl-option value="mnc">mnc</sl-option>
+        <sl-option value="mnc.gz">mnc.gz</sl-option>
+        <sl-option value="mgh">mgh</sl-option>
+        <sl-option value="mha">mha</sl-option>
+        <sl-option value="mrc">mrc</sl-option>
+        <sl-option value="nii">nii</sl-option>
+        <sl-option value="nii.gz">nii.gz</sl-option>
+        <sl-option value="png">png</sl-option>
+        <sl-option value="nrrd">nrrd</sl-option>
+        <sl-option value="png">png</sl-option>
+        <sl-option value="pic">pic</sl-option>
+        <sl-option value="tif">tif</sl-option>
+        <sl-option value="isq">isq</sl-option>
+        <sl-option value="fdf">fdf</sl-option>
+        <sl-option value="vtk">vtk</sl-option>
+      </sl-select>
+      <sl-button variant="neutral" outline name="difference-uchar-2d-image-download" disabled>Download</sl-button>
+<br /><br />
+    </div>
+
+    </sl-tab-panel>
+
+  </sl-tab-group>
+</div>
+
+<script type="module" src="./index.ts"></script>
+
+</body>
+</html>
diff --git a/packages/compare-images/typescript/test/browser/demo-app/index.ts b/packages/compare-images/typescript/test/browser/demo-app/index.ts
new file mode 100644
index 000000000..8874281cc
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/index.ts
@@ -0,0 +1,9 @@
+import * as compareImages from '../../../dist/bundles/compare-images.js'
+
+// Use local, vendored WebAssembly module assets
+const pipelinesBaseUrl: string | URL = new URL('/pipelines', document.location.origin).href
+compareImages.setPipelinesBaseUrl(pipelinesBaseUrl)
+const pipelineWorkerUrl: string | URL | null = new URL('/web-workers/pipeline.worker.js', document.location.origin).href
+compareImages.setPipelineWorkerUrl(pipelineWorkerUrl)
+
+import './compare-images-controller.js'
diff --git a/packages/compare-images/typescript/test/browser/demo-app/javascript-logo.svg b/packages/compare-images/typescript/test/browser/demo-app/javascript-logo.svg
new file mode 100644
index 000000000..1f203d35e
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/javascript-logo.svg
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 1052 1052"><path fill="#f0db4f" d="M0 0h1052v1052H0z"/><path d="M965.9 801.1c-7.7-48-39-88.3-131.7-125.9-32.2-14.8-68.1-25.399-78.8-49.8-3.8-14.2-4.3-22.2-1.9-30.8 6.9-27.9 40.2-36.6 66.6-28.6 17 5.7 33.1 18.801 42.8 39.7 45.4-29.399 45.3-29.2 77-49.399-11.6-18-17.8-26.301-25.4-34-27.3-30.5-64.5-46.2-124-45-10.3 1.3-20.699 2.699-31 4-29.699 7.5-58 23.1-74.6 44-49.8 56.5-35.6 155.399 25 196.1 59.7 44.8 147.4 55 158.6 96.9 10.9 51.3-37.699 67.899-86 62-35.6-7.4-55.399-25.5-76.8-58.4-39.399 22.8-39.399 22.8-79.899 46.1 9.6 21 19.699 30.5 35.8 48.7 76.2 77.3 266.899 73.5 301.1-43.5 1.399-4.001 10.6-30.801 3.199-72.101zm-394-317.6h-98.4c0 85-.399 169.4-.399 254.4 0 54.1 2.8 103.7-6 118.9-14.4 29.899-51.7 26.2-68.7 20.399-17.3-8.5-26.1-20.6-36.3-37.699-2.8-4.9-4.9-8.7-5.601-9-26.699 16.3-53.3 32.699-80 49 13.301 27.3 32.9 51 58 66.399 37.5 22.5 87.9 29.4 140.601 17.3 34.3-10 63.899-30.699 79.399-62.199 22.4-41.3 17.6-91.3 17.4-146.6.5-90.2 0-180.4 0-270.9z" fill="#323330"/></svg>
\ No newline at end of file
diff --git a/packages/compare-images/typescript/test/browser/demo-app/logo.svg b/packages/compare-images/typescript/test/browser/demo-app/logo.svg
new file mode 100644
index 000000000..78843f30c
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/logo.svg
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 792 612" xmlns:v="https://vecta.io/nano"><path d="M301.8 416.7h14.9l5.2 28.4h.2l11.7-28.4H349l4.4 28.4h.2l12.5-28.4h14.1l-22.4 45.2H344l-5.2-30h-.2l-12.6 30h-14l-10.2-45.2zm105.7 39.6h-.2c-3.8 4.8-9.7 6.8-15.3 6.8-8.5 0-15.3-4.8-14.1-14 2.2-15.6 21.2-15.9 32.8-15.9.7-4.9-2.9-8.1-7.7-8.1-4.6 0-8.6 2-12.1 5.4l-6.4-7.6c5.8-4.8 13.6-7.3 20.9-7.3 16.3 0 19.4 8.3 17.4 23.3l-3.3 23h-12.8l.8-5.6zm-1.5-14.2c-4.3 0-13.9.4-14.7 6.5-.5 3.3 2.9 4.9 6 4.9 5.5 0 11-2.9 11.7-8.5l.5-3H406zm52.3-12.1c-1.7-2.5-4.2-3.8-7.5-3.8-2.3 0-5.3 1-5.7 3.7-.9 6.7 23.4 1 21 18.2-1.7 11.5-13.3 14.9-23 14.9-6.3 0-13-1.6-16.8-6.1l9.8-9.4c2.1 3.2 4.9 4.9 9.1 4.9 3.2 0 6.5-.8 6.8-3.3 1-7.3-23.3-1.1-20.9-18.3 1.5-10.6 11.6-15.3 21.1-15.3 6 0 12.4 1.5 15.9 6l-9.8 8.5zm19.3-13.3H491l-.8 6.1h.2c2.3-3.5 7.4-7.3 14.2-7.3 6.3 0 11.2 2.6 12.8 8.1 4-5.8 8.7-8.1 15.4-8.1 12.3 0 14.8 8.6 13.2 19.6l-3.7 26.7h-14l3.7-26.3c.6-4.2-.6-7.7-5.1-7.7-6.4 0-9 4.6-9.9 10.1l-3.3 23.9h-14l3.3-23.9c.5-3.3 1.6-10.1-4.8-10.1-7.3 0-9.1 5.7-10 11.5l-3.2 22.5h-14l6.6-45.1z" fill="#4f4f4f"/><path d="M239.7 153l-40.9 230.7h66.8l41-230.7z" fill="#064f8c"/><path d="M191.3 357.6l-66.3 21c-1.3.4-2.1 1.8-1.7 3.1h0c.3 1.2 1.3 2 2.5 2h62.6c1.3 0 2.3-.9 2.5-2.1l3.7-21c.4-2-1.4-3.6-3.3-3z" fill="#f7941d"/><path d="M334.3 383.7h9.4l29.7-168.9H364zm129.1 0h9.4l29.8-168.9h-9.4zm107.3-68.5l-4.9 27.7 23 94.4h11zm58.7-162.3h-11.7l-24.8 37-4 22.6h.6z" fill="#073e66"/><path d="M526.6 153H320.5l-9.7 53.7h73.4L353 383.6h60.6l31.2-176.9h56.1z" fill="#064f8c"/><path d="M187 246.1l85.6 59c18.8 13.4 26.3 37.7 18.4 59.4l-2.8 7.6-90.7-63.2C152 270.6 187 246.1 187 246.1z" fill="#f6b015"/><path d="M706.5 152.8h-67.4l-54.7 81.7h-7.9l14.4-81.5H539l-25.6 53.4-31.3 177.3h68.1l16.1-91.7h7.6l35.4 145.3h67.4L634.1 261z" fill="#064f8c"/><path d="M53.1 173.2l12.3-.7-7.4 7.1c-.8.8-.7 1 .3.5 6.3-3.3 26.2-13.2 26.2-13.2-8.5.1-20.8 1.2-31.6 5.7-1.1.3-1 .6.2.6z" fill="#f7941d"/><path d="M290.3 207.7l-64-44.4s-24.5-18.6-48.3-27l-19.2-8.2c-9.7-5.8-28.1.7-41.1 6.2l-34.4 14.3c-5.3 3.1-8.4 8.9-8.2 15v.1c.3 6.7 5.5 12.1 12.1 13.1l59.6 3.7c7.7.2 14.3-.3 20.4-3.9h0s10.3 1.2 31.6 17.2h0l107 80.7h.1l2.7-7.5c7.5-24.7.4-45.9-18.3-59.3z" fill="#f6b015"/><path d="M301.4 260.4L200.1 183h0c-21.2-16-31.6-17.2-31.6-17.2-6.1 3.6-12.6 4.1-20.4 3.9L88.5 166c-5.7-.8-10.3-5-11.6-10.3-1.2 2.5-1.8 5.2-1.7 8v.1c.3 6.5 5.2 11.8 11.5 13 .4.1.8.1 1.2.1l59 3.6c7.5.2 14.3-.3 20.4-3.9 0 0 10.3 1.2 31.6 17.2h0l97 73.3-3.3 18.5c5.7-1.7 11.4-6.1 13.4-10.8v-.1c1.6-5.2-.2-11-4.6-14.3z" fill="#f7941d"/><path d="M115.1 149.9s5.7-5.4 10-6 7.9.7 8.3 3c.3 2.3-3.4 2.7-7.7 3.3-4.2.6-10.6-.3-10.6-.3z" fill="#f1f2f2"/><path d="M283.8 358L204 302.4c-11-9.2-17.3-17.7-20.5-25.1-1.9-6.2-1.7-15.3 8-26.1.8-.8 1.7-.8 3.6.5h0l15.9 11 3.6-20.2-14.3-.1c-8.4 0-13.3 3.8-13.3 3.8s-35 24.5 10.5 62.8l79.2 55.2c.8.6 1.2 1.5 1 2.5l-2.9 16.2c5.7-1.7 11.5-5.7 13.3-10.7 2-5.3.2-11.1-4.3-14.2z" fill="#f7941d"/></svg>
\ No newline at end of file
diff --git a/packages/compare-images/typescript/test/browser/demo-app/style.css b/packages/compare-images/typescript/test/browser/demo-app/style.css
new file mode 100644
index 000000000..b8d34197c
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/style.css
@@ -0,0 +1,113 @@
+:root {
+  font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+  font-size: 16px;
+  line-height: 20px;
+  font-weight: 400;
+
+  color-scheme: light dark;
+  color: rgba(255, 255, 255, 0.87);
+  background-color: #242424;
+
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  -webkit-text-size-adjust: 100%;
+}
+
+a {
+  font-weight: 500;
+  color: #646cff;
+  text-decoration: inherit;
+}
+a:hover {
+  color: #535bf2;
+}
+
+body {
+  margin: 0;
+  display: flex;
+  place-items: center;
+  min-width: 320px;
+  min-height: 100vh;
+}
+
+h1 {
+  font-size: 3.2em;
+  line-height: 1.1;
+}
+
+li {
+  text-align: left;
+}
+
+#app {
+  min-width: 640px;
+  max-width: 1280px;
+  margin: 0 auto;
+  padding: 2rem;
+  text-align: left;
+}
+
+.logo {
+  height: 10em;
+  padding: 0.1em;
+  will-change: filter;
+}
+.logo:hover {
+  filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.vanilla:hover {
+  filter: drop-shadow(0 0 2em #3178c6aa);
+}
+
+.language-logo {
+  height: 1.0em;
+  padding-left: 0.6em;
+}
+
+.github-corner {
+  border-bottom: 0;
+  position: fixed;
+  right: 0;
+  text-decoration: none;
+  top: 0;
+  z-index: 1;
+}
+
+.github-corner:hover .octo-arm {
+  animation:octocat-wave 560ms ease-in-out;
+}
+
+@keyframes octocat-wave {
+  0%,100%{transform:rotate(0)}
+  20%,60%{transform:rotate(-25deg)}
+  40%,80%{transform:rotate(10deg)}
+}
+
+@media (max-width:500px) {
+  .github-corner:hover .octo-arm{
+    animation:none;
+  }
+  .github-corner .octo-arm{
+    animation:octocat-wave 560ms ease-in-out;
+  }
+}
+
+@media (prefers-color-scheme: light) {
+  :root {
+    color: #213547;
+    background-color: #ffffff;
+  }
+  a:hover {
+    color: #747bff;
+  }
+  button {
+    background-color: #f9f9f9;
+  }
+}
+
+.sl-toast-stack {
+  left: 0;
+  right: auto;
+}
\ No newline at end of file
diff --git a/packages/compare-images/typescript/test/browser/demo-app/typescript-logo.svg b/packages/compare-images/typescript/test/browser/demo-app/typescript-logo.svg
new file mode 100644
index 000000000..3acb58ca1
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/typescript-logo.svg
@@ -0,0 +1,1 @@
+<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="2500" height="2500"><style>.st0{fill:#007acc}.st1{fill:#fff}</style><path class="st0" d="M0 200V0h400v400H0"/><path class="st1" d="M87.7 200.7V217h52v148h36.9V217h52v-16c0-9 0-16.3-.4-16.5 0-.3-31.7-.4-70.2-.4l-70 .3v16.4l-.3-.1zM321.4 184c10.2 2.4 18 7 25 14.3 3.7 4 9.2 11 9.6 12.8 0 .6-17.3 12.3-27.8 18.8-.4.3-2-1.4-3.6-4-5.2-7.4-10.5-10.6-18.8-11.2-12-.8-20 5.5-20 16 0 3.2.6 5 1.8 7.6 2.7 5.5 7.7 8.8 23.2 15.6 28.6 12.3 41 20.4 48.5 32 8.5 13 10.4 33.4 4.7 48.7-6.4 16.7-22 28-44.3 31.7-7 1.2-23 1-30.5-.3-16-3-31.3-11-40.7-21.3-3.7-4-10.8-14.7-10.4-15.4l3.8-2.4 15-8.7 11.3-6.6 2.6 3.5c3.3 5.2 10.7 12.2 15 14.6 13 6.7 30.4 5.8 39-2 3.7-3.4 5.3-7 5.3-12 0-4.6-.7-6.7-3-10.2-3.2-4.4-9.6-8-27.6-16-20.7-8.8-29.5-14.4-37.7-23-4.7-5.2-9-13.3-11-20-1.5-5.8-2-20-.6-25.7 4.3-20 19.4-34 41-38 7-1.4 23.5-.8 30.4 1l-.2.2z"/></svg>
\ No newline at end of file
diff --git a/packages/compare-images/typescript/test/browser/demo-app/utilities.js b/packages/compare-images/typescript/test/browser/demo-app/utilities.js
new file mode 100644
index 000000000..0820648ce
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/utilities.js
@@ -0,0 +1,81 @@
+// Generated file. To retain edits, remove this comment.
+
+function downloadFile(content, filename) {
+  const url = URL.createObjectURL(new Blob([content]))
+  const a = document.createElement('a')
+  a.href = url
+  a.download = filename || 'download'
+  document.body.appendChild(a)
+  function clickHandler(event) {
+    setTimeout(() => {
+      URL.revokeObjectURL(url)
+      a.removeEventListener('click', clickHandler)
+    }, 200)
+  };
+  a.addEventListener('click', clickHandler, false)
+  a.click()
+  return a
+}
+globalThis.downloadFile = downloadFile
+
+function interfaceTypeJsonReplacer (key, value) {
+  if (!!value && value.byteLength !== undefined) {
+    return String(value.slice(0, 6)) + '...'
+  }
+  return value
+}
+globalThis.interfaceTypeJsonReplacer = interfaceTypeJsonReplacer
+
+function escapeHtml(html) {
+  const div = document.createElement('div');
+  div.textContent = html;
+  const escaped = div.innerHTML;
+  div.remove()
+  return escaped
+}
+globalThis.escapeHtml = escapeHtml
+
+function notify(title, message, variant = 'primary', icon = 'info-circle', duration = 3000) {
+  const slAlert = Object.assign(document.createElement('sl-alert'), {
+    variant,
+    closable: true,
+    duration: duration,
+    innerHTML: `
+      <sl-icon name="${icon}" slot="icon"></sl-icon>
+      <strong>${escapeHtml(title)}</strong><br />
+      ${escapeHtml(message)}
+    `
+  });
+
+  document.body.append(slAlert);
+  setTimeout(() => slAlert.toast(), 300)
+}
+globalThis.notify = notify
+
+function disableInputs(inputId) {
+  document.querySelectorAll(`#${inputId} sl-button`).forEach(button => {
+    button.disabled = true
+  })
+  document.querySelector(`#${inputId} sl-button[name="run"]`).loading = true
+  document.querySelectorAll(`#${inputId} sl-checkbox`).forEach(checkbox => {
+    checkbox.disabled = true
+  })
+  document.querySelectorAll(`#${inputId} sl-input`).forEach(input => {
+    input.disabled = true
+  })
+}
+globalThis.disableInputs = disableInputs
+
+function enableInputs(inputId) {
+  document.querySelectorAll(`#${inputId} sl-button`).forEach(button => {
+    button.disabled = false
+  })
+  document.querySelector(`#${inputId} sl-button[name="run"]`).loading = false
+  document.querySelectorAll(`#${inputId} sl-checkbox`).forEach(checkbox => {
+    checkbox.disabled = false
+  })
+  document.querySelectorAll(`#${inputId} sl-input`).forEach(input => {
+    input.disabled = false
+  })
+}
+globalThis.enableInputs = enableInputs
diff --git a/packages/compare-images/typescript/test/browser/demo-app/vector-magnitude-controller.ts b/packages/compare-images/typescript/test/browser/demo-app/vector-magnitude-controller.ts
new file mode 100644
index 000000000..fc154d590
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/vector-magnitude-controller.ts
@@ -0,0 +1,116 @@
+// Generated file. To retain edits, remove this comment.
+
+import { readImageFile } from 'itk-wasm'
+import { writeImageArrayBuffer, copyImage } from 'itk-wasm'
+import * as compareImages from '../../../dist/bundles/compare-images.js'
+import vectorMagnitudeLoadSampleInputs from "./vector-magnitude-load-sample-inputs.js"
+
+class VectorMagnitudeModel {
+
+  inputs: Map<string, any>
+  options: Map<string, any>
+  outputs: Map<string, any>
+
+  constructor() {
+    this.inputs = new Map()
+    this.options = new Map()
+    this.outputs = new Map()
+    }
+  }
+
+
+class VectorMagnitudeController  {
+
+  constructor(loadSampleInputs) {
+    this.loadSampleInputs = loadSampleInputs
+
+    this.model = new VectorMagnitudeModel()
+    const model = this.model
+
+    this.webWorker = null
+
+    if (loadSampleInputs) {
+      const loadSampleInputsButton = document.querySelector("#vectorMagnitudeInputs [name=loadSampleInputs]")
+      loadSampleInputsButton.setAttribute('style', 'display: block-inline;')
+      loadSampleInputsButton.addEventListener('click', async (event) => {
+        loadSampleInputsButton.loading = true
+        await loadSampleInputs(model)
+        loadSampleInputsButton.loading = false
+      })
+    }
+
+    // ----------------------------------------------
+    // Inputs
+    const vectorImageElement = document.querySelector('#vectorMagnitudeInputs input[name=vector-image-file]')
+    vectorImageElement.addEventListener('change', async (event) => {
+        const dataTransfer = event.dataTransfer
+        const files = event.target.files || dataTransfer.files
+
+        const { image, webWorker } = await readImageFile(null, files[0])
+        webWorker.terminate()
+        model.inputs.set("vectorImage", image)
+        const details = document.getElementById("vectorMagnitude-vector-image-details")
+        details.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(image, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        details.disabled = false
+    })
+
+    // ----------------------------------------------
+    // Options
+    // ----------------------------------------------
+    // Outputs
+    const magnitudeImageOutputDownload = document.querySelector('#vectorMagnitudeOutputs sl-button[name=magnitude-image-download]')
+    magnitudeImageOutputDownload.addEventListener('click', async (event) => {
+        event.preventDefault()
+        event.stopPropagation()
+        if (model.outputs.has("magnitudeImage")) {
+            const magnitudeImageDownloadFormat = document.getElementById('magnitude-image-output-format')
+            const downloadFormat = magnitudeImageDownloadFormat.value || 'nrrd'
+            const fileName = `magnitudeImage.${downloadFormat}`
+            const { webWorker, arrayBuffer } = await writeImageArrayBuffer(null, copyImage(model.outputs.get("magnitudeImage")), fileName)
+
+            webWorker.terminate()
+            globalThis.downloadFile(arrayBuffer, fileName)
+        }
+    })
+
+    const runButton = document.querySelector('#vectorMagnitudeInputs sl-button[name="run"]')
+    runButton.addEventListener('click', async (event) => {
+      event.preventDefault()
+
+      if(!model.inputs.has('vectorImage')) {
+        globalThis.notify("Required input not provided", "vectorImage", "danger", "exclamation-octagon")
+        return
+      }
+
+
+      try {
+        runButton.loading = true
+        const t0 = performance.now()
+
+        const { webWorker, magnitudeImage, } = await compareImages.vectorMagnitude(this.webWorker,
+          model.inputs.get('vectorImage'),
+          Object.fromEntries(model.options.entries())
+        )
+
+        const t1 = performance.now()
+        globalThis.notify("vectorMagnitude successfully completed", `in ${t1 - t0} milliseconds.`, "success", "rocket-fill")
+        this.webWorker = webWorker
+
+        model.outputs.set("magnitudeImage", magnitudeImage)
+        magnitudeImageOutputDownload.variant = "success"
+        magnitudeImageOutputDownload.disabled = false
+        const magnitudeImageDetails = document.getElementById("vectorMagnitude-magnitude-image-details")
+        magnitudeImageDetails.innerHTML = `<pre>${globalThis.escapeHtml(JSON.stringify(magnitudeImage, globalThis.interfaceTypeJsonReplacer, 2))}</pre>`
+        magnitudeImageDetails.disabled = false
+        const magnitudeImageOutput = document.getElementById('vectorMagnitude-magnitude-image-details')
+      } catch (error) {
+        globalThis.notify("Error while running pipeline", error.toString(), "danger", "exclamation-octagon")
+        throw error
+      } finally {
+        runButton.loading = false
+      }
+    })
+  }
+}
+
+const vectorMagnitudeController = new VectorMagnitudeController(vectorMagnitudeLoadSampleInputs)
diff --git a/packages/compare-images/typescript/test/browser/demo-app/vector-magnitude-load-sample-inputs.ts b/packages/compare-images/typescript/test/browser/demo-app/vector-magnitude-load-sample-inputs.ts
new file mode 100644
index 000000000..ecd9c4ab0
--- /dev/null
+++ b/packages/compare-images/typescript/test/browser/demo-app/vector-magnitude-load-sample-inputs.ts
@@ -0,0 +1,21 @@
+// Generated file. To retain edits, remove this comment.
+
+export default null
+// export default async function vectorMagnitudeLoadSampleInputs (model) {
+
+  // Load sample inputs for the vectorMagnitude function.
+  //
+  // This function should load sample inputs:
+  //
+  //  1) In the provided model map.
+  //  2) Into the corresponding HTML input elements.
+  //
+  // Example for an input named `exampleInput`:
+
+  // const exampleInput = 5
+  // model.inputs.set("exampleInput", exampleInput)
+  // const exampleElement = document.querySelector("#vectorMagnitudeInputs [name=example-input]")
+  // exampleElement.value = 5
+
+  // return model
+// }
diff --git a/packages/compare-images/typescript/test/node/compare-double-images-test.js b/packages/compare-images/typescript/test/node/compare-double-images-test.js
new file mode 100644
index 000000000..fc0a3b374
--- /dev/null
+++ b/packages/compare-images/typescript/test/node/compare-double-images-test.js
@@ -0,0 +1,29 @@
+import test from 'ava'
+import path from 'path'
+
+import { compareDoubleImagesNode } from '../../dist/bundles/compare-images-node.js'
+import { readImageLocalFile } from 'itk-wasm'
+
+const inputPathPrefix = '../test/data/input/';
+
+test('compareDoubleImagesNode produces the expected metrics and difference images', async t => {
+  const testImageFile = 'cake_easy.iwi.cbor'
+  const testImagePath = path.join(inputPathPrefix, testImageFile)
+  const testImage = await readImageLocalFile(testImagePath)
+
+  const baselineImageFile = 'cake_hard.iwi.cbor'
+  const baselineImagePath = path.join(inputPathPrefix, baselineImageFile)
+  const baselineImage = await readImageLocalFile(baselineImagePath)
+
+  const { metrics, differenceImage, differenceUchar2dImage } = await compareDoubleImagesNode(testImage, { baselineImages: [baselineImage,] })
+
+  t.is(metrics.almostEqual, false)
+  t.is(metrics.numberOfPixelsWithDifferences, 9915)
+  t.is(metrics.minimumDifference, 1.0)
+  t.is(metrics.maximumDifference, 107.0)
+  t.is(metrics.totalDifference, 337334.0)
+  t.is(metrics.meanDifference, 34.02259203227433)
+
+  t.is(differenceImage.imageType.componentType, 'float64')
+  t.is(differenceUchar2dImage.imageType.componentType, 'uint8')
+})
diff --git a/packages/compare-images/typescript/test/node/compare-images-test.js b/packages/compare-images/typescript/test/node/compare-images-test.js
new file mode 100644
index 000000000..f3e32f556
--- /dev/null
+++ b/packages/compare-images/typescript/test/node/compare-images-test.js
@@ -0,0 +1,51 @@
+import test from 'ava'
+import path from 'path'
+
+import { compareImagesNode } from '../../dist/bundles/compare-images-node.js'
+import { readImageLocalFile } from 'itk-wasm'
+
+const inputPathPrefix = '../test/data/input/'
+
+test('compareImagesNode produces the expected metrics and difference images for uint8 inputs', async t => {
+  const testImageFile = 'cake_easy.png'
+  const testImagePath = path.join(inputPathPrefix, testImageFile)
+  const testImage = await readImageLocalFile(testImagePath)
+
+  const baselineImageFile = 'cake_hard.png'
+  const baselineImagePath = path.join(inputPathPrefix, baselineImageFile)
+  const baselineImage = await readImageLocalFile(baselineImagePath)
+
+  const { metrics, differenceImage, differenceUchar2dImage } = await compareImagesNode(testImage, { baselineImages: [baselineImage,] })
+
+  t.is(metrics.almostEqual, false)
+  t.is(metrics.numberOfPixelsWithDifferences, 9915)
+  t.is(metrics.minimumDifference, 1.0)
+  t.is(metrics.maximumDifference, 107.0)
+  t.is(metrics.totalDifference, 337334.0)
+  t.is(metrics.meanDifference, 34.02259203227433)
+
+  t.is(differenceImage.imageType.componentType, 'float64')
+  t.is(differenceUchar2dImage.imageType.componentType, 'uint8')
+})
+
+test('compareImagesNode produces the expected metrics and difference images for rgb inputs', async t => {
+  const testImageFile = 'apple.jpg'
+  const testImagePath = path.join(inputPathPrefix, testImageFile)
+  const testImage = await readImageLocalFile(testImagePath)
+
+  const baselineImageFile = 'orange.jpg'
+  const baselineImagePath = path.join(inputPathPrefix, baselineImageFile)
+  const baselineImage = await readImageLocalFile(baselineImagePath)
+
+  const { metrics, differenceImage, differenceUchar2dImage } = await compareImagesNode(testImage, { baselineImages: [baselineImage,] })
+
+  t.is(metrics.almostEqual, false)
+  t.is(metrics.numberOfPixelsWithDifferences, 26477)
+  t.is(metrics.minimumDifference, 0.002273026683894841)
+  t.is(metrics.maximumDifference, 312.2511648746159)
+  t.is(metrics.totalDifference, 3121703.1639738297)
+  t.is(metrics.meanDifference, 117.90244982338746)
+
+  t.is(differenceImage.imageType.componentType, 'float64')
+  t.is(differenceUchar2dImage.imageType.componentType, 'uint8')
+})
diff --git a/packages/compress-stringify/typescript/test/browser/demo-app/index.html b/packages/compress-stringify/typescript/test/browser/demo-app/index.html
index c491f7417..1369c8ce3 100644
--- a/packages/compress-stringify/typescript/test/browser/demo-app/index.html
+++ b/packages/compress-stringify/typescript/test/browser/demo-app/index.html
@@ -54,6 +54,7 @@
 
   <h2>@itk-wasm/compress-stringify<img src="./javascript-logo.svg" alt="JavaScript logo" class="language-logo"/><img src="./typescript-logo.svg" alt="TypeScript logo" class="language-logo"/></h2>
   <i>Zstandard compression and decompression and base64 encoding and decoding in WebAssembly.</i>
+  <h3>👨‍💻 Live API Demo ✨</h3>
   <br /><br />
 
   <sl-tab-group>
@@ -71,7 +72,7 @@ <h2>@itk-wasm/compress-stringify<img src="./javascript-logo.svg" alt="JavaScript
 <br /><br />
       <sl-checkbox name="stringify">stringify - <i>Stringify the output</i></sl-checkbox>
 <br /><br />
-      <sl-input name="compression-level" type="number" value="3" label="compressionLevel" help-text="Compression level, typically 1-9"></sl-input>
+      <sl-input name="compression-level" type="number" value="3" step="1" label="compressionLevel" help-text="Compression level, typically 1-9"></sl-input>
 <br />
       <sl-input name="data-url-prefix" type="text" label="dataUrlPrefix" help-text="dataURL prefix"></sl-input>
     <sl-divider></sl-divider>
diff --git a/packages/compress-stringify/typescript/test/node/compressStringifyTest.js b/packages/compress-stringify/typescript/test/node/compress-stringify-test.js
similarity index 100%
rename from packages/compress-stringify/typescript/test/node/compressStringifyTest.js
rename to packages/compress-stringify/typescript/test/node/compress-stringify-test.js
diff --git a/packages/core/python/itkwasm/test/test_cast_image.py b/packages/core/python/itkwasm/test/test_cast_image.py
new file mode 100644
index 000000000..0cbea80e7
--- /dev/null
+++ b/packages/core/python/itkwasm/test/test_cast_image.py
@@ -0,0 +1,29 @@
+from pathlib import Path
+
+import itk
+
+from itkwasm import Image
+from itkwasm import FloatTypes, PixelTypes
+import numpy as np
+
+from itkwasm import cast_image
+
+def test_cast_image_component():
+    data = Path(__file__).absolute().parent / "input" / "cthead1.png"
+    itk_image = itk.imread(data, itk.UC)
+    itk_image_dict = itk.dict_from_image(itk_image)
+    itkwasm_image = Image(**itk_image_dict)
+
+    itkwasm_image_double = cast_image(itkwasm_image, component_type=FloatTypes.Float64)
+    assert itkwasm_image_double.imageType.componentType == FloatTypes.Float64
+    assert np.array_equal(itkwasm_image.data, itkwasm_image_double.data)
+
+def test_cast_image_pixel_type():
+    data = Path(__file__).absolute().parent / "input" / "cthead1.png"
+    itk_image = itk.imread(data, itk.UC)
+    itk_image_dict = itk.dict_from_image(itk_image)
+    itkwasm_image = Image(**itk_image_dict)
+
+    itkwasm_image_vector = cast_image(itkwasm_image, pixel_type=PixelTypes.VariableLengthVector)
+    assert itkwasm_image_vector.imageType.pixelType == PixelTypes.VariableLengthVector
+    assert np.array_equal(itkwasm_image.data, itkwasm_image_vector.data)
diff --git a/packages/dicom/typescript/test/browser/demo-app/read-image-dicom-file-series-load-sample-inputs.ts b/packages/dicom/typescript/test/browser/demo-app/read-image-dicom-file-series-load-sample-inputs.ts
index 9f6256cf4..6080a4fae 100644
--- a/packages/dicom/typescript/test/browser/demo-app/read-image-dicom-file-series-load-sample-inputs.ts
+++ b/packages/dicom/typescript/test/browser/demo-app/read-image-dicom-file-series-load-sample-inputs.ts
@@ -19,4 +19,4 @@ export default async function readImageDicomFileSeriesLoadSampleInputs (model) {
   dicomButton.loading = false
 
   return model
-}
\ No newline at end of file
+}