Source code for ewokstomo.tests.test_stitch_volumes

from pathlib import Path

import numpy as np
from ewoks import execute_graph
from tifffile import TiffFile
from tomoscan.esrf.volume import MultiTIFFVolume

from ewokstomo.tasks.stitch_volumes import (
    RefineVolumePosition,
    StitchMatchedSlices,
    _stitch_2d,
)
from ewokstomo.tests.test_utils import get_json_file


def _write_tiff_volume(
    path: Path,
    data: np.ndarray,
) -> Path:
    volume = MultiTIFFVolume(
        file_path=str(path),
        data=data.astype(np.float32),
    )
    volume.save()
    return path


def _read_stitched_tiff(
    path: Path,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    if path.suffix.lower() in (".tif", ".tiff"):
        path = path.with_suffix("")
    with TiffFile(path / "stitched_xy.tiff") as tiff_file:
        stitched_xy = tiff_file.asarray()
    with TiffFile(path / "stitched_yz.tiff") as tiff_file:
        stitched_yz = tiff_file.asarray()
    with TiffFile(path / "stitched_xz.tiff") as tiff_file:
        stitched_xz = tiff_file.asarray()
    return stitched_xy, stitched_yz, stitched_xz


def _stitch_expected(
    reference_plane: np.ndarray,
    moving_plane: np.ndarray,
    row_shift: int,
    col_shift: int,
) -> np.ndarray:
    row_min = min(0, row_shift)
    col_min = min(0, col_shift)
    row_max = max(reference_plane.shape[0], row_shift + moving_plane.shape[0])
    col_max = max(reference_plane.shape[1], col_shift + moving_plane.shape[1])

    canvas = np.zeros((row_max - row_min, col_max - col_min), dtype=np.float32)
    weights = np.zeros_like(canvas, dtype=np.float32)

    ref_r0 = -row_min
    ref_c0 = -col_min
    ref_r1 = ref_r0 + reference_plane.shape[0]
    ref_c1 = ref_c0 + reference_plane.shape[1]
    canvas[ref_r0:ref_r1, ref_c0:ref_c1] += reference_plane
    weights[ref_r0:ref_r1, ref_c0:ref_c1] += 1.0

    mov_r0 = row_shift - row_min
    mov_c0 = col_shift - col_min
    mov_r1 = mov_r0 + moving_plane.shape[0]
    mov_c1 = mov_c0 + moving_plane.shape[1]
    canvas[mov_r0:mov_r1, mov_c0:mov_c1] += moving_plane
    weights[mov_r0:mov_r1, mov_c0:mov_c1] += 1.0

    return canvas / np.maximum(weights, 1.0)


def _stitch_expected_many(
    planes: list[np.ndarray], row_shifts: list[int], col_shifts: list[int]
) -> np.ndarray:
    row_min = min(min(row_shifts), 0)
    col_min = min(min(col_shifts), 0)
    row_max = max(
        row_shift + plane.shape[0] for plane, row_shift in zip(planes, row_shifts)
    )
    col_max = max(
        col_shift + plane.shape[1] for plane, col_shift in zip(planes, col_shifts)
    )

    canvas = np.zeros((row_max - row_min, col_max - col_min), dtype=np.float32)
    weights = np.zeros_like(canvas, dtype=np.float32)

    for plane, row_shift, col_shift in zip(planes, row_shifts, col_shifts):
        row0 = row_shift - row_min
        col0 = col_shift - col_min
        row1 = row0 + plane.shape[0]
        col1 = col0 + plane.shape[1]
        canvas[row0:row1, col0:col1] += plane
        weights[row0:row1, col0:col1] += 1.0

    return canvas / np.maximum(weights, 1.0)


[docs] def test_stitch_2d_matches_intensity_before_blending(): reference = np.arange(36, dtype=np.float32).reshape(6, 6) moving = reference[2:, :] * 2.0 + 10.0 stitched = _stitch_2d(reference, moving, row_shift=2, col_shift=0) np.testing.assert_allclose(stitched[:6], reference, atol=1e-5)
[docs] def test_refine_and_stitch_orthogonal_slices(tmp_path): rng = np.random.default_rng(42) scene = rng.standard_normal((14, 19, 21)).astype(np.float32) # (z, x, y) true_shift_px = np.array([2, 3, -4], dtype=int) reference = scene[:8, :10, 4:15] moving = scene[ true_shift_px[0] : true_shift_px[0] + 8, true_shift_px[1] : true_shift_px[1] + 10, 4 + true_shift_px[2] : 15 + true_shift_px[2], ] volumes_dir = tmp_path / "volumes" reference_path = _write_tiff_volume( volumes_dir / "reference.tiff", reference, ) moving_path = _write_tiff_volume( volumes_dir / "moving.tiff", moving, ) for guess_mm in ([0.20, 0.30, -0.40], [0.10, 0.10, -0.20], [0.30, 0.50, -0.60]): refine_task = RefineVolumePosition( inputs={ "volume_paths": [reference_path, moving_path], "guess_distances_mm": guess_mm, "search_radius_px": [2, 4, 4], "downsample_factor": 1, "voxel_size_um": [100.0, 100.0, 100.0], } ) refine_task.execute() assert refine_task.outputs.volume_shifts_pixels == [[0, 0, 0], [2, 3, -4]] assert refine_task.outputs.assembly_points_pixels == [[5, 6, 3], [3, 3, 7]] output_path = tmp_path / "stitched" stitch_task = StitchMatchedSlices( inputs={ "volume_paths": [reference_path, moving_path], "volume_shifts_pixels": refine_task.outputs.volume_shifts_pixels, "assembly_points_pixels": refine_task.outputs.assembly_points_pixels, "output_path": output_path, } ) stitch_task.execute() stitched_xy, stitched_yz, stitched_xz = _read_stitched_tiff(output_path) expected_xy = _stitch_expected(reference[5], moving[3], row_shift=3, col_shift=-4) expected_yz = _stitch_expected( reference[:, 6, :], moving[:, 3, :], row_shift=2, col_shift=-4 ) expected_xz = _stitch_expected( reference[:, :, 3], moving[:, :, 7], row_shift=2, col_shift=3 ) np.testing.assert_allclose(stitched_xy, expected_xy, atol=1e-6) np.testing.assert_allclose(stitched_yz, expected_yz, atol=1e-6) np.testing.assert_allclose(stitched_xz, expected_xz, atol=1e-6)
[docs] def test_stitch_volumes_workflow_forwards_volume_paths(tmp_path): rng = np.random.default_rng(44) scene = rng.standard_normal((14, 19, 21)).astype(np.float32) # (z, x, y) true_shift_px = np.array([2, 3, -4], dtype=int) reference = scene[:8, :10, 4:15] moving = scene[ true_shift_px[0] : true_shift_px[0] + 8, true_shift_px[1] : true_shift_px[1] + 10, 4 + true_shift_px[2] : 15 + true_shift_px[2], ] volumes_dir = tmp_path / "volumes" volume_paths = [ _write_tiff_volume(volumes_dir / "reference.tiff", reference), _write_tiff_volume(volumes_dir / "moving.tiff", moving), ] output_path = tmp_path / "workflow_stitched" result = execute_graph( str(get_json_file("stitch_volumes.json")), inputs=[ { "id": "refine_position_task", "name": "volume_paths", "value": volume_paths, }, { "id": "refine_position_task", "name": "guess_distances_mm", "value": [0.10, 0.10, -0.20], }, { "id": "refine_position_task", "name": "search_radius_px", "value": [2, 4, 4], }, { "id": "refine_position_task", "name": "downsample_factor", "value": 1, }, { "id": "refine_position_task", "name": "voxel_size_um", "value": [100.0, 100.0, 100.0], }, { "id": "stitch_task", "name": "output_path", "value": output_path, }, ], ) assert result["stitched_path"] == str(output_path) stitched_xy, stitched_yz, stitched_xz = _read_stitched_tiff(output_path) expected_xy = _stitch_expected(reference[5], moving[3], row_shift=3, col_shift=-4) expected_yz = _stitch_expected( reference[:, 6, :], moving[:, 3, :], row_shift=2, col_shift=-4 ) expected_xz = _stitch_expected( reference[:, :, 3], moving[:, :, 7], row_shift=2, col_shift=3 ) np.testing.assert_allclose(stitched_xy, expected_xy, atol=1e-6) np.testing.assert_allclose(stitched_yz, expected_yz, atol=1e-6) np.testing.assert_allclose(stitched_xz, expected_xz, atol=1e-6)
[docs] def test_refine_and_stitch_tiff_volumes(tmp_path): rng = np.random.default_rng(43) scene = rng.standard_normal((14, 19, 21)).astype(np.float32) # (z, x, y) true_shift_px = np.array([2, 3, -4], dtype=int) reference = scene[:8, :10, 4:15] moving = scene[ true_shift_px[0] : true_shift_px[0] + 8, true_shift_px[1] : true_shift_px[1] + 10, 4 + true_shift_px[2] : 15 + true_shift_px[2], ] volumes_dir = tmp_path / "volumes" reference_path = _write_tiff_volume(volumes_dir / "reference.tiff", reference) moving_path = _write_tiff_volume(volumes_dir / "moving.tiff", moving) for path in (reference_path, moving_path): path.with_name(f"{path.stem}_infos.txt").write_text("not parseable") refine_task = RefineVolumePosition( inputs={ "volume_paths": [reference_path, moving_path], "guess_distances_mm": [0.10, 0.10, -0.20], "search_radius_px": [2, 4, 4], "downsample_factor": 1, "voxel_size_um": [100.0, 100.0, 100.0], } ) refine_task.execute() assert refine_task.outputs.volume_shifts_pixels == [[0, 0, 0], [2, 3, -4]] assert refine_task.outputs.assembly_points_pixels == [[5, 6, 3], [3, 3, 7]] stitch_task = StitchMatchedSlices( inputs={ "volume_paths": [reference_path, moving_path], "volume_shifts_pixels": refine_task.outputs.volume_shifts_pixels, "assembly_points_pixels": refine_task.outputs.assembly_points_pixels, } ) stitch_task.execute() output_path = Path(stitch_task.outputs.stitched_path) assert output_path.is_dir() stitched_xy, stitched_yz, stitched_xz = _read_stitched_tiff(output_path) assert stitched_xy.shape == (13, 15) assert stitched_yz.shape == (10, 15) assert stitched_xz.shape == (10, 13) expected_xy = _stitch_expected(reference[5], moving[3], row_shift=3, col_shift=-4) expected_yz = _stitch_expected( reference[:, 6, :], moving[:, 3, :], row_shift=2, col_shift=-4 ) expected_xz = _stitch_expected( reference[:, :, 3], moving[:, :, 7], row_shift=2, col_shift=3 ) np.testing.assert_allclose(stitched_xy, expected_xy, atol=1e-6) np.testing.assert_allclose(stitched_yz, expected_yz, atol=1e-6) np.testing.assert_allclose(stitched_xz, expected_xz, atol=1e-6)
[docs] def test_refine_and_stitch_orthogonal_slices_for_volume_list(tmp_path): rng = np.random.default_rng(7) scene = rng.standard_normal((20, 24, 30)).astype(np.float32) # (z, x, y) volume0 = scene[:10, :12, 8:24] volume1 = scene[2:12, 3:15, 4:20] volume2 = scene[4:14, 6:18, :16] volumes_dir = tmp_path / "volumes" volume_paths = [ _write_tiff_volume(volumes_dir / "volume0.tiff", volume0), _write_tiff_volume(volumes_dir / "volume1.tiff", volume1), _write_tiff_volume(volumes_dir / "volume2.tiff", volume2), ] refine_task = RefineVolumePosition( inputs={ "volume_paths": volume_paths, "guess_distances_mm": [[0.10, 0.20, -0.20], [0.30, 0.40, -0.50]], "search_radius_px": [[2, 4, 4], [2, 4, 4]], "downsample_factor": 1, "voxel_size_um": [100.0, 100.0, 100.0], } ) refine_task.execute() assert refine_task.outputs.pair_refined_shifts_pixels == [[2, 3, -4], [2, 3, -4]] assert refine_task.outputs.volume_shifts_pixels == [ [0, 0, 0], [2, 3, -4], [4, 6, -8], ] assert refine_task.outputs.assembly_points_pixels == [ [7, 9, 4], [5, 6, 8], [3, 3, 12], ] output_path = tmp_path / "stitched_list" stitch_task = StitchMatchedSlices( inputs={ "volume_paths": volume_paths, "volume_shifts_pixels": refine_task.outputs.volume_shifts_pixels, "assembly_points_pixels": refine_task.outputs.assembly_points_pixels, "output_path": output_path, } ) stitch_task.execute() stitched_xy, stitched_yz, stitched_xz = _read_stitched_tiff(output_path) expected_xy = _stitch_expected_many( [volume0[7], volume1[5], volume2[3]], row_shifts=[0, 3, 6], col_shifts=[0, -4, -8], ) expected_yz = _stitch_expected_many( [volume0[:, 9, :], volume1[:, 6, :], volume2[:, 3, :]], row_shifts=[0, 2, 4], col_shifts=[0, -4, -8], ) expected_xz = _stitch_expected_many( [volume0[:, :, 4], volume1[:, :, 8], volume2[:, :, 12]], row_shifts=[0, 2, 4], col_shifts=[0, 3, 6], ) np.testing.assert_allclose(stitched_xy, expected_xy, atol=1e-6) np.testing.assert_allclose(stitched_yz, expected_yz, atol=1e-6) np.testing.assert_allclose(stitched_xz, expected_xz, atol=1e-6)