"""This module implements segmentation related utilities."""
# Copyright (C) 2021-2022 Intel Corporation
# SPDX-License-Identifier: Apache-2.0
#
import warnings
from copy import copy
from typing import List, Optional, Sequence, Tuple, cast
import cv2
import numpy as np
from bson import ObjectId
from otx.api.entities.annotation import Annotation
from otx.api.entities.dataset_item import DatasetItemEntity
from otx.api.entities.id import ID
from otx.api.entities.label import LabelEntity
from otx.api.entities.scored_label import ScoredLabel
from otx.api.entities.shapes.polygon import Point, Polygon
from otx.api.utils.shape_factory import ShapeFactory
[docs]
def mask_from_dataset_item(
dataset_item: DatasetItemEntity, labels: List[LabelEntity], use_otx_adapter: bool = True
) -> np.ndarray:
"""Creates a mask from dataset item.
The mask will be two dimensional, and the value of each pixel matches the class index with offset 1. The background
class index is zero. labels[0] matches pixel value 1, etc. The class index is
determined based on the order of 'labels'.
Args:
dataset_item: Item to make mask for
labels: The labels to use for creating the mask. The order of
the labels determines the class index.
Returns:
Numpy array of mask
"""
# todo: cache this so that it does not have to be redone for all the same media
if use_otx_adapter:
mask = mask_from_annotation(dataset_item.get_annotations(), labels, dataset_item.width, dataset_item.height)
else:
mask = mask_from_file(dataset_item)
return mask
[docs]
def mask_from_file(dataset_item: DatasetItemEntity) -> np.ndarray:
"""Loads masks directly from annotation image.
Only Common Sematic Segmentation format is supported.
"""
mask_form_file = dataset_item.media.path
if mask_form_file is None:
raise ValueError("Mask file doesn't exist or corrupted")
mask_form_file = mask_form_file.replace("images", "masks")
mask = cv2.imread(mask_form_file, cv2.IMREAD_GRAYSCALE)
mask = np.expand_dims(mask, axis=2)
return mask
[docs]
def mask_from_annotation(
annotations: List[Annotation], labels: List[LabelEntity], width: int, height: int
) -> np.ndarray:
"""Generate a segmentation mask of a numpy image, and a list of shapes.
The mask is will be two dimensional and the value of each pixel matches the class
index with offset 1. The background class index is zero. labels[0] matches pixel
value 1, etc. The class index is determined based on the order of `labels`:
Args:
annotations: List of annotations to plot in mask
labels: List of labels. The index position of the label
determines the class number in the segmentation mask.
width: Width of the mask
height: Height of the mask
Returns:
2d numpy array of mask
"""
mask = np.zeros(shape=(height, width), dtype=np.uint8)
for annotation in annotations:
shape = annotation.shape
if not isinstance(shape, Polygon):
shape = ShapeFactory.shape_as_polygon(annotation.shape)
known_labels = [
label for label in annotation.get_labels() if isinstance(label, ScoredLabel) and label.get_label() in labels
]
if len(known_labels) == 0:
# Skip unknown shapes
continue
label_to_compare = known_labels[0].get_label()
class_idx = labels.index(label_to_compare) + 1
contour = []
for point in shape.points:
contour.append([int(point.x * width), int(point.y * height)])
mask = cv2.drawContours(mask, np.asarray([contour]), 0, (class_idx, class_idx, class_idx), -1)
mask = np.expand_dims(mask, axis=2)
return mask
[docs]
def create_hard_prediction_from_soft_prediction(
soft_prediction: np.ndarray, soft_threshold: float, blur_strength: int = 5
) -> np.ndarray:
"""Creates a hard prediction containing the final label index per pixel.
Args:
soft_prediction: Output from segmentation network. Assumes
floating point values, between 0.0 and 1.0. Can be a
2d-array of shape (height, width) or per-class segmentation
logits of shape (height, width, num_classes)
soft_threshold: minimum class confidence for each pixel. The
higher the value, the more strict the segmentation is
(usually set to 0.5)
blur_strength: The higher the value, the smoother the
segmentation output will be, but less accurate
Returns:
Numpy array of the hard prediction
"""
soft_prediction_blurred = cv2.blur(soft_prediction, (blur_strength, blur_strength))
if len(soft_prediction.shape) == 3:
# Apply threshold to filter out `unconfident` predictions, then get max along
# class dimension
soft_prediction_blurred[soft_prediction_blurred < soft_threshold] = 0
hard_prediction = np.argmax(soft_prediction_blurred, axis=2)
elif len(soft_prediction.shape) == 2:
# In the binary case, simply apply threshold
hard_prediction = soft_prediction_blurred > soft_threshold
else:
raise ValueError(
f"Invalid prediction input of shape {soft_prediction.shape}. " f"Expected either a 2D or 3D array."
)
return hard_prediction
Contour = List[Tuple[float, float]]
[docs]
def get_subcontours(contour: Contour) -> List[Contour]:
"""Splits contour into subcontours that do not have self intersections."""
ContourInternal = List[Optional[Tuple[float, float]]]
def find_loops(points: ContourInternal) -> List[Sequence[int]]:
"""For each consecutive pair of equivalent rows in the input matrix returns their indices."""
_, inverse, count = np.unique(points, axis=0, return_inverse=True, return_counts=True)
duplicates = np.where(count > 1)[0]
indices = []
for x in duplicates:
y = np.nonzero(inverse == x)[0]
for i, _ in enumerate(y[:-1]):
indices.append(y[i : i + 2])
return indices
base_contour = cast(ContourInternal, copy(contour))
# Make sure that contour is closed.
if not np.array_equal(base_contour[0], base_contour[-1]):
base_contour.append(base_contour[0])
subcontours: List[Contour] = []
loops = sorted(find_loops(base_contour), key=lambda x: x[0], reverse=True)
for loop in loops:
i, j = loop
subcontour = base_contour[i:j]
subcontour = list(x for x in subcontour if x is not None)
subcontours.append(cast(Contour, subcontour))
base_contour[i:j] = [None] * (j - i)
subcontours = [i for i in subcontours if len(i) > 2]
return subcontours
[docs]
def create_annotation_from_segmentation_map(
hard_prediction: np.ndarray, soft_prediction: np.ndarray, label_map: dict
) -> List[Annotation]:
"""Creates polygons from the soft predictions.
Background label will be ignored and not be converted to polygons.
Args:
hard_prediction: hard prediction containing the final label
index per pixel. See function
`create_hard_prediction_from_soft_prediction`.
soft_prediction: soft prediction with shape H x W x N_labels,
where soft_prediction[:, :, 0] is the soft prediction for
background. If soft_prediction is of H x W shape, it is
assumed that this soft prediction will be applied for all
labels.
label_map: dictionary mapping labels to an index. It is assumed
that the first item in the dictionary corresponds to the
background label and will therefore be ignored.
Returns:
List of shapes
"""
# pylint: disable=too-many-locals
height, width = hard_prediction.shape[:2]
img_class = hard_prediction.swapaxes(0, 1)
# pylint: disable=too-many-nested-blocks
annotations: List[Annotation] = []
for label_index, label in label_map.items():
# Skip background
if label_index == 0:
continue
# obtain current label soft prediction
if len(soft_prediction.shape) == 3:
current_label_soft_prediction = soft_prediction[:, :, label_index]
else:
current_label_soft_prediction = soft_prediction
obj_group = img_class == label_index
label_index_map = (obj_group.T.astype(int) * 255).astype(np.uint8)
# Contour retrieval mode CCOMP (Connected components) creates a two-level
# hierarchy of contours
contours, hierarchies = cv2.findContours(label_index_map, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE)
if hierarchies is not None:
for contour, hierarchy in zip(contours, hierarchies[0]):
if len(contour) <= 2 or cv2.contourArea(contour) < 1.0:
continue
if hierarchy[3] == -1:
# In this case a contour does not represent a hole
contour = list((point[0][0], point[0][1]) for point in contour)
# Split contour into subcontours that do not have self intersections.
subcontours = get_subcontours(contour)
for subcontour in subcontours:
# compute probability of the shape
mask = np.zeros(hard_prediction.shape, dtype=np.uint8)
cv2.drawContours(
mask,
np.asarray([[[x, y]] for x, y in subcontour]),
contourIdx=-1,
color=1,
thickness=-1,
)
probability = cv2.mean(current_label_soft_prediction, mask)[0]
# convert the list of points to a closed polygon
points = [Point(x=x / (width - 1), y=y / (height - 1)) for x, y in subcontour]
polygon = Polygon(points=points)
if polygon.get_area() > 0:
# Contour is a closed polygon with area > 0
annotations.append(
Annotation(
shape=polygon,
labels=[ScoredLabel(label, probability)],
id=ID(ObjectId()),
)
)
else:
# Contour is a closed polygon with area == 0
warnings.warn(
"The geometry of the segmentation map you are converting "
"is not fully supported. Polygons with a area of zero "
"will be removed.",
UserWarning,
)
else:
# If contour hierarchy[3] != -1 then contour has a parent and
# therefore is a hole
# Do not allow holes in segmentation masks to be filled silently,
# but trigger warning instead
warnings.warn(
"The geometry of the segmentation map you are converting is "
"not fully supported. A hole was found and will be filled.",
UserWarning,
)
return annotations