Skip to content

muvis-align is an image registration package for aligning 2D and 3D images. The package is implemented as a napari plugin, notebooks using the package classes, and a command line interface (CLI) for batch processing. The napari plugin can also be run as a container on a local machine or on a cloud platform via an interactive web interface.

muvis-align practices demonstrated in the source code

Repository: https://github.com/ccp-volume-em/muvis-align

muvis-align demonstrates practical patterns for scalable, reproducible image registration workflows across a napari plugin, notebooks, CLI execution, batch/HPC use, and containerized interactive access.


Next Generation File Formats (NGFF)

muvis-align uses OME-Zarr / NGFF as a primary output format.

OME-Zarr / NGFF writing is delegated to to_multiscales and to_ngff_zarr from ngff-zarr, including multiscale pyramid generation.

Source: src/muvis_align/image/ome_zarr_helper.py

from ngff_zarr import to_ngff_image, to_multiscales, to_ngff_zarr


def save_ome_image(data, path, dim_order, pixel_size, channels, translation, rotation,
                   pyramid_downsample=None, compression=None, ome_version=default_ome_zarr_version):

    storage_options = {}
    compressor, compression_filters = create_compression_filter(compression)
    if compressor is not None:
        storage_options['compressor'] = compressor
    if compression_filters is not None:
        storage_options['filters'] = compression_filters

    axes_units = {dim: 'micrometer' for dim in dim_order if dim in 'xyz'}
    image = to_ngff_image(data, dims=dim_order, scale=pixel_size, translation=translation, axes_units=axes_units)
    multiscales = to_multiscales(image, scale_factors=pyramid_downsample, chunks=create_chunk_dict(default_chunk_size, dim_order))
    ...
    to_ngff_zarr(path, multiscales, chunks_per_shard=chunks_per_shard, version=ome_version, **storage_options)

    return multiscales.metadata

Handling larger-than-memory data

The code uses Dask-backed image sources for lazy loading and chunked processing.

Source: src/muvis_align/image/source_helper.py

def create_image_source(filename, source_metadata=None, file_label=None, index=None, transform_key=None):
    kwargs = {'file_label': file_label, 'index': index}
    if transform_key is not None:
        kwargs['transform_key'] = transform_key
    ext = os.path.splitext(filename)[1].lstrip('.').lower()
    if ext.startswith('tif'):
        source = TiffImageSource(filename, source_metadata, **kwargs)
    elif '.zar' in filename.lower():
        source = ZarrImageSource(filename, source_metadata, **kwargs)
    else:
        if ext:
            raise ValueError(f'Unsupported file type: {ext}')
        else:
            raise ValueError(f'Unsupported: {filename}')
    return source

TIFF files are read lazily through tifffile's Zarr store (aszarr), each pyramid level wrapped as a Dask array, avoiding immediate full-volume loading.

Source: src/muvis_align/image/ome_tiff_helper.py

with tifffile.TiffFile(filename) as tif:
    series = tif.series[0]
    axes = series.axes
    # multiscales=True: a group even for one level, levels ordered finest first
    group = zarr.open_group(store=tif.aszarr(series=0, multiscales=True), mode='r')
    attributes = group.attrs.get('ome', group.attrs)
    datasets = attributes['multiscales'][0]['datasets']
    arrays = []
    for dataset in datasets:
        array = group[dataset['path']]
        ...
for level, array in enumerate(arrays):
    # meta given, so dask does not probe the dtype with a read
    data = da.from_array(array, chunks=array.chunks, meta=np.empty((0,) * array.ndim, dtype=array.dtype),
                         name='tiff-' + tokenize(filename, stat.st_size, stat.st_mtime_ns, level))
    datas.append(data)

OME-Zarr metadata is read from the store's consolidated metadata (falling back to ngff-zarr's parser), and each multiscale level is opened directly as a Dask array.

Source: src/muvis_align/image/ZarrImageSource.py

def init_metadata(self):
    # metadata only, off consolidated metadata: the arrays wait for first access to self.data
    metadata = read_ome_zarr_source_metadata(self.filename)

    self.dimension_order = metadata['dimension_order']
    self.shapes = metadata['shapes']
    self.shape = self.shapes[0]
    self.dtype = metadata['dtype']
    ...

def _load_data(self):
    ...
    datas = []
    for path, sim_shape in zip(self._level_paths, self.shapes):
        data = da.from_zarr(self.filename, component=path)
        if tuple(data.shape) != tuple(sim_shape):
            data = data.reshape(sim_shape)
        datas.append(data)
    self._data = datas

Fusion writes directly to OME-Zarr, level by level and block by block, rather than creating a full fused array in memory first.

Source: src/muvis_align/MVSRegistration.py, src/muvis_align/fusion_blocks.py

with dask.config.set(scheduler='threads'), warnings.catch_warnings():
    if saving_zarr:
        levels = export_levels(source_spacings, output_stack_properties, scaled_dims, native=is_native)
        fused_image = fuse_levels_to_ome_zarr(
            msims, levels, output_filename, transform_key, output_stack_properties, output_chunksize,
            fusion_func=fuse_func, zarr_options=zarr_options,
            batch_options=self._fusion_batch_options(saving_zarr, max_workers=workers),
            ranks=source_spacings if is_native else None, average_levels=not is_native,
            level_weights=level_bytes)
    else:
        fused_image = fusion.fuse(msims, fusion_func=fuse_func, transform_key=transform_key,
                                  output_stack_properties=output_stack_properties,
                                  output_chunksize=output_chunksize)

FAIR implementation

Rather than only documenting FAIR principles, muvis-align implements concrete FAIR-supporting mechanisms:

  • project template defined as workflow schema in Bilayers format
  • structured parameters in YAML
  • OME-Zarr / OME-TIFF image outputs
  • RO-Crate generation
  • persistent mappings and metrics files
  • napari and CLI interfaces
  • container metadata for reproducible execution

Declarative UI definition based on workflow schema

Source: src/muvis_align/ui/project_template.yaml

The project_template.yaml file is written in the Bilayers format and serves as the declarative description of the muvis-align workflow. The file is used to dynamically generate the magicgui-based napari user interface, allowing workflow parameters, input definitions, and processing options to be defined in a structured configuration file rather than being hard-coded in the application. This approach simplifies interface maintenance, improves reproducibility, and enables the same workflow definition to be reused across the napari plugin, command-line execution, and containerized deployments. Bilayers schemas are represented as a Pydantic model, which adds typed validation and structured serialization for this workflow definition layer.

Source: src/muvis_align/ui/bilayers_util.py

from pydantic import BaseModel, ConfigDict, Field, field_validator


class BilayersField(BaseModel):
    model_config = ConfigDict(extra='allow')

    name: str
    type: str
    label: str | None = None
    default: Any = None
    description: str | None = None
    section_id: str | None = None
    section_key: str | None = None
    cli_order: int = 0
    options: list[BilayersOption] | None = None
    file_count: str | None = None
    output_dir_set: bool = False

    @field_validator('type', mode='before')
    @classmethod
    def _normalize_type(cls, value):
        return str(value).lower()

    @field_validator('cli_order', mode='before')
    @classmethod
    def _normalize_cli_order(cls, value):
        if value is None or value == '':
            return 0
        return int(value)


def get_section_dict(template, keys=None):
    sections = {}
    if keys is None:
        keys = [None]

    for key in keys:
        items = template.get(key, []) if key is not None else template
        for raw_item in items:
            item = BilayersField.model_validate({**raw_item, 'section_key': key})
            section_id = item.section_id or key
            sections.setdefault(section_id, []).append(item)

    return {
        section_id: [item.model_dump() for item in sorted(section_items, key=lambda item: item.cli_order)]
        for section_id, section_items in sections.items()
    }

The package creates a Workflow Run RO-Crate for each run, recording the project parameters, source images and results.

Source: src/muvis_align/file/rocrate_utils.py

def create_workflow_run_crate(dest_path, params_path, params, source_paths, result_paths,
                              start_time=None, end_time=None):
    crate = ROCrate()
    root = crate.root_dataset
    root['name'] = f'muvis-align run: {get_filetitle(os.path.normpath(dest_path))}'
    for profile_id, profile_name in WORKFLOW_RUN_PROFILES.items():
        profile = crate.add(ContextEntity(crate, profile_id, {'@type': ['CreativeWork', 'Profile'],
                                                              'name': profile_name, 'version': '0.5'}))
        root.append_to('conformsTo', profile)

    workflow_entity = add_workflow(crate)
    parameters = add_formal_parameters(crate, workflow_entity)
    ...
    action_entity = crate.add_action(workflow_entity, identifier='#run-001', object=objects, result=results,
                                     properties=action_properties)
    root.append_to('mentions', action_entity)

    crate.write(dest_path)
    return crate

OME-Zarr outputs receive a Zarr-specific RO-Crate, recording the sources, acquisition, registration metrics and the fusion step.

Source: src/muvis_align/file/rocrate_utils.py

def create_zarr_ro_crate(dest_path, sources=None, source_paths=(), metrics=None):
    crate = ZarrCrate()
    # the crate is written as the fusion ends
    fusion_time = to_iso_datetime(datetime.now())
    properties = {'name': get_filetitle(dest_path),
                  'description': f'OME-Zarr image fused by muvis-align from {len(source_paths)} sources',
                  'datePublished': fusion_time,
                  'encodingFormat': ZARR_ENCODING_FORMAT}
    dataset_entity = crate.add_dataset(dest_path='.', properties=properties)
    source_entities = [crate.add(ContextEntity(crate, to_crate_path(source_path, dest_path), {
        '@type': 'Dataset' if os.path.isdir(source_path) else 'File'})) for source_path in source_paths]
    if source_entities:
        dataset_entity['isBasedOn'] = source_entities
    if sources:
        add_data_capture(crate, dataset_entity, sources, source_entities)
    ...
    fusion_properties = {'@type': 'CreateAction', 'name': 'Fusion to OME-Zarr', 'endTime': fusion_time,
                         'instrument': add_software(crate), 'result': dataset_entity}
    if source_entities:
        fusion_properties['object'] = source_entities
    fusion_entity = crate.add(ContextEntity(crate, '#fusion-001', fusion_properties))
    dataset_entity.append_to('mentions', fusion_entity)
    crate.write(dest_path)
    return crate

Persistent metadata

Input metadata is read from OME-TIFF or OME-Zarr and converted into physical units, positions, rotations, channels, scales, and transforms.

Interoperable NGFF RFC 6 transforms

Registration transforms are written in the OME-NGFF RFC 6 coordinate-transform format rather than as application-specific matrix arrays. Each affine identifies its image path and names its input (source_metadata) and output (registered) coordinate systems. Using the ome-zarr-models v0.6 model for both serialization and validation makes the saved transforms portable to other tools that implement the NGFF coordinate-system and transformation model.

Source: src/muvis_align/file/transforms.py

def metadata_models(path, transform, source_key='source_metadata', transform_key='registered'):
    affine = Affine(
        path=path,
        affine=transform,
        input=CoordinateSystemIdentifier(name=source_key),
        output=CoordinateSystemIdentifier(name=transform_key),
    )
    return affine.model_dump(exclude_none=True, exclude_defaults=True, exclude_unset=True)


def read_transforms(filename):
    transforms = import_json(filename)
    return {label: Affine.model_validate(transform).affine for label, transform in transforms.items()}

Source: src/muvis_align/image/ome_zarr_util.py

multiscale = multiscales[0]
file_dims = tuple(axis['name'] for axis in multiscale.get('axes', []))

file_shapes, scales, dtypes = [], [], set()
translation = {}
for index, dataset in enumerate(multiscale.get('datasets', [])):
    node = nodes.get(dataset.get('path'))
    transforms = dataset.get('coordinateTransformations', [])
    scale_values = next((transform['scale'] for transform in transforms
                         if transform.get('type') == 'scale'), None)
    shape = node.get('shape')
    file_shapes.append(tuple(shape))
    scales.append(dict(zip(file_dims, scale_values)))
    if index == 0:
        translation_values = next((transform['translation'] for transform in transforms
                                   if transform.get('type') == 'translation'), None)
        if translation_values is not None:
            translation = dict(zip(file_dims, translation_values))

The workflow builds an MSIM object for each source and performs pairwise/global registration on these multiscale spatial images.

Source: src/muvis_align/MVSRegistration.py

def build_msim(index):
    return build_source_msim(self.sources[index], self._msim_output_order, self.positions[index],
                             self._msim_transforms[index], self.source_transform_key,
                             z_scale=self._msim_z_scale, from_level=from_levels[index] if from_levels else 0,
                             ends_only=ends_only)

msims = pooled_map(build_msim, len(self.sources), default_msim_build_workers, progress_factory,
                   desc='Building sources', weight=weight,
                   timing_label='Build msims' if self.logging_time else None)
...
results = self.register_global(self.pair_msims, register_indices=register_indices, params=params)

The parsed OME metadata (axes, units, voxel scale, translations/positions, channels, and coordinate transforms) is preserved in this xarray-based MSIM (multiscale-spatial-image) representation, where image data remains in labeled dimensions and spatial context is stored as coordinates/attributes. This metadata is propagated through image operations, updated during pairwise/global registration, and carried into fusion/export (for example, OME-Zarr) to preserve physical-space interpretation and multiscale provenance.

The CLI runs the pipeline from a YAML parameter file.

Source: run.py

parser = argparse.ArgumentParser(description=f'muvis-align')
parser.add_argument('params',
                    help='The parameters file',
                    default='resources/params.yml')

args = parser.parse_args()
print(f'Parameters file: {args.params}')
with open(args.params, 'r', encoding='utf8') as file:
    params = yaml.safe_load(file)

pipeline = Pipeline(params, params_path=args.params)
pipeline.run()

Performance metrics

The project calculates registration metrics such as NCC, SSIM, normalized mutual information, MSE-derived score, match rate, and distance-based feature metrics.

Registration quality metrics

muvis-align computes several complementary image similarity metrics to evaluate registration quality. Each metric captures a different aspect of alignment quality, making it useful to evaluate multiple metrics together rather than relying on a single score.

Metric Full name Measures Typical range
NCC Normalized Cross-Correlation Intensity correlation between images -1 to 1
SSIM Structural Similarity Index Measure Similarity of local image structures 0 to 1
ONMI Overlapping Normalized Mutual Information Shared information between overlapping image regions 0 to 1+

The metrics are implemented in metrics.py:

all_metric_funcs = {
    'ncc': multiview_stitcher.metrics.normalized_cross_correlation,
    'ssim': lambda im1, im2: structural_similarity(
        np.nan_to_num(im1),
        np.nan_to_num(im2),
        data_range=data_range),
    'onmi': lambda im1, im2:
        normalized_mutual_information(
            np.nan_to_num(im1),
            np.nan_to_num(im2)) - 1,
    'mse': lambda im1, im2:
        1 / mean_squared_error(im1, im2),
}

Source: src/muvis_align/metrics.py

NCC — Normalized Cross-Correlation

NCC measures the correlation of image intensities between two images. It is particularly useful when corresponding structures have similar brightness values.

  • NCC = 1 indicates perfect correlation.
  • NCC = 0 indicates no correlation.
  • Negative values indicate inverse correlation.

NCC is computationally efficient and widely used for rigid image registration, although it can be sensitive to intensity variations between acquisitions.

SSIM — Structural Similarity Index Measure

SSIM evaluates the similarity of local image structure by comparing:

  • intensity
  • contrast
  • local texture

Unlike pixel-wise metrics, SSIM is sensitive to structural preservation and is therefore useful for assessing whether biological features remain aligned after registration.

  • SSIM = 1 indicates identical structure.
  • Values close to zero indicate poor structural agreement.

SSIM is often more representative of perceived image quality than correlation-based metrics.

ONMI — Overlapping Normalized Mutual Information

ONMI measures the amount of shared information between overlapping image regions.

Mutual information is particularly valuable when images originate from different modalities, channels, or acquisition conditions because it does not assume similar intensity values.

Higher ONMI values indicate that:

  • corresponding image regions contain similar information,
  • overlapping structures are well aligned,
  • registration improves the statistical dependence between views.

Because ONMI is based on image information content rather than intensity similarity, it is often more robust for multimodal registration problems.

Using NCC, SSIM, and ONMI together provides complementary measures of registration quality:

  • NCC evaluates intensity agreement.
  • SSIM evaluates structural similarity.
  • ONMI evaluates shared image information.

This combination gives a more robust assessment of registration performance than any single metric alone.


Storing data for resumable workflows

muvis-align stores intermediate registration state in mapping and metrics files, allowing workflows to continue from pairwise registration, global registration, or fused output.

Source: src/muvis_align/constants.py

default_pair_mappings_name = 'pair_mappings.json'
default_mappings_name = 'mappings.json'
metrics_name = 'metrics.json'
default_mappings_tabular_name = 'mappings.csv'

The registration state machine tracks progress through the workflow.

Source: src/muvis_align/MVSRegistration.py

class RegState(Enum):
    UNINIT = auto()
    INIT = auto()
    SIMS_INIT = auto()
    PAIRS_REG = auto()
    GLOBAL_REG = auto()
    FUSED = auto()

Existing output files are detected before re-running expensive steps.

Source: src/muvis_align/MVSRegistration.py

def check_progress(self, output_filename, output_format):
    pair_mappings_filename = self.output + self.output_params.get('pair_mappings', default_pair_mappings_name)
    mappings_filename = self.output + self.output_params.get('mappings', default_mappings_name)
    if self.output_exists(output_filename, output_format):
        self.state = RegState.FUSED
    elif os.path.exists(mappings_filename):
        self.state = RegState.GLOBAL_REG
    elif os.path.exists(pair_mappings_filename):
        self.state = RegState.PAIRS_REG

Pair mappings, final mappings, and metrics are saved separately.

Source: src/muvis_align/MVSRegistration.py

def register(self, register_msims=None, register_indices=None, params=None):
    pair_results = self.register_pairs(register_msims=register_msims, register_indices=register_indices, params=params)
    self.save_pair_results(pair_results['pair_mappings'], metrics=pair_results['metrics'])
    results = self.register_global(self.pair_msims, register_indices=register_indices, params=params)
    self.save_group_pair_mappings()
    self.save_mappings(results['mappings'])
    self.save_metrics(results['metrics'])
    return results

Restoring registered transforms from intermediate output

Registered alignment transforms are persisted as JSON intermediate outputs (pair_mappings.json and mappings.json) and reloaded on later runs when these files already exist.

Source: src/muvis_align/MVSRegistration.py

def check_progress(self, output_filename, output_format):
    pair_mappings_filename = self.output + self.output_params.get('pair_mappings', default_pair_mappings_name)
    mappings_filename = self.output + self.output_params.get('mappings', default_mappings_name)
    if self.output_exists(output_filename, output_format):
        self.state = RegState.FUSED
    elif os.path.exists(mappings_filename):
        self.state = RegState.GLOBAL_REG
    elif os.path.exists(pair_mappings_filename):
        self.state = RegState.PAIRS_REG

When progress indicates pairwise/global registration already exists, init_progress loads saved mappings and reapplies transforms to the MSIMs instead of recomputing registration.

Source: src/muvis_align/MVSRegistration.py

if self.is_global_registered():
    mappings = read_transforms(mappings_filename)
    ...
    self.mappings = {}
    for index, filename in enumerate(self.filenames):
        self.mappings[index] = xaffine_from_matrix(find_file_dict_item(mappings, filename))
    if self._msims is not None:
        self._apply_mappings(self._msims)

JSON persistence/reload is handled by shared utilities:

Source: src/muvis_align/util.py

def import_json(filename):
    with open(filename, encoding='utf8') as file:
        data = json.load(file)
    return data


def export_json(filename, data):
    with open(filename, 'w', encoding='utf8') as file:
        json.dump(data, file, indent=4)

Interactive interface with Xpra container

The Dockerfile defines a standard napari container and a second Xpra-enabled image for browser-based interactive use.

Source: Dockerfile

FROM python:3.12-slim-bookworm AS muvis-align
...
COPY requirements.txt .

RUN python -m pip install --upgrade pip
RUN pip install -r requirements.txt
RUN pip install napari[all]
RUN pip install napari-ome-zarr

COPY run.py .
COPY pyproject.toml .
COPY src/ src/

RUN --mount=type=bind,source=.git,target=/app/.git \
    pip install .

ENTRYPOINT ["python3", "-m", "napari", "--plugin", "muvis-align"]

The Xpra stage installs Xpra and launches napari through a browser-accessible session.

Source: Dockerfile

FROM muvis-align AS muvis-align-xpra
...
RUN apt-get update && \
    apt-get install -yqq \
        xpra \
        xvfb \
        menu-xdg \
        xdg-utils \
        xterm \
        sshfs \
        x11-xkb-utils \
        xkb-data && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

ENV DISPLAY=:100
ENV XPRA_PORT=9876
ENV XPRA_START="python3 -c \"import napari; viewer = napari.Viewer(); viewer.window.add_plugin_dock_widget('muvis-align'); viewer.window._qt_window.showMaximized(); napari.run()\""
ENV XPRA_EXIT_WITH_CHILDREN="yes"
ENV XPRA_EXIT_WITH_CLIENT="no"
ENV XPRA_XVFB_SCREEN="8192x4096x24+32"
EXPOSE 9876
CMD echo "Launching napari on Xpra. Connect via http://localhost:$XPRA_PORT or $(hostname -i):$XPRA_PORT"; \
    PATH=/usr/bin:/usr/local/bin:$PATH xpra start \
    --bind-tcp=0.0.0.0:$XPRA_PORT \
    --html=on \
    ...
    --resize-display=yes \
    --start-child="$XPRA_START" \
    --exit-with-children="$XPRA_EXIT_WITH_CHILDREN" \
    --exit-with-client="$XPRA_EXIT_WITH_CLIENT" \
    --daemon=no \
    --xvfb="/usr/bin/Xvfb +extension Composite +extension RANDR -screen 0 $XPRA_XVFB_SCREEN -dpi 96 -nolisten tcp -noreset" \
    --pulseaudio=no \
    --notifications=no \
    --bell=no \
    $DISPLAY

Summary table

Practice Source-code implementation
NGFF OME-Zarr default extension, NGFF v0.5, to_ngff_zarr
Larger-than-memory data Dask-backed TIFF and Zarr sources, chunked fusion, direct Zarr output
FAIR implementation package metadata, YAML configuration, RO-Crate generation, OME-Zarr outputs
Persistent metadata OME metadata parsing, xarray/MSIM spatial transforms, positions, scales, channels; interoperable NGFF RFC 6 affine transforms
Performance metrics NCC, SSIM, ONMI, MSE-derived score, match metrics, timing logs
Resumable workflow state machine, pair mappings, global mappings, metrics JSON
Xpra interface Docker stage exposing browser-based napari over Xpra