Registration and panorama stitching

PanoramaStitcher is the shortest route from two or more overlapping planar images to one typed, partial ERP panorama. It is an Experimental application rather than a flagship API: use it when its central rotation-only capture model matches the data, and inspect its diagnostics before accepting the output.

1. Load real images and run the Application

Pillow owns planar image file decoding. PanorAi owns calibration, registration, spherical warping and the output EquirectangularImage:

from pathlib import Path

import numpy as np
from PIL import Image

from panorai.applications import PanoramaStitcher
from panorai.stitching import StitchingOptions

paths = [Path("left.jpg"), Path("centre.jpg"), Path("right.jpg")]
images = tuple(np.asarray(Image.open(path).convert("RGB")) for path in paths)

stitcher = PanoramaStitcher(
    StitchingOptions(
        feature_method="sift",
        output_shape_hw=(1024, 2048),
        exposure_compensation="gain",
        blend="feather",
    )
)
result = stitcher(images, image_ids=tuple(path.stem for path in paths))
result.panorama.save("partial-panorama.png")

if result.diagnostics.coverage < 0.5:
    print("The capture covers less than half of the sphere")
print(result.diagnostics.rejected_pairs)

When every horizontal field of view is known, pass one value per image:

result = stitcher(
    images,
    image_ids=("left", "centre", "right"),
    horizontal_fov_deg=(78.0, 78.0, 78.0),
)

Omit horizontal_fov_deg only when the shared-FOV assumption is justified. The estimator may reject weak or boundary solutions as unobservable. max_median_angular_error_deg additionally bounds the accepted central-rotation residual, so a low-error planar homography cannot by itself justify a panorama camera model.

2. Preserve explicit support and validity

Masks are independent from RGB values and use (H, W) boolean arrays:

result = stitcher(
    images,
    support_masks=(support_left, support_centre, support_right),
    validity_masks=(valid_left, valid_centre, valid_right),
)

validity must never extend outside support. Zero-valued RGB is not a missing-data marker. The returned ERP exposes its support and validity while result.support_count and result.contribution_map explain which inputs contributed to each output sample.

3. Inspect and reconfigure the Pipeline

The wrapper exposes the exact lower-level composition:

pipeline = stitcher.as_pipeline()
print(tuple(pipeline.named_steps))
# DetectStitchingFeatures, DiscoverOverlaps, CalibrateAndRegister,
# WarpRegisteredImages, CompensateExposure, BlendStitchingPanorama

Each public Stage implements execute(value) -> StageResult and may be used alone or replaced in another Pipeline:

from panorai.pipeline import Pipeline
from panorai.stitching import (
    BlendStitchingPanorama,
    CalibrateAndRegister,
    CompensateExposure,
    DetectStitchingFeatures,
    DiscoverOverlaps,
    StitchingInput,
    WarpRegisteredImages,
)

options = stitcher.options
custom = Pipeline(
    DetectStitchingFeatures(options),
    DiscoverOverlaps(options),
    CalibrateAndRegister(options),
    WarpRegisteredImages(options),
    CompensateExposure(options),
    BlendStitchingPanorama(options),
)
typed_input = StitchingInput.from_arrays(images)
result = custom(typed_input)

Pipeline serialization uses the explicit PanorAi registry; it never imports a class named by an input JSON document.

4. Use calibration independently

Calibration consumes already matched planar pixel centres and returns intrinsics, one target-from-source rotation, inliers and observability diagnostics:

from panorai.calibration import (
    RotationCalibrationOptions,
    calibrate_rotation_pair,
)

calibration = calibrate_rotation_pair(
    pixels_a_xy,
    pixels_b_xy,
    image_a.shape[:2],
    image_b.shape[:2],
    horizontal_fov_a_deg=None,
    horizontal_fov_b_deg=None,
    options=RotationCalibrationOptions(min_inliers=30),
)
if not calibration.observable:
    raise RuntimeError(calibration.degeneracy_reasons)

This function does not detect or match features. Keeping those decisions out of calibration makes the geometry reusable by other Applications.

5. Use robust model fitting independently

panorai.model_fitting owns deterministic sampling, RANSAC/MSAC scoring and local all-inlier refitting:

import math

from panorai.model_fitting import RobustFitOptions, fit_rotation

fit = fit_rotation(
    source_bearings,
    target_bearings,
    options=RobustFitOptions(
        residual_threshold=math.radians(1.0),
        method="msac",
        min_inliers=30,
        random_seed=0,
    ),
)
rotation_target_from_source = fit.model

Custom model families implement fit and residuals through the public ModelFamily protocol. Residual units must match residual_threshold.

Failure boundary

Do not accept the result only because an image was produced. The current profile is not suitable for translated cameras, strong parallax, rolling shutter, fisheye images without a prior rectification, unrelated image groups or unobservable focal length. It provides average/feather fusion, not seam optimization or multi-band blending. See the registration concept and the public roadmap for the promotion gates.