# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 University of Dundee & Open Microscopy Environment.
# All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import numpy as np
from omero.gateway import ColorHolder
from omero.model import MaskI, Shape
from typing import Tuple, List, Dict, Set
from omero.rtypes import rdouble, rint, rstring, unwrap
import re
# Mapping of dimension names to axes in the image array
DIMENSION_ORDER: Dict[str, int] = {
"T": 0,
"C": 1,
"Z": 2,
"Y": 3,
"X": 4,
}
MASK_DTYPE_SIZE: Dict[int, np.dtype] = {
1: bool,
8: np.int8,
16: np.int16,
32: np.int32,
64: np.int64,
}
OME_MODEL_POINT_LIST_RE = re.compile(r"([\d.]+),([\d.]+)")
[docs]class NoMaskFound(ValueError):
"""
Exception thrown when no foreground pixels were found in a mask
"""
def __init__(self, msg="No mask found"):
super(Exception, self).__init__(msg)
[docs]class InvalidBinaryImage(ValueError):
"""
Exception thrown when invalid labels are found
"""
def __init__(self, msg="Invalid labels found"):
super(Exception, self).__init__(msg)
[docs]def mask_from_binary_image(
binim, rgba=None, z=None, c=None, t=None, text=None, raise_on_no_mask=True
):
"""
Create a mask shape from a binary image (background=0)
:param numpy.array binim: Binary 2D array, must contain values [0, 1] only
:param rgba int-4-tuple: Optional (red, green, blue, alpha) colour
:param z: Optional Z-index for the mask
:param c: Optional C-index for the mask
:param t: Optional T-index for the mask
:param text: Optional text for the mask
:param raise_on_no_mask: If True (default) throw an exception if no mask
found, otherwise return an empty Mask
:return: An OMERO mask
:raises NoMaskFound: If no labels were found
:raises InvalidBinaryImage: If the maximum labels is greater than 1
"""
# Find bounding box to minimise size of mask
xmask = binim.sum(0).nonzero()[0]
ymask = binim.sum(1).nonzero()[0]
if any(xmask) and any(ymask):
x0 = min(xmask)
w = max(xmask) - x0 + 1
y0 = min(ymask)
h = max(ymask) - y0 + 1
submask = binim[y0 : (y0 + h), x0 : (x0 + w)]
if not np.array_equal(np.unique(submask), [0, 1]) and not np.array_equal(
np.unique(submask), [1]
):
raise InvalidBinaryImage()
else:
if raise_on_no_mask:
raise NoMaskFound()
x0 = 0
w = 0
y0 = 0
h = 0
submask = []
mask = MaskI()
# BUG in older versions of Numpy:
# https://github.com/numpy/numpy/issues/5377
# Need to convert to an int array
# mask.setBytes(np.packbits(submask))
mask.setBytes(np.packbits(np.asarray(submask, dtype=int)))
mask.setWidth(rdouble(w))
mask.setHeight(rdouble(h))
mask.setX(rdouble(x0))
mask.setY(rdouble(y0))
if rgba is not None:
ch = ColorHolder.fromRGBA(*rgba)
mask.setFillColor(rint(ch.getInt()))
if z is not None:
mask.setTheZ(rint(z))
if c is not None:
mask.setTheC(rint(c))
if t is not None:
mask.setTheT(rint(t))
if text is not None:
mask.setTextValue(rstring(text))
return mask
[docs]def masks_from_label_image(
labelim, rgba=None, z=None, c=None, t=None, text=None, raise_on_no_mask=True
):
"""
Create mask shapes from a label image (background=0)
:param numpy.array labelim: 2D label array
:param rgba int-4-tuple: Optional (red, green, blue, alpha) colour
:param z: Optional Z-index for the mask
:param c: Optional C-index for the mask
:param t: Optional T-index for the mask
:param text: Optional text for the mask
:param raise_on_no_mask: If True (default) throw an exception if no mask
found, otherwise return an empty Mask
:return: A list of OMERO masks in label order ([] if no labels found)
"""
masks = []
for i in range(1, labelim.max() + 1):
mask = mask_from_binary_image(
labelim == i, rgba, z, c, t, text, raise_on_no_mask
)
masks.append(mask)
return masks
[docs]def shape_to_binary_image(shape: Shape) -> Tuple[np.ndarray, Tuple[int, ...]]:
"""
Convert an OMERO shape to a binary image
:param shape Shape: An OMERO shape
:return: tuple of
- Binary mask
- (T, C, Z, Y, X, h, w) tuple of mask settings (T, C, Z may be
None)
"""
if isinstance(shape, MaskI):
return _mask_to_binary_image(shape)
return _polygon_to_binary_image(shape)
def _mask_to_binary_image(
mask: Shape, dtype=bool
) -> Tuple[np.ndarray, Tuple[int, ...]]:
"""
Convert an OMERO mask to a binary image
:param mask MaskI: An OMERO mask
:param dtype: Data type for the binary image
:return: tuple of
- Binary mask
- (T, C, Z, Y, X, h, w) tuple of mask settings (T, C, Z may be
None)
"""
t = unwrap(mask.theT)
c = unwrap(mask.theC)
z = unwrap(mask.theZ)
x = int(mask.x.val)
y = int(mask.y.val)
w = int(mask.width.val)
h = int(mask.height.val)
mask_packed = mask.getBytes()
# convert bytearray into something we can use
intarray = np.frombuffer(mask_packed, dtype=np.uint8)
binarray = np.unpackbits(intarray).astype(dtype)
# truncate and reshape
binarray = np.reshape(binarray[: (w * h)], (h, w))
return binarray, (t, c, z, y, x, h, w)
def _polygon_to_binary_image(
polygon: Shape, dtype=bool
) -> Tuple[np.ndarray, Tuple[int, ...]]:
"""
Convert an OMERO polygon to a binary image
:param polygon Shape: An OMERO polygon
:return: tuple of
- Binary mask
- (T, C, Z, Y, X, h, w) tuple of mask settings (T, C, Z may be
None)
"""
from skimage.draw import polygon
t = unwrap(polygon.theT)
c = unwrap(polygon.theC)
z = unwrap(polygon.theZ)
# "10,20, 50,150, 200,200, 250,75"
points = unwrap(polygon.points).strip()
coords = OME_MODEL_POINT_LIST_RE.findall(points)
x_coords = np.array([int(round(float(xy[0]))) for xy in coords])
y_coords = np.array([int(round(float(xy[1]))) for xy in coords])
# bounding box of polygon
x = x_coords.min()
y = y_coords.min()
w = x_coords.max() - x
h = y_coords.max() - y
img = np.zeros((h, w), dtype=dtype)
# coords *within* bounding box
x_coords = x_coords - x
y_coords = y_coords - y
pixels = polygon(y_coords, x_coords, img.shape)
img[pixels] = 1
return img, (t, c, z, y, x, h, w)
[docs]def masks_to_labels(
masks: List[MaskI],
mask_shape: Tuple[int, ...],
ignored_dimensions: Set[str] = None,
check_overlaps: bool = True,
) -> Tuple[np.ndarray, Dict[int, str], Dict[int, Dict]]:
"""
:param masks List[MaskI]: List of OMERO masks
:param mask_shape 5-tuple: the image dimensions (T, C, Z, Y, X), taking
into account `ignored_dimensions`
:param ignored_dimensions Set[str]: Ignore these dimensions and set
size to 1
:param check_overlaps bool: Whether to check for overlapping masks or
not
:return: Label image with size `mask_shape` as well as color metadata
and dict of other properties.
"""
# FIXME: hard-coded dimensions
assert len(mask_shape) == 5, "must be a 5-tuple of (T, C, Z, Y, X)"
size_t: int = mask_shape[0]
size_c: int = mask_shape[1]
size_z: int = mask_shape[2]
ignored_dimensions = ignored_dimensions or set()
for d in "TCZYX":
if d in ignored_dimensions:
assert (
mask_shape[DIMENSION_ORDER[d]] == 1
), f"Ignored dimension {d} should be size 1"
fillColors: Dict[int, str] = {}
properties: Dict[int, Dict] = {}
roi_ids = [shape.roi.id.val for shape in masks]
sorted_roi_ids = list(set(roi_ids))
sorted_roi_ids.sort()
# label values are 1...n
max_value = len(sorted_roi_ids)
# find most suitable dtype...
labels_dtype = np.int64
sorted_dtypes = [kv for kv in MASK_DTYPE_SIZE.items()]
sorted_dtypes.sort(key=lambda x: x[0])
# ignore first dtype (bool)
for int_dtype in sorted_dtypes[1:]:
dtype = int_dtype[1]
# choose first dtype that handles max_value
if np.iinfo(dtype).max >= max_value:
labels_dtype = dtype
break
labels = np.zeros(mask_shape, labels_dtype)
for shape in masks:
# Using ROI ID allows stitching label from multiple images
# into a Plate and not creating duplicates from different iamges.
# All shapes will be the same value (color) for each ROI
shape_value = sorted_roi_ids.index(shape.roi.id.val) + 1
properties[shape_value] = {
"omero:shapeId": shape.id.val,
"omero:roiId": shape.roi.id.val,
}
if shape.textValue:
properties[shape_value]["omero:text"] = unwrap(shape.textValue)
if shape.fillColor:
fillColors[shape_value] = unwrap(shape.fillColor)
binim_yx, (t, c, z, y, x, h, w) = shape_to_binary_image(shape)
for i_t in _get_indices(ignored_dimensions, "T", t, size_t):
for i_c in _get_indices(ignored_dimensions, "C", c, size_c):
for i_z in _get_indices(ignored_dimensions, "Z", z, size_z):
if check_overlaps and np.any(
np.logical_and(
labels[i_t, i_c, i_z, y : (y + h), x : (x + w)].astype(
bool
),
binim_yx,
)
):
raise Exception(
f"Mask {shape_value} overlaps with existing labels"
)
# ADD to the array, so zeros in our binarray don't
# wipe out previous shapes
labels[i_t, i_c, i_z, y : (y + h), x : (x + w)] += (
binim_yx * shape_value
)
return labels, fillColors, properties
def _get_indices(
ignored_dimensions: Set[str], d: str, d_value: int, d_size: int
) -> List[int]:
"""
Figures out which Z/C/T-planes a mask should be copied to
"""
if d in ignored_dimensions:
return [0]
if d_value is not None:
return [d_value]
return range(d_size)