Extending EZStitcher

This guide explains how to extend EZStitcher with new functionality.

Extending AutoPipelineFactory

Note

Extending AutoPipelineFactory is primarily for contributors to the core library or for organization-wide standardization. For most advanced use cases, creating custom pipelines with the Pipeline/Step abstraction is recommended instead.

The AutoPipelineFactory class can be extended to create custom factory classes that build on the standard pipeline creation logic. This is useful when:

  1. You want to create a standardized pipeline structure for your organization

  2. You need to add additional pipelines to the standard position generation and assembly pipelines

  3. You’re contributing new functionality to the core EZStitcher library

Here’s an example of extending AutoPipelineFactory to add a quality control pipeline:

from ezstitcher.factories import AutoPipelineFactory
from ezstitcher.core.steps import Step
from ezstitcher.core.pipeline import Pipeline

class QCFactory(AutoPipelineFactory):
    """Adds an analysis pipeline after stitching."""

    def create_pipelines(self):
        # Get the standard pipelines from the parent class
        pipelines = super().create_pipelines()

        # Get the output directory from the assembly pipeline
        stitched_dir = pipelines[1].output_dir

        # Create a new analysis pipeline
        analysis = Pipeline(
            input_dir=stitched_dir,
            steps=[Step(func=self.simple_qc)],
            name="QC",
        )

        # Add the analysis pipeline to the list
        pipelines.append(analysis)
        return pipelines

    @staticmethod
    def simple_qc(images):
        from skimage.exposure import histogram
        return [histogram(im)[0] for im in images]

# Usage
factory = QCFactory(
    input_dir=orchestrator.workspace_path,
    normalize=True
)
pipelines = factory.create_pipelines()
orchestrator.run(pipelines=pipelines)

When to Extend vs. Create Custom Pipelines

Use custom pipelines (recommended for most users) when:

  • You need a one-off solution for a specific dataset

  • You want maximum flexibility and control

  • You want transparent, explicit code

  • You’re prototyping or experimenting

Extend AutoPipelineFactory (for contributors) when:

  • You’re adding a new feature to the EZStitcher library

  • You need to standardize pipeline creation across an organization

  • You’re creating a reusable component that builds on the standard pipelines

  • You need to maintain backward compatibility with existing code

In most cases, creating custom pipelines with the Pipeline/Step abstraction provides more flexibility and transparency than extending AutoPipelineFactory.

Adding a New Microscope Type

EZStitcher is designed to be easily extended with support for new microscope types. For detailed information about the microscope formats currently supported by EZStitcher, see Microscope Formats. For a comparison of different microscope formats, see Comparison of Microscope Formats.

For comprehensive information about the microscope interfaces, including:

  • MicroscopeHandler class

  • FilenameParser interface

  • MetadataHandler interface

  • Available methods and attributes

  • Function signatures and parameters

See the Microscope Interfaces documentation.

There are two approaches to adding a new microscope type:

  1. Using the BaseMicroscopeHandler class (recommended for most cases)

  2. Implementing the FilenameParser and MetadataHandler interfaces separately (for more complex cases)

Both approaches are described below.

Approach 1: Using BaseMicroscopeHandler

The simplest way to add a new microscope type is to subclass the BaseMicroscopeHandler class:

from ezstitcher.core.microscope_interfaces import BaseMicroscopeHandler
import re
from pathlib import Path

class CustomMicroscopeHandler(BaseMicroscopeHandler):
    """Handler for a custom microscope format."""

    # Regular expression for parsing file names
    # Example: Sample_A01_s3_w2_z1.tif
    FILE_PATTERN = re.compile(
        r'(?P<prefix>.+)_'
        r'(?P<well>[A-Z][0-9]{2})_'
        r's(?P<site>[0-9]+)_'
        r'w(?P<channel>[0-9]+)_'
        r'z(?P<z_index>[0-9]+)'
        r'\.tif$'
    )

    def __init__(self, plate_path):
        """Initialize the handler."""
        super().__init__(plate_path)

    def get_wells(self):
        """Get list of wells in the plate."""
        wells = set()
        for file_path in Path(self.plate_path).glob('**/*.tif'):
            match = self.FILE_PATTERN.match(file_path.name)
            if match:
                wells.add(match.group('well'))
        return sorted(list(wells))

    def get_sites(self, well):
        """Get list of sites for a well."""
        sites = set()
        for file_path in Path(self.plate_path).glob(f'**/*_{well}_*.tif'):
            match = self.FILE_PATTERN.match(file_path.name)
            if match:
                sites.add(match.group('site'))
        return sorted(list(sites))

    def get_channels(self, well, site=None):
        """Get list of channels for a well/site."""
        channels = set()
        pattern = f'**/*_{well}_s{site}_*.tif' if site else f'**/*_{well}_*.tif'
        for file_path in Path(self.plate_path).glob(pattern):
            match = self.FILE_PATTERN.match(file_path.name)
            if match:
                channels.add(match.group('channel'))
        return sorted(list(channels))

    def get_z_indices(self, well, site=None, channel=None):
        """Get list of z-indices for a well/site/channel."""
        z_indices = set()
        pattern = f'**/*_{well}_s{site}_w{channel}_*.tif'
        for file_path in Path(self.plate_path).glob(pattern):
            match = self.FILE_PATTERN.match(file_path.name)
            if match:
                z_indices.add(match.group('z_index'))
        return sorted(list(z_indices))

    def get_image_path(self, well, site, channel, z_index=None):
        """Get path to a specific image."""
        z_part = f'_z{z_index}' if z_index else ''
        pattern = f'**/*_{well}_s{site}_w{channel}{z_part}.tif'
        for file_path in Path(self.plate_path).glob(pattern):
            if self.FILE_PATTERN.match(file_path.name):
                return str(file_path)
        return None

    def parse_file_name(self, file_path):
        """Parse components from a file name."""
        match = self.FILE_PATTERN.match(Path(file_path).name)
        if match:
            return {
                'well': match.group('well'),
                'site': match.group('site'),
                'channel': match.group('channel'),
                'z_index': match.group('z_index')
            }
        return None

    @classmethod
    def can_handle(cls, plate_path):
        """Check if this handler can handle the given plate."""
        # Check if any files match the pattern
        for file_path in Path(plate_path).glob('**/*.tif'):
            if cls.FILE_PATTERN.match(file_path.name):
                return True
        return False

To register your custom handler with EZStitcher:

from ezstitcher.core.microscope_interfaces import register_microscope_handler

# Register the custom handler
register_microscope_handler(CustomMicroscopeHandler)

# Now EZStitcher will automatically detect and use your handler
orchestrator = PipelineOrchestrator(
    config=config,
    plate_path="/path/to/custom/plate"
)

You can also explicitly specify which handler to use:

# Create orchestrator with specific handler
orchestrator = PipelineOrchestrator(
    config=config,
    plate_path="/path/to/plate",
    microscope_handler=CustomMicroscopeHandler
)

Approach 2: Implementing FilenameParser and MetadataHandler

For more complex cases, you can implement the FilenameParser and MetadataHandler interfaces separately:

"""
NewMicroscope implementations for ezstitcher.

This module provides concrete implementations of FilenameParser and MetadataHandler
for NewMicroscope microscopes.
"""

import re
import logging
from pathlib import Path
from typing import Dict, List, Optional, Union, Any, Tuple

from ezstitcher.core.microscope_interfaces import FilenameParser, MetadataHandler

logger = logging.getLogger(__name__)


class NewMicroscopeFilenameParser(FilenameParser):
    """Filename parser for NewMicroscope microscopes."""

    # Define the regex pattern as a class attribute
    FILENAME_PATTERN = r'([A-Z]\d{2})_s(\d+)_w(\d+)(?:_z(\d+))?\.(?:tif|tiff)'

    @classmethod
    def can_parse(cls, filename: str) -> bool:
        """Check if this parser can parse the given filename."""
        # Use the class attribute pattern
        return bool(re.match(cls.FILENAME_PATTERN, filename))

    def parse_filename(self, filename: str) -> Optional[Dict[str, Any]]:
        """Parse a NewMicroscope filename into its components."""
        match = re.match(self.FILENAME_PATTERN, filename)

        if not match:
            return None

        well, site, channel, z_index = match.groups()

        return {
            'well': well,
            'site': int(site),
            'channel': int(channel),
            'z_index': int(z_index) if z_index else None,
            'extension': Path(filename).suffix
        }

    def construct_filename(self, well: str, site: Optional[Union[int, str]] = None,
                          channel: Optional[int] = None,
                          z_index: Optional[Union[int, str]] = None,
                          extension: str = '.tif',
                          site_padding: int = 3, z_padding: int = 3) -> str:
        """Construct a NewMicroscope filename from components."""
        # Format site number with padding
        if site is None:
            site_str = ""
        elif isinstance(site, str) and site == self.PLACEHOLDER_PATTERN:
            site_str = f"_s{site}"
        else:
            site_str = f"_s{int(site):0{site_padding}d}"

        # Format channel number
        if channel is None:
            channel_str = ""
        else:
            channel_str = f"_w{int(channel)}"

        # Format z-index with padding
        if z_index is None:
            z_str = ""
        elif isinstance(z_index, str) and z_index == self.PLACEHOLDER_PATTERN:
            z_str = f"_z{z_index}"
        else:
            z_str = f"_z{int(z_index):0{z_padding}d}"

        # Ensure extension starts with a dot
        if not extension.startswith('.'):
            extension = f".{extension}"

        return f"{well}{site_str}{channel_str}{z_str}{extension}"


class NewMicroscopeMetadataHandler(MetadataHandler):
    """Metadata handler for NewMicroscope microscopes."""

    def find_metadata_file(self, plate_path: Union[str, Path]) -> Optional[Path]:
        """Find the metadata file for a NewMicroscope plate."""
        plate_path = Path(plate_path)

        # Look for metadata file
        metadata_file = plate_path / "metadata.xml"
        if metadata_file.exists():
            return metadata_file

        return None

    def get_grid_dimensions(self, plate_path: Union[str, Path]) -> Tuple[int, int]:
        """Get grid dimensions for stitching from NewMicroscope metadata."""
        metadata_file = self.find_metadata_file(plate_path)
        if not metadata_file:
            # Default grid size if metadata file not found
            return (3, 3)

        # Parse metadata file to extract grid dimensions
        # This is just an example, implement your own parsing logic
        try:
            # Parse XML or other format
            # ...

            # Return grid dimensions
            return (4, 4)
        except Exception as e:
            logger.error(f"Error parsing metadata file: {e}")
            return (3, 3)

    def get_pixel_size(self, plate_path: Union[str, Path]) -> Optional[float]:
        """Get the pixel size from NewMicroscope metadata."""
        metadata_file = self.find_metadata_file(plate_path)
        if not metadata_file:
            return None

        # Parse metadata file to extract pixel size
        # This is just an example, implement your own parsing logic
        try:
            # Parse XML or other format
            # ...

            # Return pixel size in micrometers
            return 0.65
        except Exception as e:
            logger.error(f"Error parsing metadata file: {e}")
            return None

Then, register the new microscope type in ezstitcher/microscopes/__init__.py:

"""
Microscope-specific implementations for ezstitcher.

This package contains modules for different microscope types, each providing
concrete implementations of FilenameParser and MetadataHandler interfaces.
"""

# Import microscope handlers for easier access
from ezstitcher.microscopes.imagexpress import ImageXpressFilenameParser, ImageXpressMetadataHandler
from ezstitcher.microscopes.opera_phenix import OperaPhenixFilenameParser, OperaPhenixMetadataHandler
from ezstitcher.microscopes.new_microscope import NewMicroscopeFilenameParser, NewMicroscopeMetadataHandler