Mosaics with 4D volumes¶

Mosaics display multiple image views simultaneously, often called “lightbox” mode. NiiVue lets you define both the orientation and position for any number of image snapshots. In this example, a slider is used to step through each 3D volume in a 4D time series, with slices chosen to be evenly spaced across the volume. Because the number of volumes and spatial extent are unknown until loading completes, these parameters are computed within the on_image_loaded() event, triggered once the image becomes available. This approach differs from typical sequential Python workflows, as load_volumes() is asynchronous—allowing images to load without freezing the user interface.

In [1]:
import ipywidgets as widgets
from ipyniivue import NiiVue

nv = NiiVue()
nv.load_volumes([{"path":  "../images/mpld_asl.nii.gz"}])

def world_coord(i, j, k, affine):
    """Return world (x,y,z) for voxel index (i,j,k) using 4x4 affine list."""
    x = affine[0][0] * i + affine[0][1] * j + affine[0][2] * k + affine[0][3]
    y = affine[1][0] * i + affine[1][1] * j + affine[1][2] * k + affine[1][3]
    z = affine[2][0] * i + affine[2][1] * j + affine[2][2] * k + affine[2][3]
    return (x, y, z)

def axis_range(dims, affine, mid_mode='voxel'):
    """
    Return 1D lists with the canonical world coordinate for each slice along X, Y and Z.

    Parameters
    ----------
    dims : sequence
        NIfTI dims where dims[1]=nx, dims[2]=ny, dims[3]=nz
    affine : 4x4 nested sequence
        Voxel-to-world affine matrix (used by world_coord)
    mid_mode : {'voxel', 'between'}
        'voxel'  -> center voxel convention: (dim-1)/2.0  (default)
        'between'-> geometric middle between indices: dim/2.0

    Returns
    -------
    x_positions, y_positions, z_positions : tuple of lists
        - x_positions: list of length nx of world X (float) for each i (0..nx-1)
        - y_positions: list of length ny of world Y (float) for each j (0..ny-1)
        - z_positions: list of length nz of world Z (float) for each k (0..nz-1)
    """
    nx = int(dims[1])
    ny = int(dims[2])
    nz = int(dims[3])

    if mid_mode == 'between':
        mid_x = nx / 2.0
        mid_y = ny / 2.0
        mid_z = nz / 2.0
    else:
        # default: 'voxel' center convention
        mid_x = (nx - 1) / 2.0
        mid_y = (ny - 1) / 2.0
        mid_z = (nz - 1) / 2.0

    # X axis canonical (world X) for each i
    x_positions = []
    for i in range(nx):
        wx, wy, wz = world_coord(i, mid_y, mid_z, affine)
        x_positions.append(wx)

    # Y axis canonical (world Y) for each j
    y_positions = []
    for j in range(ny):
        wx, wy, wz = world_coord(mid_x, j, mid_z, affine)
        y_positions.append(wy)

    # Z axis canonical (world Z) for each k
    z_positions = []
    for k in range(nz):
        wx, wy, wz = world_coord(mid_x, mid_y, k, affine)
        z_positions.append(wz)

    return x_positions, y_positions, z_positions


@nv.on_image_loaded
def on_image_loaded(volume):
    """
    Event handler called when an image is loaded.

    Parameters
    ----------
    volume : ipyniivue.Volume
        The loaded image volume.
    """
    vol_slider4d.max = volume.n_frame_4d - 1
    x_pos, y_pos, z_pos = axis_range(volume.hdr.dims, volume.hdr.affine)
    
    # attach to the volume instance
    volume.x_pos = x_pos
    volume.y_pos = y_pos
    volume.z_pos = z_pos
    update_mosaic()

# Start by showing every 5th slice, with 10 columns
#nv2.opts.slice_mosaic_string = full_mosaic(coords, ncols=init_cols, step=init_step)

axis_selector = widgets.Dropdown(
    options=["axial", "sagittal", "coronal"], description="View"
)
vol_slider4d = widgets.IntSlider(min=0, max=0, description="Volume")
column_slider = widgets.IntSlider(min=0, max=20, value=10, description="Columns")
step_slider = widgets.IntSlider(min=1, max=5, value=1, description="Step size")
size_slider = widgets.IntSlider(min=100, max=1000, value=300, description="Height")




def update_mosaic(*args):
    """Update the mosaic string using the slice_pos arrays and a step size."""
    prefix = axis_selector.value[0].upper()

    # pick axis and its slice positions array
    if prefix == 'A':
        slice_pos = nv.volumes[0].z_pos
    elif prefix == 'C':
        slice_pos = nv.volumes[0].y_pos
    else:
        slice_pos = nv.volumes[0].x_pos

    cols = column_slider.value
    stepSize = step_slider.value

    # defensive checks
    if stepSize <= 0:
        # invalid step -> nothing to show
        nv.opts.slice_mosaic_string = prefix
        return

    total = len(slice_pos)
    N = total // stepSize   # number of items to show (integer division)
    if N <= 0:
        nv.opts.slice_mosaic_string = prefix
        return

    # Build positions by taking every `stepSize`-th entry from slice_pos
    positions = []
    for n in range(N):
        idx = n * stepSize
        # safety: ensure index in bounds (should be by construction)
        if idx < total:
            positions.append(slice_pos[idx])
        else:
            break

    # format with fixed decimals (3 here)
    def fmt(v):
        return f"{v:.3f}"

    # build mosaic string: prefix + " " + numbers, inserting ";" after every `cols` items
    parts = [prefix]
    for i, pos in enumerate(positions):
        parts.append(fmt(pos))
        if (i + 1) % cols == 0 and (i + 1) != len(positions):
            parts.append(";")

    mosaic_string = " ".join(parts)
    # apply
    nv.volumes[0].frame_4d = vol_slider4d.value
    nv.opts.slice_mosaic_string = mosaic_string


def update_height(*args):
    """Update widget height."""
    nv.height = size_slider.value

vol_slider4d.observe(update_mosaic, "value")
column_slider.observe(update_mosaic, "value")
step_slider.observe(update_mosaic, "value")
axis_selector.observe(update_mosaic, "value")
size_slider.observe(update_height, "value")


display(vol_slider4d)
display(column_slider)
display(step_slider)
display(size_slider)
display(axis_selector)
display(nv)
In [ ]: