Skip to content

Tiling Calibration

This example demonstrates how to calibrate the relationship between the camera sensor coordinate system and the microscope stage coordinate system. Calibration determines the physical pixel size (scale), axis mirroring, and the rotation angle between the camera sensor axes and the stage travel axes.

High-accuracy tiling acquisitions require stage coordinate shifts to map precisely to camera field-of-view (FOV) dimensions. The calibration procedure runs a multi-phase correlation algorithm that measures physical stage movements against image offsets:

  1. Phase 1: Pixel Size Approximation: Drives a simple 1D stage displacement to estimate the spatial scale (µm/pixel).
  2. Phase 2: Scale, Angle, and Mirroring Calibration: Acquires images in a four-cardinal-direction cross pattern to calculate rotation and detect axis mirroring.
  3. Phase 3: Validation Mosaic: Runs a 2x2 tiling acquisition using the calibrated coordinates, proving alignment by stitching the tiles together.

Prerequisites

This example builds on the device exploration pattern introduced in Explore Devices and the channel definition concepts from Define Channels. The script uses the device map to locate the stage controllers (X_AXIS, Y_AXIS, MOVE_AXIS) and active optical components.

Before running the calibration, configure the following hardware-specific parameters in the script to match your setup:

  • Camera Pixel Pitch (CAMERA_PIXEL_PITCH_UM): The physical pixel size of the camera sensor in micrometers (e.g., 6.5 µm). Set this to match the camera pixel pitch configured in the Inscoper configurator during hardware setup.
  • Total Magnification (OBJECTIVE_MAGNIFICATION): The total magnification factor of the optical path (e.g., 20.0). This represents the combined magnification of all magnifying devices in the optical path, including the objective lens, intermediate magnification changers, and auxiliary lenses.
  • Binning (BINNING): The sensor pixel binning factor configured for the camera (e.g., 1).

The calibration script uses these parameters to calculate the expected theoretical pixel size. If the measured experimental pixel size deviates from this expected theoretical value by more than a set ratio (e.g., 5×), the calibration aborts to prevent alignment errors caused by incorrect hardware entries.

Overview

The workflow executes the following steps in order:

  1. Resolve the stage sub-devices: Identify the stage axis controller handles from the device map.
  2. Start the camera: Prepare the camera to stream images using the SOFTWARE_TRIGGER mode.
  3. Establish the reference channel: Load the Transmitted Light (TL) channel configuration parameters.
  4. Establish the reference position: Prompt the user to manually adjust the microscope stage to a target region containing visible, non-uniform features for correlation.
  5. Phase 1 (Pixel Size):
    • Move the stage by a nominal offset in X (e.g., 10% of FOV).
    • Correlate the reference and shifted images to calculate the raw pixel shift and approximate pixel size.
  6. Phase 2 (Angle, Mirror, Scale):
    • Move the stage in a cross pattern: Left, Right, Top, and Bottom (e.g., 30% of FOV).
    • Compute horizontal (Left ↔ Right) and vertical (Top ↔ Bottom) image translations using multi-scale Pearson correlation.
    • Resolve the camera-to-stage rotation angle (\(\theta\)), identify axis mirroring, and determine the calibrated scale (µm/pixel).
  7. Phase 3 (Validation Mosaic):
    • Acquire a 2x2 grid of overlapping tiles (e.g., 10% overlap) using the computed calibration parameters.
    • Transform grid positions from camera-space coordinates to stage coordinates using the calibration matrix.
    • Stitch the overlapping areas to confirm seamless visual tiling.
  8. Restore Stage: Return the stage to the original starting coordinates.

Calibration Outputs and Visualizations

The calibration workflow generates four diagnostic plots to visually inspect the alignment at each phase.

Phase 1: Pixel Size Approximation

Estimate the pixel size by shifting the stage along the X-axis and calculating the resulting translation in the camera sensor coordinate system.

Phase 1: Pixel Size Approximation

Phase 2: Cardinal Shifts for Angle and Mirroring

Acquire four images displaced in a cross shape to calculate the precise rotation angle and detect sensor mirroring.

Phase 2: Rotation Angle, Mirroring, and Scale

Phase 3-1: 2x2 Validation Layout

Plot the physical positions and bounds of the 2x2 validation tiles in stage space (microns).

Phase 3-1: Validation Mosaic Layout

Phase 3-2: Reconstructed Panorama

Stitch the validation tiles together using the computed calibration coefficients to verify seamless boundary matching.

Phase 3-2: Stitched Validation Mosaic


Note

The examples on this page use a configuration named camera_and_microscope_and_fluo_source and a camera named MyCamera. Replace these values with the names defined in your own hardware configuration file.

Code Example

# ============================================================================
# CALIBRATION PARAMETERS
# ============================================================================

# Maximum acceptable ratio between experimental and theoretical pixel sizes.
# If the experimental pixel size is off by more than 5×, the system aborts 
# as this indicates configuration issues (e.g., wrong objective selected).
PIXEL_SIZE_MAX_RATIO = 5.0

# ----------------------------------------------------------------------------
# Theoretical Pixel Size Calculation:
# ----------------------------------------------------------------------------
# The camera pixel pitch should match the value set during system configuration.
CAMERA_PIXEL_PITCH_UM = 6.5

# Total magnification factor of the optical path (objective magnification,
# intermediate magnification changers, and any auxiliary lenses).
OBJECTIVE_MAGNIFICATION = 20.0

BINNING = 1
pixel_size_um_theoric = CAMERA_PIXEL_PITCH_UM * BINNING / OBJECTIVE_MAGNIFICATION
pixel_size_nm_theoric = pixel_size_um_theoric * 1000.0

# We use the minimum dimension of the camera sensor as our base unit for shifts 
# (e.g., shifts are expressed as 10% or 30% of the smaller camera dimension).
image_dim = min(camera_width, camera_height)


# ============================================================================
# IMAGE & CORRELATION UTILITIES
# ============================================================================



def compute_pearson_correlation(img1: np.ndarray, ox1: int, oy1: int,
                                img2: np.ndarray, ox2: int, oy2: int,
                                width: int, height: int) -> float:
    """Compute Pearson correlation coefficient between two image sub-regions."""
    if width <= 0 or height <= 0:
        return float('-inf')

    # Flatten the 2D regions of interest into 1D vectors
    patch1 = img1[oy1:oy1 + height, ox1:ox1 + width].ravel()
    patch2 = img2[oy2:oy2 + height, ox2:ox2 + width].ravel()

    if len(patch1) == 0:
        return float('-inf')

    # If either patch is completely uniform (all pixels have the same value),
    # its variance is 0, which makes the correlation mathematically undefined.
    # We return 0.0 to prevent warnings/errors and continue searching.
    if np.all(patch1 == patch1[0]) or np.all(patch2 == patch2[0]):
        return 0.0

    # Compute correlation coefficient matrix using NumPy and extract the cross-correlation element
    return float(np.corrcoef(patch1, patch2)[0, 1])


def _search_offset(img1: np.ndarray, img2: np.ndarray,
                   x_center: int, y_center: int,
                   x_radius: int, y_radius: int) -> Tuple[int, int, float]:
    """Brute-force search for the (x, y) offset that maximizes Pearson correlation within [center ± radius]."""
    h1, w1 = img1.shape
    h2, w2 = img2.shape
    best_x, best_y = 0, 0
    best_corr = float('-inf')

    # Loop over the rectangular search window [center ± radius]
    for y_off in range(y_center - y_radius, y_center + y_radius + 1):
        for x_off in range(x_center - x_radius, x_center + x_radius + 1):

            # Determine the width and height of the overlapping region
            corr_width = w1 - abs(x_off)
            corr_height = h1 - abs(y_off)

            if corr_width <= 0 or corr_height <= 0:
                continue

            # Determine where to crop each image.
            # If x_off is positive, img2 is shifted right relative to img1.
            # If negative, img2 is shifted left.
            ox1 = max(0, x_off)
            oy1 = max(0, y_off)
            ox2 = max(0, -x_off)
            oy2 = max(0, -y_off)

            # Clamp overlapping box to ensure it does not exceed the boundaries of either image
            corr_width = min(corr_width, w1 - ox1, w2 - ox2)
            corr_height = min(corr_height, h1 - oy1, h2 - oy2)

            if corr_width <= 0 or corr_height <= 0:
                continue

            # Compute correlation for this translation hypothesis
            corr = compute_pearson_correlation(img1, ox1, oy1,
                                               img2, ox2, oy2,
                                               corr_width, corr_height)

            # Keep track of the translation that yields the highest correlation coefficient
            if corr > best_corr:
                best_x = x_off
                best_y = y_off
                best_corr = corr

    return best_x, best_y, best_corr


def get_offset_by_correlation(img1: np.ndarray, img2: np.ndarray) -> Tuple[int, int, float]:
    """Multi-scale recursive correlation search (coarse-to-fine) to find the pixel translation offset between two images."""
    DIM_THRESHOLD = 256
    RATIO = 4

    h1, w1 = img1.shape

    if w1 * h1 > DIM_THRESHOLD * DIM_THRESHOLD:
        # Coarse stage: downsample images by 4× and find the coarse offset recursively.
        mini1 = block_reduce(img1, (RATIO, RATIO), np.mean)
        mini2 = block_reduce(img2, (RATIO, RATIO), np.mean)

        ox_mini, oy_mini, _ = get_offset_by_correlation(mini1, mini2)

        # Fine stage: scale the coarse offset back to full resolution,
        # then scan a small area of ±4 pixels to find the precise sub-pixel seam
        return _search_offset(img1, img2,
                              ox_mini * RATIO, oy_mini * RATIO,
                              RATIO, RATIO)
    else:
        # Small images: do a brute-force sweep up to 80% of the image size
        max_ratio = 0.8
        x_max = int(w1 * max_ratio)
        y_max = int(w1 * max_ratio)
        return _search_offset(img1, img2, 0, 0, x_max, y_max)


def acquire_images(positions: List[Tuple[float, float]]) -> List[np.ndarray]:
    """Acquire camera images at the given stage positions (nm) and return them as a list of 2D numpy arrays."""
    # Clear previous images from the listener buffer
    my_listener._received_images.clear()

    tiling_status = []
    for x_pos, y_pos in positions:
        tile_status = inscoper_api.Status()

        # Load Transmitted Light channel parameters (objective turret, shutters, power, etc.)
        for sub_device_id, value in tl_channel_description.items():
            if sub_device_id is not None:
                tile_status.addParam(sub_device_id, value)

        # Clamp target coordinates to nearest multiple of the physical step size constraints
        target_x = int(round(x_pos / x_step)) * int(x_step)
        target_y = int(round(y_pos / y_step)) * int(y_step)
        tile_status.addParam(x_axis_sub_device_id, target_x)
        tile_status.addParam(y_axis_sub_device_id, target_y)

        # Add camera trigger tags
        tile_status.addTag("MyCamera")
        tile_status.addTag("SOFTWARE_TRIGGER")

        tiling_status.append(tile_status)

    # Load the sequence of target statuses and run it asynchronously
    my_bridge.loadSequence(2, "Acquisition", tiling_status)
    my_bridge.runSequence(2, False)

    # Retrieve images collected by the listener
    received_images = my_listener.getReceivedImages()

    numpy_images = []
    if received_images:
        # Determine bit-depth and dimension parameters from the first frame
        first_image = received_images[0]
        W = first_image.getWidth()
        H = first_image.getHeight()
        pixel_size_bytes = first_image.getPixelSize()
        bit_depth = first_image.getBitDepth()
        bytes_per_value = bit_depth // 8
        values_per_pixel = pixel_size_bytes // bytes_per_value

        # Convert each image from raw byte arrays to 2D float64 numpy matrices
        for image in received_images:
            image_data = image.getImageData()
            img = np.reshape(image_data, (H, W, values_per_pixel))
            img_channel = img[:, :, 0].astype(np.float64)
            numpy_images.append(img_channel)

    return numpy_images


def plot_and_save_stage(numpy_images: List[np.ndarray], positions: List[Tuple[float, float]], stage_name: str, subtitle: str, pixel_size_um: float):
    """Plot acquired tiles in stage-space coordinates (µm) and save as a transparent PNG."""
    fig, ax = plt.subplots(figsize=(10, 10))
    ax.set_xlabel("Stage X (µm)")
    ax.set_ylabel("Stage Y (µm)")

    # Color palette for drawing distinct borders around each tile
    edge_colors = ['#FF3D00', '#00E676', '#2979FF', '#FFC400', '#E040FB', '#00E5FF', '#FF6D00', '#76FF03']
    all_lefts, all_rights, all_bottoms, all_tops = [], [], [], []

    # Map each tile image into physical stage-space coordinates
    for idx, img in enumerate(numpy_images):
        x_pos, y_pos = positions[idx]
        cx = x_pos / 1000.0  # Convert nanometers to micrometers
        cy = y_pos / 1000.0

        # Calculate half width/height in micrometers
        half_w = (img.shape[1] * pixel_size_um) / 2.0
        half_h = (img.shape[0] * pixel_size_um) / 2.0

        # Calculate bounding coordinates
        left = cx - half_w
        right = cx + half_w
        bottom = cy - half_h
        top = cy + half_h

        all_lefts.append(left)
        all_rights.append(right)
        all_bottoms.append(bottom)
        all_tops.append(top)

        # Plot the tile overlaying its physical location
        extent = [left, right, bottom, top]
        ax.imshow(img, cmap='gray', extent=extent, alpha=0.8, origin='upper')

        # Draw a solid border to visually distinguish overlapping sections
        color = edge_colors[idx % len(edge_colors)]
        rect = plt.Rectangle((left, bottom), 2 * half_w, 2 * half_h,
                             fill=False, edgecolor=color, linewidth=2.5, linestyle='-')
        ax.add_patch(rect)

    # Set coordinate limits using all tiles with a 5% margin to prevent cropping
    if all_lefts:
        min_x = min(all_lefts)
        max_x = max(all_rights)
        min_y = min(all_bottoms)
        max_y = max(all_tops)
        pad_x = (max_x - min_x) * 0.05 if max_x != min_x else 10.0
        pad_y = (max_y - min_y) * 0.05 if max_y != min_y else 10.0
        ax.set_xlim(min_x - pad_x, max_x + pad_x)
        ax.set_ylim(min_y - pad_y, max_y + pad_y)

    ax.set_aspect('equal', 'datalim')

    title_text = f"Quality Check — {stage_name} ({len(numpy_images)} tiles)"
    if subtitle:
        title_text += f"\n{subtitle}"
    ax.set_title(title_text, fontsize=10, pad=12)
    plt.grid(True, linestyle=':', alpha=0.5)
    plt.tight_layout()

    # Save the visualization plot as a transparent PNG in the script folder
    script_dir = os.path.dirname(os.path.abspath(__file__))
    filename = stage_name.lower().replace(":", "").replace(" ", "_").replace(",", "").replace("&", "") + ".png"
    save_path = os.path.join(script_dir, filename)
    fig.savefig(save_path, dpi=300, transparent=True, bbox_inches='tight')
    print(f"    [Saved Plot] {save_path}")
    plt.show()


def mosaic_convert(x: float, y: float, theta: float, mirror: bool) -> Tuple[float, float]:
    """Convert camera-space offsets to stage-space offsets by applying mirror swap and 2D rotation."""
    if mirror:
        x, y = y, x

    x_res = x * math.cos(theta) - y * math.sin(theta)
    y_res = x * math.sin(theta) + y * math.cos(theta)
    return x_res, y_res


# ──────────────────────────────────────────────────────────────
# Step 4: Wait for the user to adjust the X and Y position on the microscope
# ──────────────────────────────────────────────────────────────
input(
    "\n[Step 4] Position the stage over a FEATURE-RICH area\n"
    "         (e.g., tissue, cells, text on a slide).\n"
    "         Avoid blank or uniform areas — correlation needs texture.\n"
    "         Press Enter when ready... "
)

# ──────────────────────────────────────────────────────────────
# Step 5: Get current stage positions and define the tiling grid
# ──────────────────────────────────────────────────────────────
current_x = my_bridge.getLongValue(x_axis_sub_device_id)
current_y = my_bridge.getLongValue(y_axis_sub_device_id)
print(f"Current X position: {current_x} nm")
print(f"Current Y position: {current_y} nm")

# ==============================================================
# PHASE 1: Pixel Size Approximation (2 images)
# ==============================================================
print("\n" + "=" * 70)
print("  PHASE 1: Pixel size approximation")
print("=" * 70)

# Shift the stage by 10% of the image size in the X direction (in nanometers).
# This is a one-dimensional translation used to calculate the starting scale ratio.
shift_10pct_nm = 0.1 * image_dim * pixel_size_nm_theoric

positions_p1 = [
    (current_x, current_y),                        # Center frame
    (current_x + shift_10pct_nm, current_y),       # Offset frame (10% shift in X)
]

print(f"    Shift: {shift_10pct_nm:.0f} nm ({shift_10pct_nm / 1000:.1f} µm)")
print("    Acquiring 2 images...")

# Run sequence and collect images
images_p1 = acquire_images(positions_p1)
if len(images_p1) < 2:
    raise RuntimeError("Phase 1 failed: did not receive 2 images")

# Display the two tiles in physical coordinates for user verification
plot_and_save_stage(
    images_p1, positions_p1,
    stage_name="Phase 1: Pixel Size Approximation",
    subtitle="Checking 1D raw stage shift (10%) to estimate starting pixel size.",
    pixel_size_um=pixel_size_um_theoric
)

img_center = images_p1[0]
img_right10 = images_p1[1]

# Correlate center frame with shifted frame to calculate the pixel displacement
print("    Computing correlation (center ↔ right+10%)...")
t0 = time.time()
offset_x, offset_y, corr = get_offset_by_correlation(img_center, img_right10)
dt = time.time() - t0
offset_mag = math.sqrt(offset_x ** 2 + offset_y ** 2)

print(f"    Offset: ({offset_x}, {offset_y}) px  |  magnitude: {offset_mag:.1f} px")
print(f"    Correlation: {corr:.4f}  |  Time: {dt:.2f}s")

# Check for failure to translate: if magnitude is zero, correlation or movement failed
if offset_mag == 0:
    raise RuntimeError(
        "Phase 1 failed: zero offset. "
        "Ensure the sample has visible features and the stage actually moved."
    )

# Compute the first approximation of the experimental pixel size:
# Experimental pixel size = stage displacement (in nm) / pixel displacement (magnitude in pixels)
pixel_size_nm_exp10 = (image_dim * 0.1 * pixel_size_nm_theoric) / offset_mag
ratio_10 = pixel_size_nm_exp10 / pixel_size_nm_theoric
print(f"    Experimental pixel size: {pixel_size_nm_exp10:.2f} nm ({pixel_size_nm_exp10 / 1000:.4f} µm)")
print(f"    Ratio (exp / theoric): {ratio_10:.3f}")

# Abort if the pixel size ratio is highly abnormal (greater than 5× or less than 0.2×).
if ratio_10 > PIXEL_SIZE_MAX_RATIO or ratio_10 < 1.0 / PIXEL_SIZE_MAX_RATIO:
    raise RuntimeError(
        f"Phase 1 failed: pixel size ratio {ratio_10:.3f} exceeds threshold (±{PIXEL_SIZE_MAX_RATIO}×).\n"
        f"  Theoric:      {pixel_size_nm_theoric:.1f} nm\n"
        f"  Experimental: {pixel_size_nm_exp10:.1f} nm\n"
        f"  Check CAMERA_PIXEL_PITCH_UM ({CAMERA_PIXEL_PITCH_UM}) and OBJECTIVE_MAGNIFICATION ({OBJECTIVE_MAGNIFICATION})."
    )

# ==============================================================
# PHASE 2: Angle, Mirror and Scale (4 images)
# ==============================================================
print("\n" + "=" * 70)
print("  PHASE 2: Angle, mirror and scale computation")
print("=" * 70)

# Move the stage by 30% of the image size to each cardinal direction (Left, Right, Top, Bottom).
# This distance provides substantial overlap while introducing clear cross-axis shifts.
offset_ratio = 0.3
cardinal_offset_nm = image_dim * offset_ratio * pixel_size_nm_exp10

positions_p2 = [
    (current_x - cardinal_offset_nm, current_y),     # Left position
    (current_x + cardinal_offset_nm, current_y),     # Right position
    (current_x, current_y - cardinal_offset_nm),     # Top position
    (current_x, current_y + cardinal_offset_nm),     # Bottom position
]

print(f"    Cardinal offset: {cardinal_offset_nm:.0f} nm ({cardinal_offset_nm / 1000:.1f} µm)")
print("    Acquiring 4 images (left, right, top, bottom)...")

# Run sequence to collect the 4 cardinal tiles
images_p2 = acquire_images(positions_p2)
if len(images_p2) < 4:
    raise RuntimeError("Phase 2 failed: did not receive 4 images")

# Display overlapping cardinal tiles in stage-space for visual check
plot_and_save_stage(
    images_p2, positions_p2,
    stage_name="Phase 2: Angle, Mirror and Scale",
    subtitle="Checking cardinal stage shifts (30%) to compute rotation, mirror, and scale.",
    pixel_size_um=pixel_size_nm_exp10 / 1000.0
)

img_left = images_p2[0]
img_right = images_p2[1]
img_top = images_p2[2]
img_bottom = images_p2[3]

# Correlate Left frame with Right frame to evaluate horizontal translation parameters
print("    Computing horizontal correlation (left ↔ right)...")
t0 = time.time()
h_off_x, h_off_y, h_corr = get_offset_by_correlation(img_left, img_right)
dt = time.time() - t0
print(f"    H offset: ({h_off_x}, {h_off_y}) px  |  corr: {h_corr:.4f}  |  {dt:.2f}s")

# Correlate Top frame with Bottom frame to evaluate vertical translation parameters
print("    Computing vertical correlation (top ↔ bottom)...")
t0 = time.time()
v_off_x, v_off_y, v_corr = get_offset_by_correlation(img_top, img_bottom)
dt = time.time() - t0
print(f"    V offset: ({v_off_x}, {v_off_y}) px  |  corr: {v_corr:.4f}  |  {dt:.2f}s")

# ── Compute rotation angles in radians ──
horizontal_angle = (math.atan2(h_off_y, h_off_x) + 2 * math.pi) % (2 * math.pi)
vertical_angle = (math.atan2(v_off_y, v_off_x) + 2 * math.pi) % (2 * math.pi)

print(f"\n    Raw angles:")
print(f"      Horizontal: {math.degrees(horizontal_angle):.2f}°  ({horizontal_angle:.6f} rad)")
print(f"      Vertical:   {math.degrees(vertical_angle):.2f}°  ({vertical_angle:.6f} rad)")

# Ensure horizontal and vertical angles are within π of each other for normalization
while abs(horizontal_angle - vertical_angle) > math.pi:
    if horizontal_angle < vertical_angle:
        horizontal_angle += 2 * math.pi
    else:
        vertical_angle += 2 * math.pi

# ── Determine if camera sensor axes are mirrored relative to stage axes ──
# Under nominal rotation, the vertical angle should lead or follow the horizontal one.
# An inverse relationship indicates axis mirroring.
mirror = vertical_angle < horizontal_angle

# ── Adjust angles based on mirror state (matching Java IIS algorithm) ──
if mirror:
    horizontal_angle -= math.pi / 2
else:
    vertical_angle -= math.pi / 2
    vertical_angle = -vertical_angle
    horizontal_angle = -horizontal_angle

print(f"\n    Adjusted angles:")
print(f"      Horizontal: {math.degrees(horizontal_angle):.2f}°  ({horizontal_angle:.6f} rad)")
print(f"      Vertical:   {math.degrees(vertical_angle):.2f}°  ({vertical_angle:.6f} rad)")
print(f"      Mirror:     {mirror}")

# ── Compute refined pixel sizes ──
h_magnitude = math.sqrt(h_off_x ** 2 + h_off_y ** 2)
v_magnitude = math.sqrt(v_off_x ** 2 + v_off_y ** 2)

if h_magnitude == 0 or v_magnitude == 0:
    raise RuntimeError(
        "Phase 2 failed: zero offset in cardinal correlation. "
        "The stage may not be moving, or the sample lacks features."
    )

# Since we moved 30% left and 30% right, the total distance between left/right is 60% (2 * offset_ratio).
# Refined pixel size = total stage shift distance (in nm) / measured pixel translation magnitude
pixel_size_nm_hori = (image_dim * 2 * offset_ratio * pixel_size_nm_exp10) / h_magnitude
pixel_size_nm_verti = (image_dim * 2 * offset_ratio * pixel_size_nm_exp10) / v_magnitude
pixel_size_nm_exp = (pixel_size_nm_hori + pixel_size_nm_verti) / 2.0

print(f"\n    Refined pixel sizes:")
print(f"      Horizontal: {pixel_size_nm_hori:.2f} nm")
print(f"      Vertical:   {pixel_size_nm_verti:.2f} nm")
print(f"      Average:    {pixel_size_nm_exp:.2f} nm")

# ── Final calibration parameters ──
# theta: Average of horizontal and vertical angles
# scale: Ratio of theoretical pixel size to experimental pixel size
theta = (horizontal_angle + vertical_angle) / 2.0
scale = pixel_size_nm_theoric / pixel_size_nm_exp

# ==============================================================
# OUTPUT RESULTS
# ==============================================================
print("\n" + "=" * 70)
print("  CALIBRATION RESULTS")
print("=" * 70)

calibration = {
    "StageCalibAngle": theta,
    "StageCalibMirror": mirror,
    "StageCalibStaticScale": scale,
}

print(f"\n    Result: {calibration}\n")
print(f"    Angle (rad):     {theta:.6f} ({math.degrees(theta):.2f}°)")
print(f"    Mirror:          {mirror}")
print(f"    Scale:           {scale:.6f}")
print(f"    Theoric pixel:   {pixel_size_nm_theoric:.2f} nm")
print(f"    Exp. pixel:      {pixel_size_nm_exp:.2f} nm")

# ==============================================================
# PHASE 3: Validation Mosaic (2×2, 0% overlap)
# ==============================================================
print("\n" + "=" * 70)
print("  PHASE 3: Validation mosaic (2×2)")
print("=" * 70)

# Field-of-view in nanometers using the refined experimental pixel size
fov_x_nm = camera_width * pixel_size_nm_exp
fov_y_nm = camera_height * pixel_size_nm_exp

mosaic_positions = []
mosaic_grid_coords = []

# Generate coordinate offsets for a 2x2 grid centered on the original position.
# Col/Row coordinates are offset by ±0.5 FOV from center.
for j in range(2):
    # Snake pattern: reverse column order on odd rows to minimize travel
    cols = range(2 - 1, -1, -1) if j % 2 != 0 else range(2)
    for i in cols:
        # Camera-space offset from center (in nanometers)
        cam_x = (i - 0.5) * fov_x_nm
        cam_y = (j - 0.5) * fov_y_nm

        # Apply computed calibration (mirror, rotation, scale) to convert 
        # camera coordinate offsets to stage motor offsets.
        stage_dx, stage_dy = mosaic_convert(cam_x, cam_y, theta, mirror)
        stage_dx *= scale
        stage_dy *= scale

        target_x = current_x + stage_dx
        target_y = current_y + stage_dy

        mosaic_positions.append((target_x, target_y))
        mosaic_grid_coords.append((j, i))

print(f"    FOV: {fov_x_nm / 1000:.1f} × {fov_y_nm / 1000:.1f} µm")
print(f"    Acquiring {len(mosaic_positions)} tiles...")

# Run the sequence to acquire the 2x2 validation tiles
mosaic_images = acquire_images(mosaic_positions)

if len(mosaic_images) >= 4:
    # Plot individual tile bounds in stage-space for checking overlaps
    plot_and_save_stage(
        mosaic_images, mosaic_positions,
        stage_name="Phase 3: Validation Mosaic",
        subtitle="Checking validation mosaic tile coordinates before stitching.",
        pixel_size_um=pixel_size_nm_exp / 1000.0
    )

    # Stitch the 2x2 mosaic tiles using simple pixel boundary alignment (no blending)
    H, W = mosaic_images[0].shape
    pano_width = 2 * W
    pano_height = 2 * H

    pano = np.zeros((pano_height, pano_width), dtype=np.float32)
    weights = np.zeros((pano_height, pano_width), dtype=np.float32)

    # Accumulate pixels into the panorama grid
    for idx, img in enumerate(mosaic_images):
        if idx >= len(mosaic_grid_coords):
            break
        j, i = mosaic_grid_coords[idx]
        start_x = i * W
        start_y = j * H
        end_x = start_x + W
        end_y = start_y + H

        pano[start_y:end_y, start_x:end_x] += img
        weights[start_y:end_y, start_x:end_x] += 1.0

    # Normalize overlapping boundaries
    mask = weights > 0
    pano[mask] /= weights[mask]

    # Display and save the final stitched mosaic panorama
    total_fov_x_um = pano_width * pixel_size_nm_exp / 1000
    total_fov_y_um = pano_height * pixel_size_nm_exp / 1000

    fig, ax = plt.subplots(figsize=(10, 10))
    ax.imshow(pano, cmap="gray")
    title_text = (
        f"Stitched Validation Mosaic (2×2)\n"
        f"{total_fov_x_um:.0f} × {total_fov_y_um:.0f} µm  |  "
        f"θ = {math.degrees(theta):.1f}°  |  "
        f"mirror = {mirror}  |  "
        f"scale = {scale:.4f}\n"
        "If calibration is correct, features should align seamlessly across the grid boundaries."
    )
    ax.set_title(title_text, fontsize=11, pad=12)

    # Draw a dotted validation grid overlay in cyan to visually highlight seams
    ax.axhline(y=H, color="cyan", linewidth=0.8, alpha=0.5, linestyle="--")
    ax.axvline(x=W, color="cyan", linewidth=0.8, alpha=0.5, linestyle="--")

    ax.axis("off")
    plt.tight_layout()

    # Save the validation mosaic image to the script folder
    script_dir = os.path.dirname(os.path.abspath(__file__))
    save_path = os.path.join(script_dir, "stitched_validation_mosaic.png")
    fig.savefig(save_path, dpi=300, transparent=True, bbox_inches='tight')
    print(f"    [Saved Plot] {save_path}")

    print("\n    Displaying validation mosaic...")
    print("    → If tiles align well (no visible seams), calibration is good!")
    print("    → If tiles overlap or have gaps, check the configuration.")
    plt.show()
else:
    print("    WARNING: Insufficient images for validation mosaic.")

# ──────────────────────────────────────────────────────────────
# Move stage back to center position (original starting coordinates)
# ──────────────────────────────────────────────────────────────
print("\n[Cleanup] Returning stage to center position...")

return_status = inscoper_api.Status()
for sub_device_id, value in tl_channel_description.items():
    if sub_device_id is not None:
        return_status.addParam(sub_device_id, value)
return_status.addParam(x_axis_sub_device_id, int(current_x))
return_status.addParam(y_axis_sub_device_id, int(current_y))
return_status.addTag("MyCamera")
return_status.addTag("SOFTWARE_TRIGGER")

my_bridge.loadSequence(2, "Acquisition", [return_status])
my_bridge.runSequence(2, False)

# ──────────────────────────────────────────────────────────────
# Final summary output
# ──────────────────────────────────────────────────────────────
print("\n" + "=" * 70)
print("  CALIBRATION COMPLETE")
print("=" * 70)
print(f"\n    Result: {calibration}\n")
print("    To apply this calibration when generating tiling positions:")
print()
print("      # Camera offset → stage offset")
print("      if mirror:")
print("          cam_x, cam_y = cam_y, cam_x")
print("      stage_x = scale * (cam_x * cos(θ) - cam_y * sin(θ))")
print("      stage_y = scale * (cam_x * sin(θ) + cam_y * cos(θ))")
print()