import numpy as np
import json
import cv2
from .styles import COCO
from .basic import Semantic
from .utils import json_default
[docs]class Annotation(Semantic):
"""
Annotation is a marking on an image.
This class acts as a level ontop of :class:`BBox`, :class:`Mask` and :class:`Polygons`
to manage and generate other annotations or export formats.
"""
[docs] @classmethod
def from_mask(cls, image, category, mask):
"""
Creates annotation class from a mask
:param image: image assoicated with annotation
:type image: :class:`Image`
:param category: category to label annotation
:type category: :class:`Category`
:param mask: mask to create annotation from
:type mask: :class:`Mask`, numpy.ndarray, list
"""
return cls(image, category, mask=mask)
[docs] @classmethod
def from_bbox(cls, image, category, bbox):
"""
Creates annotation from bounding box
:param image: image assoicated with annotation
:type image: :class:`Image`
:param category: category to label annotation
:type category: :class:`Category`
:param polygons: bbox to create annotation from
:type polygons: :class:`BBox`, list, tuple
"""
return cls(image, category, bbox=bbox)
[docs] @classmethod
def from_polygons(cls, image, category, polygons):
"""
Creates annotation from polygons
Accepts following format for lists:
.. code-block:: python
# Segmentation Format
[
[x1, y1, x2, y2, x3, y3,...],
[x1, y1, x2, y2, x3, y3,...],
...
]
or
.. code-block:: python
# Point Format
[
[[x1, y1], [x2, y2], [x3, y3],...],
[[x1, y1], [x2, y2], [x3, y3],...],
...
]
*No sepcificaiton is reqiured between which
format is used*
:param image: image assoicated with annotation
:type image: :class:`Image`
:param category: category to label annotation
:type category: :class:`Category`
:param polygons: polygons to create annotation from
:type polygons: :class:`Polygons`, list
"""
return cls(image, category, polygons=polygons)
def __init__(self, image, category, bbox=None, mask=None, polygons=None, id=0, color=None, metadata={}):
assert isinstance(id, int), "id must be an integer"
assert bbox or mask or polygons, "you must provide a mask, bbox or polygon"
self.image = image
self.category = category
self._c_bbox = BBox.create(bbox)
self._c_mask = Mask.create(mask)
self._c_polygons = Polygons.create(polygons)
self._init_with_bbox = self._c_bbox is not None
self._init_with_mask = self._c_mask is not None
self._init_with_polygons = self._c_polygons is not None
super().__init__(id, metadata)
@property
def mask(self):
"""
:returns: annotation's :class:`Mask` object
"""
if not self._c_mask:
if self._init_with_polygons:
self._c_mask = self.polygons.mask(width=self.image.width, height=self.image.height)
else:
self._c_mask = self.bbox.mask(width=self.image.width, height=self.image.height)
return self._c_mask
@property
def array(self):
"""
Numpy array boolean mask repsentation of the annotations
"""
return self.mask.array
@property
def polygons(self):
"""
:class:`Polygons` repsentation of the annotations
"""
if not self._c_polygons:
if self._init_with_mask:
self._c_polygons = self.mask.polygons()
else:
self._c_polygons = self.bbox.polygons()
return self._c_polygons
@property
def bbox(self):
"""
:class:`BBox` repsentation of the annotations
"""
if not self._c_bbox:
if self._init_with_polygons:
self._c_bbox = self.polygons.bbox()
else:
self._c_bbox = self.mask.bbox()
return self._c_bbox
@property
def area(self):
"""
Qantity that expresses the extent of a two-dimensional figure
"""
if self._init_with_mask or self._init_with_polygons:
return self.mask.area()
return self.bbox.area
def index(self, image):
annotation_index = image.annotations
category_index = image.categories
if self.id < 1:
self.id = len(annotation_index) + 1
found = annotation_index.get(self.id)
if found:
# Increment index until not found
annotation_index.id += 1
self.index(image)
else:
annotation_index[self.id] = self
# Category indexing should be case insenstive
category_name = self.category.name.lower()
# Check if category exists
category_found = category_index.get(category_name)
if category_found:
# Update category
self.category = category_found
else:
# Index category
category_index[category_name] = self.category
@property
def size(self):
"""
Tuple of width and height
"""
return self.image.size
def __contains__(self, item):
return self.mask.contains(item)
def _coco(self, include=True):
annotation = {
'id': self.id,
'image_id': self.image.id,
'category_id': self.category.id,
'width': self.image.width,
'height': self.image.height,
'area': int(self.area),
'segmentation': self.polygons.segmentation,
'bbox': self.bbox.bbox(style=BBox.WIDTH_HEIGHT),
'metadata': self.metadata
}
if include:
return {
'categories': [self.category._coco()],
'images': [self.image._coco(include=False)],
'annotations': [annotation]
}
return annotation
def _yolo(self):
height = self.bbox.height / self.image.height
width = self.bbox.width / self.image.width
x = self.bbox._xmin / self.image.width
y = self.bbox._ymin / self.image.height
label = self.category.id
return "{} {:.5f} {:.5f} {:.5f} {:.5f}".format(label, x, y, width, height)
def save(self, file_path, style=COCO):
with open(file_path, 'w') as fp:
json.dump(self.export(style=style), fp, default=json_default)
[docs]class BBox:
"""
Bounding Box is an enclosing retangular box for a image marking
"""
#: Value types of :class:`BBox`
INSTANCE_TYPES = (np.ndarray, list, tuple)
#: Bounding box format style [x1, y1, x2, y2]
MIN_MAX = 'minmax'
#: Bounding box format style [x1, y1, width, height]
WIDTH_HEIGHT = 'widthheight'
@classmethod
def from_mask(cls, mask):
return mask.bbox()
@classmethod
def from_polygons(cls, polygons):
return polygons.bbox()
@classmethod
def create(cls, bbox):
if isinstance(bbox, BBox.INSTANCE_TYPES):
return BBox(bbox)
if isinstance(bbox, BBox):
return bbox
return None
@classmethod
def empty(cls):
return BBox((0, 0, 0, 0))
_c_polygons = None
_c_mask = None
def __init__(self, bbox, style=None):
"""
:param bbox:
:param style: maxmin or widthheight
"""
assert len(bbox) == 4
self.style = style if style else BBox.MIN_MAX
self._xmin = int(bbox[0])
self._ymin = int(bbox[1])
if self.style == self.MIN_MAX:
self._xmax = int(bbox[2])
self._ymax = int(bbox[3])
self.width = self._xmax - self._xmin
self.height = self._ymax - self._ymin
if self.style == self.WIDTH_HEIGHT:
self.width = int(bbox[2])
self.height = int(bbox[3])
self._xmax = self._xmin + self.width
self._ymax = self._ymin + self.height
self.area = self.width * self.height
def bbox(self, style=None):
style = style if style else self.style
if style == self.MIN_MAX:
return self._xmin, self._ymin, self._xmax, self._ymax
return self._xmin, self._ymin, self.width, self.height
def polygons(self):
if not self._c_polygons:
polygon = self.top_left + self.top_right \
+ self.bottom_right + self.bottom_left
return Polygons([polygon])
return self._c_polygons
def mask(self, width=None, height=None):
if not self._c_mask:
mask = np.zeros((height, width))
mask[self.min_point[1]:self.max_point[1], self.min_point[0]:self.max_point[0]] = 1
self._c_mask = Mask(mask)
return self._c_mask
def apply(self, image, color=None, thickness=2):
color = color if color else (255, 0, 0)
cv2.rectangle(image, self.min_point, self.max_point, color, thickness)
@property
def min_point(self):
return self._xmin, self._ymin
@property
def max_point(self):
return self._xmax, self._ymax
@property
def top_right(self):
return self._xmax, self._ymin
@property
def top_left(self):
return self._xmin, self._ymax
@property
def bottom_right(self):
return self._xmax, self._ymax
@property
def bottom_left(self):
return self._xmin, self._ymax
@property
def size(self):
return self.width, self.height
def _compute_size(self):
self.width = self._xmax - self._xmin
self.height = self._ymax - self._ymin
def __getitem__(self, item):
return self.bbox()[item]
def __repr__(self):
return repr(self.bbox())
def __str__(self):
return str(self.bbox())
def __eq__(self, other):
if isinstance(other, self.INSTANCE_TYPES):
other = BBox(other)
if isinstance(other, BBox):
return np.array_equal(self.bbox(style=self.MIN_MAX), other.bbox(style=self.MIN_MAX))
return False
[docs]class Polygons:
INSTANCE_TYPES = (list, tuple)
@classmethod
def from_mask(cls, mask):
return mask.polygons()
@classmethod
def from_bbox(cls, bbox, style=None):
return bbox.polygons()
@classmethod
def create(cls, polygons):
if isinstance(polygons, Polygons.INSTANCE_TYPES):
return Polygons(polygons)
if isinstance(polygons, Polygons):
return polygons
return None
_c_bbox = None
_c_mask = None
_c_points = None
_c_segmentation = None
def __init__(self, polygons):
self.polygons = [np.array(polygon).flatten() for polygon in polygons]
def mask(self, width=None, height=None):
if not self._c_mask:
size = height, width if height and width else self.bbox().max_point
# Generate mask from polygons
mask = np.zeros(size)
mask = cv2.fillPoly(mask, self.points, 1)
self._c_mask = Mask(mask)
self._c_mask._c_polygons = self
return self._c_mask
def bbox(self):
if not self._c_bbox:
y_min = x_min = float('inf')
y_max = x_max = float('-inf')
for point_list in self.points:
minx, miny = np.min(point_list, axis=0)
maxx, maxy = np.max(point_list, axis=0)
y_min = min(miny, y_min)
x_min = min(minx, x_min)
y_max = max(maxy, y_max)
x_max = max(maxx, x_max)
self._c_bbox = BBox((x_min, y_min, x_max, y_max))
self._c_bbox._c_polygons = self
return self._c_bbox
def simplify(self):
# TODO: Write simplification algotherm
self._c_points = None
@property
def points(self):
if not self._c_points:
self._c_points = [
np.array(point).reshape(-1, 2).round().astype(int)
for point in self.polygons
]
return self._c_points
@property
def segmentation(self):
if not self._c_segmentation:
self._c_segmentation = [polygon.tolist() for polygon in self.polygons]
return self._c_segmentation
def __eq__(self, other):
if isinstance(other, self.INSTANCE_TYPES):
other = Polygons(other)
if isinstance(other, Polygons):
for i in range(len(self.polygons)):
if not np.array_equal(self[i], other[i]):
return False
return True
return False
def __getitem__(self, key):
return self.polygons[key]
def __repr__(self):
return repr(self.polygons)
[docs]class Mask:
"""
Mask class
"""
INSTANCE_TYPES = (np.ndarray,)
@classmethod
def from_polygons(cls, polygons):
return polygons.mask()
@classmethod
def from_bbox(cls, bbox):
return bbox.mask()
@classmethod
def create(cls, mask):
if isinstance(mask, Mask.INSTANCE_TYPES):
return Mask(mask)
if isinstance(mask, Mask):
return mask
return None
_c_bbox = None
_c_polygons = None
def __init__(self, array):
self.array = np.array(array, dtype=bool)
def bbox(self):
if not self._c_bbox:
# Generate bbox from mask
rows = np.any(self.array, axis=1)
cols = np.any(self.array, axis=0)
if not np.any(rows) or not np.any(cols):
return BBox.empty()
rmin, rmax = np.where(rows)[0][[0, -1]]
cmin, cmax = np.where(cols)[0][[0, -1]]
self._c_bbox = BBox((cmin, rmin, cmax, rmax))
self._c_bbox._c_mask = self
return self._c_bbox
def polygons(self):
if not self._c_polygons:
# Generate polygons from mask
mask = self.array.astype(np.uint8)
mask = cv2.copyMakeBorder(mask, 1, 1, 1, 1, cv2.BORDER_CONSTANT, value=0)
polygons = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE, offset=(-1, -1))
polygons = polygons[0] if len(polygons) == 2 else polygons[1]
polygons = [polygon.flatten() for polygon in polygons]
self._c_polygons = Polygons(polygons)
self._c_polygons._c_mask = self
return self._c_polygons
[docs] def union(self, other):
"""
Unites the array of the specified mask with this mask’s array and returns the result as a new mask.
:param other: mask (or numpy array) to unite with
:return: resulting mask
"""
if isinstance(other, np.ndarray):
other = Mask(other)
return Mask(np.logical_or(self.array, other.array))
def __add__(self, other):
return self.union(other)
[docs] def intersect(self, other):
"""
Intersects the array of the specified mask with this masks’s array
and returns the result as a new mask.
:param other: mask (or numpy array) to intersect with
:return: resulting mask
"""
if isinstance(other, np.ndarray):
other = Mask(other)
return Mask(np.logical_and(self.array, other.array))
def __mul__(self, other):
return self.intersect(other)
[docs] def iou(self, other):
"""
Intersect over union value of the specified masks
:param other: mask (or numpy array) to compute value with
:return: resulting float value
"""
i = self.intersect(other).sum()
u = self.union(other).sum()
if i == 0 or u == 0:
return 0
return i / float(u)
def invert(self):
return Mask(np.invert(self.array))
def __invert__(self):
return self.invert()
def apply(self, image, color=None, alpha=0.5):
color = color if color else (255, 0, 0)
for c in range(3):
image[:, :, c] = np.where(
self.array,
image[:, :, c] * (1 - alpha) + alpha * color[c],
image[:, :, c]
)
return image
[docs] def subtract(self, other):
"""
Subtracts the array of the specified mask from this masks’s array and
returns the result as a new mask.
:param other: mask (or numpy array) to subtract
:retrn: resulting mask
"""
if isinstance(other, np.ndarray):
other = Mask(other)
return self.intersect(other.invert())
def __sub__(self, other):
return self.subtract(other)
[docs] def contains(self, item):
"""
Checks whether a point (tuple), array or mask is within current mask.
Note: Masks and arrays must be fully contained to return True
:param item: object to check
:return: boolean if item is contained
"""
if isinstance(item, tuple):
array = self.array
for i in item:
array = array[i]
return array
if isinstance(item, np.ndarray):
item = Mask(item)
if isinstance(item, Mask):
return self.intersect(item).area() > 0
return False
def __contains__(self, item):
return self.contains(item)
def sum(self):
return self.array.sum()
def area(self):
return self.sum()
def __getitem__(self, key):
return self.array[key]
def __setitem__(self, key, value):
self.array[key] = value
def __eq__(self, other):
if isinstance(other, (np.ndarray, list)):
other = Mask(other)
if isinstance(other, Mask):
return np.array_equal(self.array, other.array)
return False
def __repr__(self):
return repr(self.array)
__all__ = ["Annotation", "BBox", "Mask", "Polygons"]