Source code for playnano.io.formats.read_aris

"""
Module to decode and load .aris high-speed AFM data files into an AFMImageStack object.

The Asylum Reseach ARIS files contain multiple frames that may differ in pixel
resolution. The global pixel size (from the first frame) is stored in
`pixel_size_nm`, while per-frame overrides are stored in each entry of
`frame_metadata` under the key `frame_pixel_size_nm`.
"""

import logging
from pathlib import Path

import h5py
import numpy as np

from playnano.afm_stack import AFMImageStack
from playnano.utils.io_utils import decode_hdf5_attr

logger = logging.getLogger(__name__)


def _get_channel_names(info: h5py.Group) -> list[str]:
    """
    Extract and decode channel names from the ARIS HDF5 DataSetInfo group.

    Parameters
    ----------
    info : h5py.Group
        The '/DataSetInfo' group of the ARIS file.

    Returns
    -------
    list of str
        Decoded channel names.
    """
    return [decode_hdf5_attr(name) for name in info.attrs["ChannelNames"]]


def _aris_global_pixel_to_nm_scaling_h5(info: h5py.Group) -> float:
    """
    Extract pixel-to-nanometre scaling from an ARIS HDF5 DataSetInfo Global group.

    This uses the fast scan axis (FastScanSize) in meters and converts the physical
    scan size to nanometres per pixel based on the number of pixels per line
    (ScanPoints).

    FastScanSize (in meters) / ScanPoints (number of pixels) * 1e9

    Parameters
    ----------
    info : h5py.Group
        HDF5 group containing the metadata associated with a DataSet (DataSetInfo).

    Returns
    -------
    float
        Real-world size of a single pixel in nanometres.

    Raises
    ------
    KeyError
        If required attributes are missing.
    ValueError
        If ScanPoints is zero.
    """

    try:
        scan_width = info["Global/Parameters/Scan"].attrs[
            "FastScanSize"
        ]  # physical length in meters
        pixel_scan_width = info["Global/Parameters/Scan"].attrs[
            "ScanPoints"
        ]  # number of pixels

        if pixel_scan_width == 0:
            raise ValueError(
                "Pixel count (ScanPoints) is zero; cannot compute scaling."
            )

        return (scan_width / pixel_scan_width) * 1e9

    except KeyError as e:
        missing = e.args[0]
        raise KeyError(
            f"Missing required attribute '{missing}' in HDF5 measurement group."
        ) from e


def _get_sorted_frame_keys(data: h5py.Group) -> list[str]:
    """
    Return frame keys sorted numerically.

    ARIS frame datasets use keys like 'Frame 0', 'Frame 1', 'Frame 2'... .

    Parameters
    ----------
    data : h5py.Group
        The '/DataSet/Resolution 0' group containing frame datasets.

    Returns
    -------
    list of str
        Sorted frame keys.
    """
    data_keys = list(data.keys())
    return sorted(data_keys, key=lambda k: int(k.split()[1]))


[docs] def load_frames_and_scaling( data: h5py.Group, info: h5py.Group, selected_ch: str, ) -> tuple[np.ndarray, list[float]]: """ Load all frames for the given channel and compute per-frame pixel sizes. Parameters ---------- data : h5py.Group HDF5 group with frame/channel image data. info : h5py.Group HDF5 group containing metadata and per-frame overrides. selected_ch : str Channel name to extract. Returns ------- (ndarray, list of float) 3D stack array and per-frame pixel-size values (nm). """ sorted_keys = _get_sorted_frame_keys(data) initial_pixel_size_nm = _aris_global_pixel_to_nm_scaling_h5(info) initial_scan_pixel_width = info["Global/Parameters/Scan"].attrs["ScanPoints"] frames = [] pixel_sizes_nm = [] for frame_key in sorted_keys: frames.append(data[frame_key][selected_ch]["Image"][:]) # If the scan size changes it is recorded in the per frame record in the # metadata group try: new_scan_width = info["Frames"][frame_key]["Parameters"]["Scan"].attrs[ "FastScanSize" ] except (KeyError, AttributeError): new_scan_width = None if new_scan_width is None: pixel_sizes_nm.append(initial_pixel_size_nm) else: try: new_scan_points = info["Frames"][frame_key]["Parameters"]["Scan"].attrs[ "ScanPoints" ] except (KeyError, AttributeError): new_scan_points = None if new_scan_points is None: new_pixel_size = (new_scan_width / initial_scan_pixel_width) * 1e9 else: new_pixel_size = (new_scan_width / new_scan_points) * 1e9 pixel_sizes_nm.append(new_pixel_size) image_stack = np.stack(frames) return image_stack, pixel_sizes_nm
[docs] def load_aris( file_path: Path | str, channel: str, ) -> AFMImageStack: """ Load image stack from a Asylum Research .aris file, scaled to nanometers. The images are loaded, reshaped into frames, and have timestamps generated. Parameters ---------- file_path : Path | str Path to the .aris file. channel : str Channel to extract. Returns ------- AFMImageStack Loaded AFM image stack with metadata and per-frame info. Notes ----- Pixel size may vary between frames in ARIS files. The global `pixel_size_nm` attribute corresponds to the first frame, while per-frame values are stored in `frame_metadata`. """ file_path = Path(file_path) with h5py.File(file_path, "r") as file: data = file[ "/DataSet/Resolution 0" ] # where the image data is stored per frame per channel info = file["/DataSetInfo"] # where image metadata, channel names etc. file_channels = _get_channel_names(info) if channel in file_channels: selected_ch = channel else: raise ValueError(f"Channel '{channel}' is not available.") image_stack, pixel_sizes_nm = load_frames_and_scaling(data, info, selected_ch) # Read timestamps and line_rate timestamps = info["Series"]["Time"][:] if len(timestamps) != image_stack.shape[0]: raise ValueError( f"Timestamp count ({len(timestamps)}) does not match frame count ({image_stack.shape[0]})." # noqa ) line_rate = info["Global/Parameters/Scan"].attrs["ScanRate"] # Compose per-frame metadata list frame_metadata = [] for frame in range(image_stack.shape[0]): frame_metadata.append( { "timestamp": float(timestamps[frame]), "frame_pixel_size_nm": float(pixel_sizes_nm[frame]), "line_rate": float(line_rate), } ) return AFMImageStack( data=image_stack, pixel_size_nm=pixel_sizes_nm[0], channel=channel, file_path=str(file_path), frame_metadata=frame_metadata, )