Source code for fissa.readimagejrois

"""
Tools for reading ImageJ files.

Based on code originally written by Luis Pedro Coelho <luis@luispedro.org>,
2012, available at https://gist.github.com/luispedro/3437255, distributed
under the MIT License.

Modified
    - 2014 by Jeffrey Zaremba (@jzaremba), https://github.com/losonczylab/sima
    - 2015 by Scott Lowe (@scottclowe) and Sander Keemink (@swkeemink).
"""

from __future__ import division, unicode_literals

import sys
import zipfile
from itertools import product

import numpy as np
from past.builtins import basestring
from skimage.draw import ellipse

if sys.version_info >= (3, 0):
    import read_roi


def _parse_roi_file_py2(roi_obj):
    """
    Parse an individual ImageJ ROI.

    This is based on the Java implementation:
    http://rsbweb.nih.gov/ij/developer/source/ij/io/RoiDecoder.java.html

    This method is not guaranteed to work for all C compilers on Windows.

    Parameters
    ----------
    roi_obj : file object or str
        File object containing a single ImageJ ROI, or a path to a single roi
        file.

    Returns
    -------
    dict
        Returns a parsed ROI object, a dictionary with either a ``"polygons"``
        or a ``"mask"`` field.

    Raises
    ------
    IOError
        If there is an error reading the roi file object.
    ValueError
        If unable to parse ROI.
    """
    # If this is a string, try opening the path as a file and running on its
    # contents
    if isinstance(roi_obj, basestring):
        with open(roi_obj) as f:
            return _parse_roi_file_py2(f)

    # Note:
    # _getX() calls with no assignment are present to move our pointer
    # within the imageJ roi file through bytes that we do not currently use.
    # In line comments indicate what they are; these could be extracted if
    # needed in the future.

    sub_pixel_resolution = 128

    # Other options that are not currently used
    # SPLINE_FIT = 1
    # DOUBLE_HEADED = 2
    # OUTLINE = 4
    # OVERLAY_LABELS = 8
    # OVERLAY_NAMES = 16
    # OVERLAY_BACKGROUNDS = 32
    # OVERLAY_BOLD = 64
    # DRAW_OFFSET = 256

    pos = [4]

    def _get8():
        """Read 1 byte from the roi file object."""
        pos[0] += 1
        s = roi_obj.read(1)
        if not s:
            raise IOError("read_imagej_roi: Unexpected EOF")
        return ord(s)

    def _get16():
        """Read 2 bytes from the roi file object."""
        b0 = _get8()
        b1 = _get8()
        return (b0 << 8) | b1

    def _get16signed():
        """Read a signed 16 bit integer from 2 bytes from roi file object."""
        b0 = _get8()
        b1 = _get8()
        out = (b0 << 8) | b1
        # This is a signed integer, so need to check if the value is
        # positive or negative.
        if b0 > 127:
            out = out - 65536
        return out

    def _get32():
        """Read 4 bytes from the roi file object."""
        s0 = _get16()
        s1 = _get16()
        return (s0 << 16) | s1

    def _getfloat():
        """Read a float from the roi file object."""
        v = np.int32(_get32())
        return v.view(np.float32)

    def _getcoords(z=0):
        """Get the next coordinate of an roi polygon."""
        if options & sub_pixel_resolution:
            getc = _getfloat
            points = np.empty((n_coordinates, 3), dtype=np.float32)
        else:
            getc = _get16
            points = np.empty((n_coordinates, 3), dtype=np.int16)
        points[:, 0] = [getc() for _ in range(n_coordinates)]
        points[:, 1] = [getc() for _ in range(n_coordinates)]
        points[:, 0] += left
        points[:, 1] += top
        points[:, 2] = z
        return points

    magic = roi_obj.read(4)
    if magic != b"Iout":
        raise IOError("read_imagej_roi: Magic number not found.")

    _get16()  # version

    roi_type = _get8()
    # Discard extra second Byte:
    _get8()

    if not (0 <= roi_type < 11):
        raise ValueError(
            "read_imagej_roi: \
                          ROI type {} not supported".format(
                roi_type
            )
        )

    top = _get16signed()
    left = _get16signed()
    bottom = _get16signed()
    right = _get16signed()
    n_coordinates = _get16()

    if right < 0 or bottom < 0:
        raise ValueError("ROI is entirely offscreen.")

    x1 = _getfloat()  # x1
    y1 = _getfloat()  # y1
    x2 = _getfloat()  # x2
    y2 = _getfloat()  # y2
    _get16()  # stroke width
    _get32()  # shape roi size
    _get32()  # stroke color
    _get32()  # fill color
    subtype = _get16()
    if subtype == 5:
        raise ValueError(
            "read_imagej_roi: ROI subtype {} (rotated rectangle) not supported".format(
                subtype
            )
        )
    if subtype != 0 and subtype != 3:
        raise ValueError(
            "read_imagej_roi: \
                          ROI subtype {} not supported (!= 0)".format(
                subtype
            )
        )
    options = _get16()
    if subtype == 3 and roi_type == 7:
        # ellipse aspect ratio
        aspect_ratio = _getfloat()
    else:
        _get8()  # arrow style
        _get8()  # arrow head size
        _get16()  # rectangle arc size
    z = _get32()  # position
    if z > 0:
        z -= 1  # Multi-plane images start indexing at 1 instead of 0
    _get32()  # header 2 offset

    if roi_type == 0:
        # Polygon
        coords = _getcoords(z)
        coords = coords.astype("float")
        return {"polygons": coords}
    elif roi_type == 1:
        # Rectangle
        coords = [
            [left, top, z],
            [right, top, z],
            [right, bottom, z],
            [left, bottom, z],
        ]
        coords = np.array(coords).astype("float")
        return {"polygons": coords}
    elif roi_type == 2:
        # Oval
        width = right - left
        height = bottom - top

        # 0.5 moves the mid point to the center of the pixel
        x_mid = (right + left) / 2.0 - 0.5
        y_mid = (top + bottom) / 2.0 - 0.5
        mask = np.zeros((z + 1, right, bottom), dtype=bool)
        for y, x in product(
            np.arange(max(0, top), bottom), np.arange(max(0, left), right)
        ):
            mask[z, x, y] = (x - x_mid) ** 2 / (width / 2.0) ** 2 + (y - y_mid) ** 2 / (
                height / 2.0
            ) ** 2 <= 1
        return {"mask": mask}
    elif roi_type == 7:
        if subtype == 3:
            if (x1 < 0 and x2 < 0) or (y1 < 0 and y2 < 0):
                raise ValueError("ROI is entirely offscreen.")
            # Ellipse
            # Radius of major and minor axes
            r_radius = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) / 2.0
            c_radius = r_radius * aspect_ratio
            # Centre coordinates
            # We subtract 0.5 because ImageJ's co-ordinate system has indices at
            # the pixel boundaries, and we are using indices at the pixel centers.
            x_mid = (x1 + x2) / 2.0 - 0.5
            y_mid = (y1 + y2) / 2.0 - 0.5
            orientation = np.arctan2(y2 - y1, x2 - x1)

            # We need to make a mask which is a bit bigger than this, because
            # we don't know how big the ellipse will end up being. In the most
            # extreme case, it is a circle aligned along a cartesian axis.
            right = 1 + int(np.ceil(max(x1, x2) + r_radius))
            bottom = 1 + int(np.ceil(max(y1, y2) + r_radius))
            mask = np.zeros((z + 1, right, bottom), dtype=bool)

            # Generate the ellipse
            xx, yy = ellipse(
                x_mid,
                y_mid,
                r_radius,
                c_radius,
                mask.shape[1:],
                rotation=orientation,
            )
            if len(xx) == 0 or len(yy) == 0:
                raise ValueError("Ellipse ROI is empty.")

            # Trim mask down to only the points needed
            mask = mask[:, : max(xx) + 1, : max(yy) + 1]
            # Convert sparse ellipse representation to mask
            mask[z, xx, yy] = True
            return {"mask": mask}
        else:
            # Freehand
            coords = _getcoords(z)
            coords = coords.astype("float")
            return {"polygons": coords}

    elif roi_type == 10:
        raise ValueError("read_imagej_roi: point/mulipoint types are not supported")

    else:
        try:
            coords = _getcoords(z)
            coords = coords.astype("float")
            return {"polygons": coords}
        except BaseException:
            raise ValueError(
                "read_imagej_roi: ROI type {} not supported".format(roi_type)
            )


def _parse_roi_file_py3(roi_source):
    """
    Parse an individual ImageJ ROI.

    Parameters
    ----------
    roi_source : str or file object
        Path to file, or file object containing a single ImageJ ROI.

    Returns
    -------
    dict
        Returns a parsed ROI object, a dictionary with either a ``"polygons"``
        or a ``"mask"`` field.

    Raises
    ------
    IOError
        If there is an error reading the roi file object.
    ValueError
        If unable to parse ROI.
    """
    # This implementation utilises the read_roi package, which is more robust
    # but only supports Python 3+ and not Python 2.7.

    # Use read_roi package to load up the roi as a dictionary
    roi = read_roi.read_roi_file(roi_source)
    # This is a dictionary with a single entry, whose key is the label
    # of the roi. We need to get out its contents, which is another dictionary.
    keys = list(roi.keys())
    if len(keys) == 1:
        roi = roi[keys[0]]

    # Convert the roi dictionary into either polygon or a mask
    if "x" in roi and "y" in roi and "n" in roi:
        # ROI types "freehand", "freeline", "multipoint", "point", "polygon",
        # "polyline", and "trace" are loaded and returned as a set of polygon
        # co-ordinates.
        coords = np.empty((roi["n"], 3), dtype=np.float64)
        coords[:, 0] = roi["x"]
        coords[:, 1] = roi["y"]
        coords[:, 2] = roi.get("z", 0)
        if np.all(coords[:, 0] < 0) or np.all(coords[:, 1] < 0):
            raise ValueError("ROI is entirely offscreen.")
        return {"polygons": coords}

    if "width" in roi and "height" in roi and "left" in roi and "top" in roi:
        width = roi["width"]
        height = roi["height"]
        left = roi["left"]
        top = roi["top"]
        right = left + width
        bottom = top + height
        if right < 0 or bottom < 0:
            raise ValueError("ROI is entirely offscreen.")

    z = roi.get("z", 0)

    if roi["type"] == "rectangle":
        # Rectangle is converted into polygon co-ordinates
        coords = [
            [left, top, z],
            [right, top, z],
            [right, bottom, z],
            [left, bottom, z],
        ]
        coords = np.array(coords).astype("float")
        return {"polygons": coords}

    elif roi["type"] == "oval":
        # Oval
        mask = np.zeros((z + 1, right, bottom), dtype=bool)

        # We subtract 0.5 because ImageJ's co-ordinate system has indices at
        # the pixel boundaries, and we are using indices at the pixel centers.
        x_mid = left + width / 2.0 - 0.5
        y_mid = top + height / 2.0 - 0.5

        # Ensure we only make a mask of things which are inside the image
        left = max(0, left)
        top = max(0, top)

        # Work out whether each pixel is inside the oval. We only need to check
        # pixels within the extent of the oval.
        xx = np.arange(left, right)
        yy = np.arange(top, bottom)
        xx = ((xx - x_mid) / (width / 2.0)) ** 2
        yy = ((yy - y_mid) / (height / 2.0)) ** 2
        dd = np.expand_dims(xx, 1) + np.expand_dims(yy, 0)
        mask[z, left:, top:] = dd <= 1
        return {"mask": mask}

    elif roi["type"] == "ellipse" or (
        roi["type"] == "freehand" and "aspect_ratio" in roi and "ex1" in roi
    ):
        # Ellipse
        # Co-ordinates of points at either end of major axis
        x1 = roi["ex1"]
        y1 = roi["ey1"]
        x2 = roi["ex2"]
        y2 = roi["ey2"]
        if (x1 < 0 and x2 < 0) or (y1 < 0 and y2 < 0):
            raise ValueError("ROI is entirely offscreen.")

        # Radius of major and minor axes
        r_radius = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) / 2.0
        c_radius = r_radius * roi["aspect_ratio"]
        # Centre coordinates
        # We subtract 0.5 because ImageJ's co-ordinate system has indices at
        # the pixel boundaries, and we are using indices at the pixel centers.
        x_mid = (x1 + x2) / 2.0 - 0.5
        y_mid = (y1 + y2) / 2.0 - 0.5
        orientation = np.arctan2(y2 - y1, x2 - x1)

        # We need to make a mask which is a bit bigger than this, because
        # we don't know how big the ellipse will end up being. In the most
        # extreme case, it is a circle aligned along a cartesian axis.
        right = 1 + int(np.ceil(max(x1, x2) + r_radius))
        bottom = 1 + int(np.ceil(max(y1, y2) + r_radius))
        mask = np.zeros((z + 1, right, bottom), dtype=bool)

        # Generate the ellipse
        xx, yy = ellipse(
            x_mid,
            y_mid,
            r_radius,
            c_radius,
            mask.shape[1:],
            rotation=orientation,
        )
        if len(xx) == 0 or len(yy) == 0:
            raise ValueError("Ellipse ROI is empty.")

        # Trim mask down to only the points needed
        mask = mask[:, : max(xx) + 1, : max(yy) + 1]
        # Convert sparse ellipse representation to mask
        mask[z, xx, yy] = True
        return {"mask": mask}

    else:
        raise ValueError("ROI type {} not supported".format(roi["type"]))


# Handle different functions on Python 2/3
parse_roi_file = (
    _parse_roi_file_py3 if sys.version_info >= (3, 0) else _parse_roi_file_py2
)


[docs]def read_imagej_roi_zip(filename): """ Read an ImageJ ROI zip set and parse each ROI individually. Parameters ---------- filename : str Path to the ImageJ ROis zip file. Returns ------- roi_list : list List of the parsed ImageJ ROIs. """ roi_list = [] with zipfile.ZipFile(filename) as zf: for name in zf.namelist(): roi = parse_roi_file(zf.open(name)) if roi is None: continue roi["label"] = str(name).rstrip(".roi") roi_list.append(roi) return roi_list