aleju--imgaug
2836 行
104 KiB
Python
2836 行
104 KiB
Python
"""Classes dealing with polygons."""
|
|
from __future__ import print_function, division, absolute_import
|
|
|
|
import traceback
|
|
import collections
|
|
|
|
import numpy as np
|
|
import scipy.spatial.distance
|
|
import six.moves as sm
|
|
import skimage.draw
|
|
import skimage.measure
|
|
|
|
from .. import imgaug as ia
|
|
from .. import random as iarandom
|
|
from .base import IAugmentable
|
|
from .utils import (
|
|
normalize_imglike_shape,
|
|
interpolate_points,
|
|
_remove_out_of_image_fraction_,
|
|
project_coords_,
|
|
_normalize_shift_args,
|
|
_handle_on_image_shape
|
|
)
|
|
|
|
|
|
def recover_psois_(psois, psois_orig, recoverer, random_state):
|
|
"""Apply a polygon recoverer to input polygons in-place.
|
|
|
|
Parameters
|
|
----------
|
|
psois : list of imgaug.augmentables.polys.PolygonsOnImage or imgaug.augmentables.polys.PolygonsOnImage
|
|
The possibly broken polygons, e.g. after augmentation.
|
|
The `recoverer` is applied to them.
|
|
|
|
psois_orig : list of imgaug.augmentables.polys.PolygonsOnImage or imgaug.augmentables.polys.PolygonsOnImage
|
|
Original polygons that were later changed to `psois`.
|
|
They are an extra input to `recoverer`.
|
|
|
|
recoverer : imgaug.augmentables.polys._ConcavePolygonRecoverer
|
|
The polygon recoverer used to repair broken input polygons.
|
|
|
|
random_state : None or int or RNG or numpy.random.Generator or numpy.random.BitGenerator or numpy.random.SeedSequence or numpy.random.RandomState
|
|
An RNG to use during the polygon recovery.
|
|
|
|
Returns
|
|
-------
|
|
list of imgaug.augmentables.polys.PolygonsOnImage or imgaug.augmentables.polys.PolygonsOnImage
|
|
List of repaired polygons. Note that this is `psois`, which was
|
|
changed in-place.
|
|
|
|
"""
|
|
input_was_list = True
|
|
if not isinstance(psois, list):
|
|
input_was_list = False
|
|
psois = [psois]
|
|
psois_orig = [psois_orig]
|
|
|
|
for i, psoi in enumerate(psois):
|
|
for j, polygon in enumerate(psoi.polygons):
|
|
poly_rec = recoverer.recover_from(
|
|
polygon.exterior, psois_orig[i].polygons[j],
|
|
random_state)
|
|
|
|
# Don't write into `polygon.exterior[...] = ...` because the
|
|
# shapes might have changed. We could also first check if the
|
|
# shapes are identical and only then write in-place, but as the
|
|
# array for `poly_rec.exterior` was already created, that would
|
|
# not provide any benefits.
|
|
polygon.exterior = poly_rec.exterior
|
|
|
|
if not input_was_list:
|
|
return psois[0]
|
|
return psois
|
|
|
|
|
|
# TODO somehow merge with BoundingBox
|
|
# TODO add functions: simplify() (eg via shapely.ops.simplify()),
|
|
# extend(all_sides=0, top=0, right=0, bottom=0, left=0),
|
|
# intersection(other, default=None), union(other), iou(other), to_heatmap, to_mask
|
|
class Polygon(object):
|
|
"""Class representing polygons.
|
|
|
|
Each polygon is parameterized by its corner points, given as absolute
|
|
x- and y-coordinates with sub-pixel accuracy.
|
|
|
|
Parameters
|
|
----------
|
|
exterior : list of imgaug.augmentables.kps.Keypoint or list of tuple of float or (N,2) ndarray
|
|
List of points defining the polygon. May be either a ``list`` of
|
|
:class:`~imgaug.augmentables.kps.Keypoint` objects or a ``list`` of
|
|
``tuple`` s in xy-form or a numpy array of shape (N,2) for ``N``
|
|
points in xy-form.
|
|
All coordinates are expected to be the absolute subpixel-coordinates
|
|
on the image, given as ``float`` s, e.g. ``x=10.7`` and ``y=3.4`` for a
|
|
point at coordinates ``(10.7, 3.4)``. Their order is expected to be
|
|
clock-wise. They are expected to not be closed (i.e. first and last
|
|
coordinate differ).
|
|
|
|
label : None or str, optional
|
|
Label of the polygon, e.g. a string representing the class.
|
|
|
|
"""
|
|
|
|
def __init__(self, exterior, label=None):
|
|
"""Create a new Polygon instance."""
|
|
# TODO get rid of this deferred import
|
|
from imgaug.augmentables.kps import Keypoint
|
|
|
|
if isinstance(exterior, list):
|
|
if not exterior:
|
|
# for empty lists, make sure that the shape is (0, 2) and
|
|
# not (0,) as that is also expected when the input is a numpy
|
|
# array
|
|
self.exterior = np.zeros((0, 2), dtype=np.float32)
|
|
elif isinstance(exterior[0], Keypoint):
|
|
# list of Keypoint
|
|
self.exterior = np.float32([[point.x, point.y]
|
|
for point in exterior])
|
|
else:
|
|
# list of tuples (x, y)
|
|
# TODO just np.float32(exterior) here?
|
|
self.exterior = np.float32([[point[0], point[1]]
|
|
for point in exterior])
|
|
else:
|
|
assert ia.is_np_array(exterior), (
|
|
"Expected exterior to be a list of tuples (x, y) or "
|
|
"an (N, 2) array, got type %s" % (exterior,))
|
|
assert exterior.ndim == 2 and exterior.shape[1] == 2, (
|
|
"Expected exterior to be a list of tuples (x, y) or "
|
|
"an (N, 2) array, got an array of shape %s" % (
|
|
exterior.shape,))
|
|
# TODO deal with int inputs here?
|
|
self.exterior = np.float32(exterior)
|
|
|
|
# Remove last point if it is essentially the same as the first
|
|
# point (polygons are always assumed to be closed anyways). This also
|
|
# prevents problems with shapely, which seems to add the last point
|
|
# automatically.
|
|
is_closed = (
|
|
len(self.exterior) >= 2
|
|
and np.allclose(self.exterior[0, :], self.exterior[-1, :]))
|
|
if is_closed:
|
|
self.exterior = self.exterior[:-1]
|
|
|
|
self.label = label
|
|
|
|
@property
|
|
def coords(self):
|
|
"""Alias for attribute ``exterior``.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Returns
|
|
-------
|
|
ndarray
|
|
An ``(N, 2)`` ``float32`` ndarray containing the coordinates of
|
|
this polygon. This identical to the attribute ``exterior``.
|
|
|
|
"""
|
|
return self.exterior
|
|
|
|
@property
|
|
def xx(self):
|
|
"""Get the x-coordinates of all points on the exterior.
|
|
|
|
Returns
|
|
-------
|
|
(N,2) ndarray
|
|
``float32`` x-coordinates array of all points on the exterior.
|
|
|
|
"""
|
|
return self.exterior[:, 0]
|
|
|
|
@property
|
|
def yy(self):
|
|
"""Get the y-coordinates of all points on the exterior.
|
|
|
|
Returns
|
|
-------
|
|
(N,2) ndarray
|
|
``float32`` y-coordinates array of all points on the exterior.
|
|
|
|
"""
|
|
return self.exterior[:, 1]
|
|
|
|
@property
|
|
def xx_int(self):
|
|
"""Get the discretized x-coordinates of all points on the exterior.
|
|
|
|
The conversion from ``float32`` coordinates to ``int32`` is done
|
|
by first rounding the coordinates to the closest integer and then
|
|
removing everything after the decimal point.
|
|
|
|
Returns
|
|
-------
|
|
(N,2) ndarray
|
|
``int32`` x-coordinates of all points on the exterior.
|
|
|
|
"""
|
|
return np.int32(np.round(self.xx))
|
|
|
|
@property
|
|
def yy_int(self):
|
|
"""Get the discretized y-coordinates of all points on the exterior.
|
|
|
|
The conversion from ``float32`` coordinates to ``int32`` is done
|
|
by first rounding the coordinates to the closest integer and then
|
|
removing everything after the decimal point.
|
|
|
|
Returns
|
|
-------
|
|
(N,2) ndarray
|
|
``int32`` y-coordinates of all points on the exterior.
|
|
|
|
"""
|
|
return np.int32(np.round(self.yy))
|
|
|
|
@property
|
|
def is_valid(self):
|
|
"""Estimate whether the polygon has a valid geometry.
|
|
|
|
To to be considered valid, the polygon must be made up of at
|
|
least ``3`` points and have a concave shape, i.e. line segments may
|
|
not intersect or overlap. Multiple consecutive points are allowed to
|
|
have the same coordinates.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
``True`` if polygon has at least ``3`` points and is concave,
|
|
otherwise ``False``.
|
|
|
|
"""
|
|
if len(self.exterior) < 3:
|
|
return False
|
|
return self.to_shapely_polygon().is_valid
|
|
|
|
@property
|
|
def area(self):
|
|
"""Compute the area of the polygon.
|
|
|
|
Returns
|
|
-------
|
|
number
|
|
Area of the polygon.
|
|
|
|
"""
|
|
if len(self.exterior) < 3:
|
|
return 0.0
|
|
poly = self.to_shapely_polygon()
|
|
return poly.area
|
|
|
|
@property
|
|
def height(self):
|
|
"""Compute the height of a bounding box encapsulating the polygon.
|
|
|
|
The height is computed based on the two exterior coordinates with
|
|
lowest and largest x-coordinates.
|
|
|
|
Returns
|
|
-------
|
|
number
|
|
Height of the polygon.
|
|
|
|
"""
|
|
yy = self.yy
|
|
return max(yy) - min(yy)
|
|
|
|
@property
|
|
def width(self):
|
|
"""Compute the width of a bounding box encapsulating the polygon.
|
|
|
|
The width is computed based on the two exterior coordinates with
|
|
lowest and largest x-coordinates.
|
|
|
|
Returns
|
|
-------
|
|
number
|
|
Width of the polygon.
|
|
|
|
"""
|
|
xx = self.xx
|
|
return max(xx) - min(xx)
|
|
|
|
def project_(self, from_shape, to_shape):
|
|
"""Project the polygon onto an image with different shape in-place.
|
|
|
|
The relative coordinates of all points remain the same.
|
|
E.g. a point at ``(x=20, y=20)`` on an image
|
|
``(width=100, height=200)`` will be projected on a new
|
|
image ``(width=200, height=100)`` to ``(x=40, y=10)``.
|
|
|
|
This is intended for cases where the original image is resized.
|
|
It cannot be used for more complex changes (e.g. padding, cropping).
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
from_shape : tuple of int
|
|
Shape of the original image. (Before resize.)
|
|
|
|
to_shape : tuple of int
|
|
Shape of the new image. (After resize.)
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.Polygon
|
|
Polygon object with new coordinates.
|
|
The object may have been modified in-place.
|
|
|
|
"""
|
|
self.exterior = project_coords_(self.coords, from_shape, to_shape)
|
|
return self
|
|
|
|
def project(self, from_shape, to_shape):
|
|
"""Project the polygon onto an image with different shape.
|
|
|
|
The relative coordinates of all points remain the same.
|
|
E.g. a point at ``(x=20, y=20)`` on an image
|
|
``(width=100, height=200)`` will be projected on a new
|
|
image ``(width=200, height=100)`` to ``(x=40, y=10)``.
|
|
|
|
This is intended for cases where the original image is resized.
|
|
It cannot be used for more complex changes (e.g. padding, cropping).
|
|
|
|
Parameters
|
|
----------
|
|
from_shape : tuple of int
|
|
Shape of the original image. (Before resize.)
|
|
|
|
to_shape : tuple of int
|
|
Shape of the new image. (After resize.)
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.Polygon
|
|
Polygon object with new coordinates.
|
|
|
|
"""
|
|
return self.deepcopy().project_(from_shape, to_shape)
|
|
|
|
def find_closest_point_index(self, x, y, return_distance=False):
|
|
"""Find the index of the exterior point closest to given coordinates.
|
|
|
|
"Closeness" is here defined based on euclidean distance.
|
|
This method will raise an ``AssertionError`` if the exterior contains
|
|
no points.
|
|
|
|
Parameters
|
|
----------
|
|
x : number
|
|
X-coordinate around which to search for close points.
|
|
|
|
y : number
|
|
Y-coordinate around which to search for close points.
|
|
|
|
return_distance : bool, optional
|
|
Whether to also return the distance of the closest point.
|
|
|
|
Returns
|
|
-------
|
|
int
|
|
Index of the closest point.
|
|
|
|
number
|
|
Euclidean distance to the closest point.
|
|
This value is only returned if `return_distance` was set
|
|
to ``True``.
|
|
|
|
"""
|
|
assert len(self.exterior) > 0, (
|
|
"Cannot find the closest point on a polygon which's exterior "
|
|
"contains no points.")
|
|
distances = []
|
|
for x2, y2 in self.exterior:
|
|
dist = (x2 - x) ** 2 + (y2 - y) ** 2
|
|
distances.append(dist)
|
|
distances = np.sqrt(distances)
|
|
closest_idx = np.argmin(distances)
|
|
if return_distance:
|
|
return closest_idx, distances[closest_idx]
|
|
return closest_idx
|
|
|
|
def compute_out_of_image_area(self, image):
|
|
"""Compute the area of the BB that is outside of the image plane.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
image : (H,W,...) ndarray or tuple of int
|
|
Image dimensions to use.
|
|
If an ``ndarray``, its shape will be used.
|
|
If a ``tuple``, it is assumed to represent the image shape
|
|
and must contain at least two integers.
|
|
|
|
Returns
|
|
-------
|
|
float
|
|
Total area of the bounding box that is outside of the image plane.
|
|
Can be ``0.0``.
|
|
|
|
"""
|
|
polys_clipped = self.clip_out_of_image(image)
|
|
if len(polys_clipped) == 0:
|
|
return self.area
|
|
return self.area - sum([poly.area for poly in polys_clipped])
|
|
|
|
def compute_out_of_image_fraction(self, image):
|
|
"""Compute fraction of polygon area outside of the image plane.
|
|
|
|
This estimates ``f = A_ooi / A``, where ``A_ooi`` is the area of the
|
|
polygon that is outside of the image plane, while ``A`` is the
|
|
total area of the bounding box.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
image : (H,W,...) ndarray or tuple of int
|
|
Image dimensions to use.
|
|
If an ``ndarray``, its shape will be used.
|
|
If a ``tuple``, it is assumed to represent the image shape
|
|
and must contain at least two integers.
|
|
|
|
Returns
|
|
-------
|
|
float
|
|
Fraction of the polygon area that is outside of the image
|
|
plane. Returns ``0.0`` if the polygon is fully inside of
|
|
the image plane or has zero points. If the polygon has an area
|
|
of zero, the polygon is treated similarly to a :class:`LineString`,
|
|
i.e. the fraction of the line that is outside the image plane is
|
|
returned.
|
|
|
|
"""
|
|
area = self.area
|
|
if area == 0:
|
|
return self.to_line_string().compute_out_of_image_fraction(image)
|
|
return self.compute_out_of_image_area(image) / area
|
|
|
|
# TODO keep this method? it is almost an alias for is_out_of_image()
|
|
def is_fully_within_image(self, image):
|
|
"""Estimate whether the polygon is fully inside an image plane.
|
|
|
|
Parameters
|
|
----------
|
|
image : (H,W,...) ndarray or tuple of int
|
|
Image dimensions to use.
|
|
If an ``ndarray``, its shape will be used.
|
|
If a ``tuple``, it is assumed to represent the image shape and
|
|
must contain at least two ``int`` s.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
``True`` if the polygon is fully inside the image area.
|
|
``False`` otherwise.
|
|
|
|
"""
|
|
return not self.is_out_of_image(image, fully=True, partly=True)
|
|
|
|
# TODO keep this method? it is almost an alias for is_out_of_image()
|
|
def is_partly_within_image(self, image):
|
|
"""Estimate whether the polygon is at least partially inside an image.
|
|
|
|
Parameters
|
|
----------
|
|
image : (H,W,...) ndarray or tuple of int
|
|
Image dimensions to use.
|
|
If an ``ndarray``, its shape will be used.
|
|
If a ``tuple``, it is assumed to represent the image shape and
|
|
must contain at least two ``int`` s.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
``True`` if the polygon is at least partially inside the image area.
|
|
``False`` otherwise.
|
|
|
|
"""
|
|
return not self.is_out_of_image(image, fully=True, partly=False)
|
|
|
|
def is_out_of_image(self, image, fully=True, partly=False):
|
|
"""Estimate whether the polygon is partially/fully outside of an image.
|
|
|
|
Parameters
|
|
----------
|
|
image : (H,W,...) ndarray or tuple of int
|
|
Image dimensions to use.
|
|
If an ``ndarray``, its shape will be used.
|
|
If a ``tuple``, it is assumed to represent the image shape and
|
|
must contain at least two ``int`` s.
|
|
|
|
fully : bool, optional
|
|
Whether to return ``True`` if the polygon is fully outside of the
|
|
image area.
|
|
|
|
partly : bool, optional
|
|
Whether to return ``True`` if the polygon is at least partially
|
|
outside fo the image area.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
``True`` if the polygon is partially/fully outside of the image
|
|
area, depending on defined parameters.
|
|
``False`` otherwise.
|
|
|
|
"""
|
|
# TODO this is inconsistent with line strings, which return a default
|
|
# value in these cases
|
|
if len(self.exterior) == 0:
|
|
raise Exception("Cannot determine whether the polygon is inside "
|
|
"the image, because it contains no points.")
|
|
|
|
# The line string is identical to the edge of the polygon.
|
|
# If the edge is fully inside the image, we know that the polygon must
|
|
# be fully inside the image.
|
|
# If the edge is partially outside of the image, we know that the
|
|
# polygon is partially outside of the image.
|
|
# Only if the edge is fully outside of the image we cannot be sure if
|
|
# the polygon's inner area overlaps with the image (e.g. if the
|
|
# polygon contains the whole image in it).
|
|
ls = self.to_line_string()
|
|
if ls.is_fully_within_image(image):
|
|
return False
|
|
if ls.is_out_of_image(image, fully=False, partly=True):
|
|
return partly
|
|
|
|
# LS is fully outside of the image. Estimate whether there is any
|
|
# intersection with the image plane. If so, we know that there is
|
|
# partial overlap (full overlap would mean that the LS was fully inside
|
|
# the image).
|
|
polys = self.clip_out_of_image(image)
|
|
if len(polys) > 0:
|
|
return partly
|
|
return fully
|
|
|
|
@ia.deprecated(alt_func="Polygon.clip_out_of_image()",
|
|
comment="clip_out_of_image() has the exactly same "
|
|
"interface.")
|
|
def cut_out_of_image(self, image):
|
|
"""Cut off all parts of the polygon that are outside of an image."""
|
|
return self.clip_out_of_image(image)
|
|
|
|
# TODO this currently can mess up the order of points - change somehow to
|
|
# keep the order
|
|
def clip_out_of_image(self, image):
|
|
"""Cut off all parts of the polygon that are outside of an image.
|
|
|
|
This operation may lead to new points being created.
|
|
As a single polygon may be split into multiple new polygons, the result
|
|
is always a list, which may contain more than one output polygon.
|
|
|
|
This operation will return an empty list if the polygon is completely
|
|
outside of the image plane.
|
|
|
|
Parameters
|
|
----------
|
|
image : (H,W,...) ndarray or tuple of int
|
|
Image dimensions to use for the clipping of the polygon.
|
|
If an ``ndarray``, its shape will be used.
|
|
If a ``tuple``, it is assumed to represent the image shape and must
|
|
contain at least two ``int`` s.
|
|
|
|
Returns
|
|
-------
|
|
list of imgaug.augmentables.polys.Polygon
|
|
Polygon, clipped to fall within the image dimensions.
|
|
Returned as a ``list``, because the clipping can split the polygon
|
|
into multiple parts. The list may also be empty, if the polygon was
|
|
fully outside of the image plane.
|
|
|
|
"""
|
|
# load shapely lazily, which makes the dependency more optional
|
|
import shapely.geometry
|
|
|
|
# Shapely polygon conversion requires at least 3 coordinates
|
|
if len(self.exterior) == 0:
|
|
return []
|
|
if len(self.exterior) in [1, 2]:
|
|
ls = self.to_line_string(closed=False)
|
|
ls_clipped = ls.clip_out_of_image(image)
|
|
assert len(ls_clipped) <= 1
|
|
if len(ls_clipped) == 0:
|
|
return []
|
|
return [self.deepcopy(exterior=ls_clipped[0].coords)]
|
|
|
|
h, w = image.shape[0:2] if ia.is_np_array(image) else image[0:2]
|
|
poly_shapely = self.to_shapely_polygon()
|
|
poly_image = shapely.geometry.Polygon([(0, 0), (w, 0), (w, h), (0, h)])
|
|
multipoly_inter_shapely = poly_shapely.intersection(poly_image)
|
|
ignore_types = (shapely.geometry.LineString,
|
|
shapely.geometry.MultiLineString,
|
|
shapely.geometry.point.Point,
|
|
shapely.geometry.MultiPoint)
|
|
if isinstance(multipoly_inter_shapely, shapely.geometry.Polygon):
|
|
multipoly_inter_shapely = shapely.geometry.MultiPolygon(
|
|
[multipoly_inter_shapely])
|
|
elif isinstance(multipoly_inter_shapely,
|
|
shapely.geometry.MultiPolygon):
|
|
# we got a multipolygon from shapely, no need to change anything
|
|
# anymore
|
|
pass
|
|
elif isinstance(multipoly_inter_shapely, ignore_types):
|
|
# polygons that become (one or more) lines/points after clipping
|
|
# are here ignored
|
|
multipoly_inter_shapely = shapely.geometry.MultiPolygon([])
|
|
elif isinstance(multipoly_inter_shapely,
|
|
shapely.geometry.GeometryCollection):
|
|
# Shapely returns GEOMETRYCOLLECTION EMPTY if there is nothing
|
|
# remaining after the clip.
|
|
assert multipoly_inter_shapely.is_empty
|
|
return []
|
|
else:
|
|
raise Exception(
|
|
"Got an unexpected result of type %s from Shapely for "
|
|
"image (%d, %d) and polygon %s. This is an internal error. "
|
|
"Please report." % (
|
|
type(multipoly_inter_shapely), h, w, self.exterior)
|
|
)
|
|
|
|
polygons = []
|
|
for poly_inter_shapely in multipoly_inter_shapely.geoms:
|
|
polygons.append(Polygon.from_shapely(poly_inter_shapely,
|
|
label=self.label))
|
|
|
|
# Shapely changes the order of points, we try here to preserve it as
|
|
# much as possible.
|
|
# Note here, that all points of the new polygon might have high
|
|
# distance to the points on the old polygon. This can happen if the
|
|
# polygon overlaps with the image plane, but all of its points are
|
|
# outside of the image plane. The new polygon will not be made up of
|
|
# any of the old points.
|
|
polygons_reordered = []
|
|
for polygon in polygons:
|
|
best_idx = None
|
|
best_dist = None
|
|
for x, y in self.exterior:
|
|
point_idx, dist = polygon.find_closest_point_index(
|
|
x=x, y=y, return_distance=True)
|
|
if best_idx is None or dist < best_dist:
|
|
best_idx = point_idx
|
|
best_dist = dist
|
|
if best_idx is not None:
|
|
polygon_reordered = \
|
|
polygon.change_first_point_by_index(best_idx)
|
|
polygons_reordered.append(polygon_reordered)
|
|
|
|
return polygons_reordered
|
|
|
|
def shift_(self, x=0, y=0):
|
|
"""Move this polygon along the x/y-axis in-place.
|
|
|
|
The origin ``(0, 0)`` is at the top left of the image.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
x : number, optional
|
|
Value to be added to all x-coordinates. Positive values shift
|
|
towards the right images.
|
|
|
|
y : number, optional
|
|
Value to be added to all y-coordinates. Positive values shift
|
|
towards the bottom images.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.Polygon
|
|
Shifted polygon.
|
|
The object may have been modified in-place.
|
|
|
|
"""
|
|
self.exterior[:, 0] += x
|
|
self.exterior[:, 1] += y
|
|
return self
|
|
|
|
def shift(self, x=0, y=0, top=None, right=None, bottom=None, left=None):
|
|
"""Move this polygon along the x/y-axis.
|
|
|
|
The origin ``(0, 0)`` is at the top left of the image.
|
|
|
|
Parameters
|
|
----------
|
|
x : number, optional
|
|
Value to be added to all x-coordinates. Positive values shift
|
|
towards the right images.
|
|
|
|
y : number, optional
|
|
Value to be added to all y-coordinates. Positive values shift
|
|
towards the bottom images.
|
|
|
|
top : None or int, optional
|
|
Deprecated since 0.4.0.
|
|
Amount of pixels by which to shift this object *from* the
|
|
top (towards the bottom).
|
|
|
|
right : None or int, optional
|
|
Deprecated since 0.4.0.
|
|
Amount of pixels by which to shift this object *from* the
|
|
right (towards the left).
|
|
|
|
bottom : None or int, optional
|
|
Deprecated since 0.4.0.
|
|
Amount of pixels by which to shift this object *from* the
|
|
bottom (towards the top).
|
|
|
|
left : None or int, optional
|
|
Deprecated since 0.4.0.
|
|
Amount of pixels by which to shift this object *from* the
|
|
left (towards the right).
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.Polygon
|
|
Shifted polygon.
|
|
|
|
"""
|
|
x, y = _normalize_shift_args(
|
|
x, y, top=top, right=right, bottom=bottom, left=left)
|
|
return self.deepcopy().shift_(x=x, y=y)
|
|
|
|
# TODO separate this into draw_face_on_image() and draw_border_on_image()
|
|
# TODO add tests for line thickness
|
|
def draw_on_image(self,
|
|
image,
|
|
color=(0, 255, 0), color_face=None,
|
|
color_lines=None, color_points=None,
|
|
alpha=1.0, alpha_face=None,
|
|
alpha_lines=None, alpha_points=None,
|
|
size=1, size_lines=None, size_points=None,
|
|
raise_if_out_of_image=False):
|
|
"""Draw the polygon on an image.
|
|
|
|
Parameters
|
|
----------
|
|
image : (H,W,C) ndarray
|
|
The image onto which to draw the polygon. Usually expected to be
|
|
of dtype ``uint8``, though other dtypes are also handled.
|
|
|
|
color : iterable of int, optional
|
|
The color to use for the whole polygon.
|
|
Must correspond to the channel layout of the image. Usually RGB.
|
|
The values for `color_face`, `color_lines` and `color_points`
|
|
will be derived from this color if they are set to ``None``.
|
|
This argument has no effect if `color_face`, `color_lines`
|
|
and `color_points` are all set anything other than ``None``.
|
|
|
|
color_face : None or iterable of int, optional
|
|
The color to use for the inner polygon area (excluding perimeter).
|
|
Must correspond to the channel layout of the image. Usually RGB.
|
|
If this is ``None``, it will be derived from ``color * 1.0``.
|
|
|
|
color_lines : None or iterable of int, optional
|
|
The color to use for the line (aka perimeter/border) of the
|
|
polygon.
|
|
Must correspond to the channel layout of the image. Usually RGB.
|
|
If this is ``None``, it will be derived from ``color * 0.5``.
|
|
|
|
color_points : None or iterable of int, optional
|
|
The color to use for the corner points of the polygon.
|
|
Must correspond to the channel layout of the image. Usually RGB.
|
|
If this is ``None``, it will be derived from ``color * 0.5``.
|
|
|
|
alpha : float, optional
|
|
The opacity of the whole polygon, where ``1.0`` denotes a
|
|
completely visible polygon and ``0.0`` an invisible one.
|
|
The values for `alpha_face`, `alpha_lines` and `alpha_points`
|
|
will be derived from this alpha value if they are set to ``None``.
|
|
This argument has no effect if `alpha_face`, `alpha_lines`
|
|
and `alpha_points` are all set anything other than ``None``.
|
|
|
|
alpha_face : None or number, optional
|
|
The opacity of the polygon's inner area (excluding the perimeter),
|
|
where ``1.0`` denotes a completely visible inner area and ``0.0``
|
|
an invisible one.
|
|
If this is ``None``, it will be derived from ``alpha * 0.5``.
|
|
|
|
alpha_lines : None or number, optional
|
|
The opacity of the polygon's line (aka perimeter/border),
|
|
where ``1.0`` denotes a completely visible line and ``0.0`` an
|
|
invisible one.
|
|
If this is ``None``, it will be derived from ``alpha * 1.0``.
|
|
|
|
alpha_points : None or number, optional
|
|
The opacity of the polygon's corner points, where ``1.0`` denotes
|
|
completely visible corners and ``0.0`` invisible ones.
|
|
If this is ``None``, it will be derived from ``alpha * 1.0``.
|
|
|
|
size : int, optional
|
|
Size of the polygon.
|
|
The sizes of the line and points are derived from this value,
|
|
unless they are set.
|
|
|
|
size_lines : None or int, optional
|
|
Thickness of the polygon's line (aka perimeter/border).
|
|
If ``None``, this value is derived from `size`.
|
|
|
|
size_points : int, optional
|
|
Size of the points in pixels.
|
|
If ``None``, this value is derived from ``3 * size``.
|
|
|
|
raise_if_out_of_image : bool, optional
|
|
Whether to raise an error if the polygon is fully
|
|
outside of the image. If set to ``False``, no error will be
|
|
raised and only the parts inside the image will be drawn.
|
|
|
|
Returns
|
|
-------
|
|
(H,W,C) ndarray
|
|
Image with the polygon drawn on it. Result dtype is the same as the
|
|
input dtype.
|
|
|
|
"""
|
|
# pylint: disable=invalid-name
|
|
def _assert_not_none(arg_name, arg_value):
|
|
assert arg_value is not None, (
|
|
"Expected '%s' to not be None, got type %s." % (
|
|
arg_name, type(arg_value),))
|
|
|
|
def _default_to(var, default):
|
|
if var is None:
|
|
return default
|
|
return var
|
|
|
|
_assert_not_none("color", color)
|
|
_assert_not_none("alpha", alpha)
|
|
_assert_not_none("size", size)
|
|
|
|
# FIXME due to the np.array(.) and the assert at ndim==2 below, this
|
|
# will always fail on 2D images?
|
|
color_face = _default_to(color_face, np.array(color))
|
|
color_lines = _default_to(color_lines, np.array(color) * 0.5)
|
|
color_points = _default_to(color_points, np.array(color) * 0.5)
|
|
|
|
alpha_face = _default_to(alpha_face, alpha * 0.5)
|
|
alpha_lines = _default_to(alpha_lines, alpha)
|
|
alpha_points = _default_to(alpha_points, alpha)
|
|
|
|
size_lines = _default_to(size_lines, size)
|
|
size_points = _default_to(size_points, size * 3)
|
|
|
|
if image.ndim == 2:
|
|
assert ia.is_single_number(color_face), (
|
|
"Got a 2D image. Expected then 'color_face' to be a single "
|
|
"number, but got %s." % (str(color_face),))
|
|
color_face = [color_face]
|
|
elif image.ndim == 3 and ia.is_single_number(color_face):
|
|
color_face = [color_face] * image.shape[-1]
|
|
|
|
if alpha_face < 0.01:
|
|
alpha_face = 0
|
|
elif alpha_face > 0.99:
|
|
alpha_face = 1
|
|
|
|
if raise_if_out_of_image and self.is_out_of_image(image):
|
|
raise Exception("Cannot draw polygon %s on image with "
|
|
"shape %s." % (str(self), image.shape))
|
|
|
|
# TODO np.clip to image plane if is_fully_within_image(), similar to
|
|
# how it is done for bounding boxes
|
|
|
|
# TODO improve efficiency by only drawing in rectangle that covers
|
|
# poly instead of drawing in the whole image
|
|
# TODO for a rectangular polygon, the face coordinates include the
|
|
# top/left boundary but not the right/bottom boundary. This may
|
|
# be unintuitive when not drawing the boundary. Maybe somehow
|
|
# remove the boundary coordinates from the face coordinates after
|
|
# generating both?
|
|
input_dtype = image.dtype
|
|
result = image.astype(np.float32)
|
|
rr, cc = skimage.draw.polygon(
|
|
self.yy_int, self.xx_int, shape=image.shape)
|
|
if len(rr) > 0:
|
|
if alpha_face == 1:
|
|
result[rr, cc] = np.float32(color_face)
|
|
elif alpha_face == 0:
|
|
pass
|
|
else:
|
|
result[rr, cc] = (
|
|
(1 - alpha_face) * result[rr, cc, :]
|
|
+ alpha_face * np.float32(color_face)
|
|
)
|
|
|
|
ls_open = self.to_line_string(closed=False)
|
|
ls_closed = self.to_line_string(closed=True)
|
|
result = ls_closed.draw_lines_on_image(
|
|
result, color=color_lines, alpha=alpha_lines,
|
|
size=size_lines, raise_if_out_of_image=raise_if_out_of_image)
|
|
result = ls_open.draw_points_on_image(
|
|
result, color=color_points, alpha=alpha_points,
|
|
size=size_points, raise_if_out_of_image=raise_if_out_of_image)
|
|
|
|
if input_dtype.type == np.uint8:
|
|
# TODO make clipping more flexible
|
|
result = np.clip(np.round(result), 0, 255).astype(input_dtype)
|
|
else:
|
|
result = result.astype(input_dtype)
|
|
|
|
return result
|
|
|
|
# TODO add pad, similar to LineStrings
|
|
# TODO add pad_max, similar to LineStrings
|
|
# TODO add prevent_zero_size, similar to LineStrings
|
|
def extract_from_image(self, image):
|
|
"""Extract all image pixels within the polygon area.
|
|
|
|
This method returns a rectangular image array. All pixels within
|
|
that rectangle that do not belong to the polygon area will be filled
|
|
with zeros (i.e. they will be black).
|
|
The method will also zero-pad the image if the polygon is
|
|
partially/fully outside of the image.
|
|
|
|
Parameters
|
|
----------
|
|
image : (H,W) ndarray or (H,W,C) ndarray
|
|
The image from which to extract the pixels within the polygon.
|
|
|
|
Returns
|
|
-------
|
|
(H',W') ndarray or (H',W',C) ndarray
|
|
Pixels within the polygon. Zero-padded if the polygon is
|
|
partially/fully outside of the image.
|
|
|
|
"""
|
|
assert image.ndim in [2, 3], (
|
|
"Expected image of shape (H,W,[C]), got shape %s." % (
|
|
image.shape,))
|
|
|
|
if len(self.exterior) <= 2:
|
|
raise Exception("Polygon must be made up of at least 3 points to "
|
|
"extract its area from an image.")
|
|
|
|
bb = self.to_bounding_box()
|
|
bb_area = bb.extract_from_image(image)
|
|
if self.is_out_of_image(image, fully=True, partly=False):
|
|
return bb_area
|
|
|
|
xx = self.xx_int
|
|
yy = self.yy_int
|
|
xx_mask = xx - np.min(xx)
|
|
yy_mask = yy - np.min(yy)
|
|
height_mask = np.max(yy_mask)
|
|
width_mask = np.max(xx_mask)
|
|
|
|
rr_face, cc_face = skimage.draw.polygon(
|
|
yy_mask, xx_mask, shape=(height_mask, width_mask))
|
|
|
|
mask = np.zeros((height_mask, width_mask), dtype=np.bool)
|
|
mask[rr_face, cc_face] = True
|
|
|
|
if image.ndim == 3:
|
|
mask = np.tile(mask[:, :, np.newaxis], (1, 1, image.shape[2]))
|
|
|
|
return bb_area * mask
|
|
|
|
def change_first_point_by_coords(self, x, y, max_distance=1e-4,
|
|
raise_if_too_far_away=True):
|
|
"""
|
|
Reorder exterior points so that the point closest to given x/y is first.
|
|
|
|
This method takes a given ``(x,y)`` coordinate, finds the closest
|
|
corner point on the exterior and reorders all exterior corner points
|
|
so that the found point becomes the first one in the array.
|
|
|
|
If no matching points are found, an exception is raised.
|
|
|
|
Parameters
|
|
----------
|
|
x : number
|
|
X-coordinate of the point.
|
|
|
|
y : number
|
|
Y-coordinate of the point.
|
|
|
|
max_distance : None or number, optional
|
|
Maximum distance past which possible matches are ignored.
|
|
If ``None`` the distance limit is deactivated.
|
|
|
|
raise_if_too_far_away : bool, optional
|
|
Whether to raise an exception if the closest found point is too
|
|
far away (``True``) or simply return an unchanged copy if this
|
|
object (``False``).
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.Polygon
|
|
Copy of this polygon with the new point order.
|
|
|
|
"""
|
|
if len(self.exterior) == 0:
|
|
raise Exception("Cannot reorder polygon points, because it "
|
|
"contains no points.")
|
|
|
|
closest_idx, closest_dist = self.find_closest_point_index(
|
|
x=x, y=y, return_distance=True)
|
|
if max_distance is not None and closest_dist > max_distance:
|
|
if not raise_if_too_far_away:
|
|
return self.deepcopy()
|
|
|
|
closest_point = self.exterior[closest_idx, :]
|
|
raise Exception(
|
|
"Closest found point (%.9f, %.9f) exceeds max_distance of "
|
|
"%.9f exceeded" % (
|
|
closest_point[0], closest_point[1], closest_dist))
|
|
return self.change_first_point_by_index(closest_idx)
|
|
|
|
def change_first_point_by_index(self, point_idx):
|
|
"""
|
|
Reorder exterior points so that the point with given index is first.
|
|
|
|
This method takes a given index and reorders all exterior corner points
|
|
so that the point with that index becomes the first one in the array.
|
|
|
|
An ``AssertionError`` will be raised if the index does not match
|
|
any exterior point's index or the exterior does not contain any points.
|
|
|
|
Parameters
|
|
----------
|
|
point_idx : int
|
|
Index of the desired starting point.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.Polygon
|
|
Copy of this polygon with the new point order.
|
|
|
|
"""
|
|
assert 0 <= point_idx < len(self.exterior), (
|
|
"Expected index of new first point to be in the discrete interval "
|
|
"[0..%d). Got index %d." % (len(self.exterior), point_idx))
|
|
|
|
if point_idx == 0:
|
|
return self.deepcopy()
|
|
exterior = np.concatenate(
|
|
(self.exterior[point_idx:, :], self.exterior[:point_idx, :]),
|
|
axis=0
|
|
)
|
|
return self.deepcopy(exterior=exterior)
|
|
|
|
def subdivide_(self, points_per_edge):
|
|
"""Derive a new poly with ``N`` interpolated points per edge in-place.
|
|
|
|
See :func:`~imgaug.augmentables.lines.LineString.subdivide` for details.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
points_per_edge : int
|
|
Number of points to interpolate on each edge.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.Polygon
|
|
Polygon with subdivided edges.
|
|
The object may have been modified in-place.
|
|
|
|
"""
|
|
if len(self.exterior) == 1:
|
|
return self
|
|
ls = self.to_line_string(closed=True)
|
|
ls_sub = ls.subdivide(points_per_edge)
|
|
# [:-1] even works if the polygon contains zero points
|
|
exterior_subdivided = ls_sub.coords[:-1]
|
|
self.exterior = exterior_subdivided
|
|
return self
|
|
|
|
def subdivide(self, points_per_edge):
|
|
"""Derive a new polygon with ``N`` interpolated points per edge.
|
|
|
|
See :func:`~imgaug.augmentables.lines.LineString.subdivide` for details.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
points_per_edge : int
|
|
Number of points to interpolate on each edge.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.Polygon
|
|
Polygon with subdivided edges.
|
|
|
|
"""
|
|
return self.deepcopy().subdivide_(points_per_edge)
|
|
|
|
def to_shapely_polygon(self):
|
|
"""Convert this polygon to a ``Shapely`` ``Polygon``.
|
|
|
|
Returns
|
|
-------
|
|
shapely.geometry.Polygon
|
|
The ``Shapely`` ``Polygon`` matching this polygon's exterior.
|
|
|
|
"""
|
|
# load shapely lazily, which makes the dependency more optional
|
|
import shapely.geometry
|
|
|
|
return shapely.geometry.Polygon(
|
|
[(point[0], point[1]) for point in self.exterior])
|
|
|
|
def to_shapely_line_string(self, closed=False, interpolate=0):
|
|
"""Convert this polygon to a ``Shapely`` ``LineString`` object.
|
|
|
|
Parameters
|
|
----------
|
|
closed : bool, optional
|
|
Whether to return the line string with the last point being
|
|
identical to the first point.
|
|
|
|
interpolate : int, optional
|
|
Number of points to interpolate between any pair of two
|
|
consecutive points. These points are added to the final line string.
|
|
|
|
Returns
|
|
-------
|
|
shapely.geometry.LineString
|
|
The ``Shapely`` ``LineString`` matching the polygon's exterior.
|
|
|
|
"""
|
|
return _convert_points_to_shapely_line_string(
|
|
self.exterior, closed=closed, interpolate=interpolate)
|
|
|
|
def to_bounding_box(self):
|
|
"""Convert this polygon to a bounding box containing the polygon.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.bbs.BoundingBox
|
|
Bounding box that tightly encapsulates the polygon.
|
|
|
|
"""
|
|
# TODO get rid of this deferred import
|
|
from imgaug.augmentables.bbs import BoundingBox
|
|
|
|
xx = self.xx
|
|
yy = self.yy
|
|
return BoundingBox(x1=min(xx), x2=max(xx),
|
|
y1=min(yy), y2=max(yy),
|
|
label=self.label)
|
|
|
|
def to_keypoints(self):
|
|
"""Convert this polygon's exterior to ``Keypoint`` instances.
|
|
|
|
Returns
|
|
-------
|
|
list of imgaug.augmentables.kps.Keypoint
|
|
Exterior vertices as :class:`~imgaug.augmentables.kps.Keypoint`
|
|
instances.
|
|
|
|
"""
|
|
# TODO get rid of this deferred import
|
|
from imgaug.augmentables.kps import Keypoint
|
|
|
|
return [Keypoint(x=point[0], y=point[1]) for point in self.exterior]
|
|
|
|
def to_line_string(self, closed=True):
|
|
"""Convert this polygon's exterior to a ``LineString`` instance.
|
|
|
|
Parameters
|
|
----------
|
|
closed : bool, optional
|
|
Whether to close the line string, i.e. to add the first point of
|
|
the `exterior` also as the last point at the end of the line string.
|
|
This has no effect if the polygon has a single point or zero
|
|
points.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.lines.LineString
|
|
Exterior of the polygon as a line string.
|
|
|
|
"""
|
|
from imgaug.augmentables.lines import LineString
|
|
if not closed or len(self.exterior) <= 1:
|
|
return LineString(self.exterior, label=self.label)
|
|
return LineString(
|
|
np.concatenate([self.exterior, self.exterior[0:1, :]], axis=0),
|
|
label=self.label)
|
|
|
|
@staticmethod
|
|
def from_shapely(polygon_shapely, label=None):
|
|
"""Create a polygon from a ``Shapely`` ``Polygon``.
|
|
|
|
.. note::
|
|
|
|
This will remove any holes in the shapely polygon.
|
|
|
|
Parameters
|
|
----------
|
|
polygon_shapely : shapely.geometry.Polygon
|
|
The shapely polygon.
|
|
|
|
label : None or str, optional
|
|
The label of the new polygon.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.Polygon
|
|
A polygon with the same exterior as the ``Shapely`` ``Polygon``.
|
|
|
|
"""
|
|
# load shapely lazily, which makes the dependency more optional
|
|
import shapely.geometry
|
|
|
|
assert isinstance(polygon_shapely, shapely.geometry.Polygon), (
|
|
"Expected the input to be a shapely.geometry.Polgon instance. "
|
|
"Got %s." % (type(polygon_shapely),))
|
|
# polygon_shapely.exterior can be None if the polygon was
|
|
# instantiated without points
|
|
has_no_exterior = (
|
|
polygon_shapely.exterior is None
|
|
or len(polygon_shapely.exterior.coords) == 0)
|
|
if has_no_exterior:
|
|
return Polygon([], label=label)
|
|
exterior = np.float32([[x, y]
|
|
for (x, y)
|
|
in polygon_shapely.exterior.coords])
|
|
return Polygon(exterior, label=label)
|
|
|
|
def coords_almost_equals(self, other, max_distance=1e-4,
|
|
points_per_edge=8):
|
|
"""Alias for :func:`Polygon.exterior_almost_equals`.
|
|
|
|
Parameters
|
|
----------
|
|
other : imgaug.augmentables.polys.Polygon or (N,2) ndarray or list of tuple
|
|
See
|
|
:func:`~imgaug.augmentables.polys.Polygon.exterior_almost_equals`.
|
|
|
|
max_distance : number, optional
|
|
See
|
|
:func:`~imgaug.augmentables.polys.Polygon.exterior_almost_equals`.
|
|
|
|
points_per_edge : int, optional
|
|
See
|
|
:func:`~imgaug.augmentables.polys.Polygon.exterior_almost_equals`.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
Whether the two polygon's exteriors can be viewed as equal
|
|
(approximate test).
|
|
|
|
"""
|
|
return self.exterior_almost_equals(
|
|
other, max_distance=max_distance, points_per_edge=points_per_edge)
|
|
|
|
def exterior_almost_equals(self, other, max_distance=1e-4,
|
|
points_per_edge=8):
|
|
"""Estimate if this and another polygon's exterior are almost identical.
|
|
|
|
The two exteriors can have different numbers of points, but any point
|
|
randomly sampled on the exterior of one polygon should be close to the
|
|
closest point on the exterior of the other polygon.
|
|
|
|
.. note::
|
|
|
|
This method works in an approximative way. One can come up with
|
|
polygons with fairly different shapes that will still be estimated
|
|
as equal by this method. In practice however this should be
|
|
unlikely to be the case. The probability for something like that
|
|
goes down as the interpolation parameter is increased.
|
|
|
|
Parameters
|
|
----------
|
|
other : imgaug.augmentables.polys.Polygon or (N,2) ndarray or list of tuple
|
|
The other polygon with which to compare the exterior.
|
|
If this is an ``ndarray``, it is assumed to represent an exterior.
|
|
It must then have dtype ``float32`` and shape ``(N,2)`` with the
|
|
second dimension denoting xy-coordinates.
|
|
If this is a ``list`` of ``tuple`` s, it is assumed to represent
|
|
an exterior. Each tuple then must contain exactly two ``number`` s,
|
|
denoting xy-coordinates.
|
|
|
|
max_distance : number, optional
|
|
The maximum euclidean distance between a point on one polygon and
|
|
the closest point on the other polygon. If the distance is exceeded
|
|
for any such pair, the two exteriors are not viewed as equal. The
|
|
points are either the points contained in the polygon's exterior
|
|
ndarray or interpolated points between these.
|
|
|
|
points_per_edge : int, optional
|
|
How many points to interpolate on each edge.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
Whether the two polygon's exteriors can be viewed as equal
|
|
(approximate test).
|
|
|
|
"""
|
|
if isinstance(other, list):
|
|
other = Polygon(np.float32(other))
|
|
elif ia.is_np_array(other):
|
|
other = Polygon(other)
|
|
else:
|
|
assert isinstance(other, Polygon), (
|
|
"Expected 'other' to be a list of coordinates, a coordinate "
|
|
"array or a single Polygon. Got type %s." % (type(other),))
|
|
|
|
return self.to_line_string(closed=True).coords_almost_equals(
|
|
other.to_line_string(closed=True),
|
|
max_distance=max_distance,
|
|
points_per_edge=points_per_edge
|
|
)
|
|
|
|
def almost_equals(self, other, max_distance=1e-4, points_per_edge=8):
|
|
"""
|
|
Estimate if this polygon's and another's geometry/labels are similar.
|
|
|
|
This is the same as
|
|
:func:`~imgaug.augmentables.polys.Polygon.exterior_almost_equals` but
|
|
additionally compares the labels.
|
|
|
|
Parameters
|
|
----------
|
|
other : imgaug.augmentables.polys.Polygon
|
|
The other object to compare against. Expected to be a ``Polygon``.
|
|
|
|
max_distance : float, optional
|
|
See
|
|
:func:`~imgaug.augmentables.polys.Polygon.exterior_almost_equals`.
|
|
|
|
points_per_edge : int, optional
|
|
See
|
|
:func:`~imgaug.augmentables.polys.Polygon.exterior_almost_equals`.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
``True`` if the coordinates are almost equal and additionally
|
|
the labels are equal. Otherwise ``False``.
|
|
|
|
"""
|
|
if self.label != other.label:
|
|
return False
|
|
return self.exterior_almost_equals(
|
|
other, max_distance=max_distance, points_per_edge=points_per_edge)
|
|
|
|
def copy(self, exterior=None, label=None):
|
|
"""Create a shallow copy of this object.
|
|
|
|
Parameters
|
|
----------
|
|
exterior : list of imgaug.augmentables.kps.Keypoint or list of tuple or (N,2) ndarray, optional
|
|
List of points defining the polygon. See
|
|
:func:`~imgaug.augmentables.polys.Polygon.__init__` for details.
|
|
|
|
label : None or str, optional
|
|
If not ``None``, the ``label`` of the copied object will be set
|
|
to this value.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.Polygon
|
|
Shallow copy.
|
|
|
|
"""
|
|
return self.deepcopy(exterior=exterior, label=label)
|
|
|
|
def deepcopy(self, exterior=None, label=None):
|
|
"""Create a deep copy of this object.
|
|
|
|
Parameters
|
|
----------
|
|
exterior : list of Keypoint or list of tuple or (N,2) ndarray, optional
|
|
List of points defining the polygon. See
|
|
`imgaug.augmentables.polys.Polygon.__init__` for details.
|
|
|
|
label : None or str
|
|
If not ``None``, the ``label`` of the copied object will be set
|
|
to this value.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.Polygon
|
|
Deep copy.
|
|
|
|
"""
|
|
return Polygon(
|
|
exterior=np.copy(self.exterior) if exterior is None else exterior,
|
|
label=self.label if label is None else label)
|
|
|
|
def __getitem__(self, indices):
|
|
"""Get the coordinate(s) with given indices.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Returns
|
|
-------
|
|
ndarray
|
|
xy-coordinate(s) as ``ndarray``.
|
|
|
|
"""
|
|
return self.exterior[indices]
|
|
|
|
def __iter__(self):
|
|
"""Iterate over the coordinates of this instance.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Yields
|
|
------
|
|
ndarray
|
|
An ``(2,)`` ``ndarray`` denoting an xy-coordinate pair.
|
|
|
|
"""
|
|
return iter(self.exterior)
|
|
|
|
def __repr__(self):
|
|
return self.__str__()
|
|
|
|
def __str__(self):
|
|
points_str = ", ".join([
|
|
"(x=%.3f, y=%.3f)" % (point[0], point[1])
|
|
for point
|
|
in self.exterior])
|
|
return "Polygon([%s] (%d points), label=%s)" % (
|
|
points_str, len(self.exterior), self.label)
|
|
|
|
|
|
# TODO add tests for this
|
|
class PolygonsOnImage(IAugmentable):
|
|
"""Container for all polygons on a single image.
|
|
|
|
Parameters
|
|
----------
|
|
polygons : list of imgaug.augmentables.polys.Polygon
|
|
List of polygons on the image.
|
|
|
|
shape : tuple of int
|
|
The shape of the image on which the objects are placed, i.e. the
|
|
result of ``image.shape``.
|
|
Should include the number of channels, not only height and width.
|
|
|
|
Examples
|
|
--------
|
|
>>> import numpy as np
|
|
>>> from imgaug.augmentables.polys import Polygon, PolygonsOnImage
|
|
>>> image = np.zeros((100, 100))
|
|
>>> polys = [
|
|
>>> Polygon([(0.5, 0.5), (100.5, 0.5), (100.5, 100.5), (0.5, 100.5)]),
|
|
>>> Polygon([(50.5, 0.5), (100.5, 50.5), (50.5, 100.5), (0.5, 50.5)])
|
|
>>> ]
|
|
>>> polys_oi = PolygonsOnImage(polys, shape=image.shape)
|
|
|
|
"""
|
|
|
|
def __init__(self, polygons, shape):
|
|
self.polygons = polygons
|
|
self.shape = _handle_on_image_shape(shape, self)
|
|
|
|
@property
|
|
def items(self):
|
|
"""Get the polygons in this container.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Returns
|
|
-------
|
|
list of Polygon
|
|
Polygons within this container.
|
|
|
|
"""
|
|
return self.polygons
|
|
|
|
@items.setter
|
|
def items(self, value):
|
|
"""Set the polygons in this container.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
value : list of Polygon
|
|
Polygons within this container.
|
|
|
|
"""
|
|
self.polygons = value
|
|
|
|
@property
|
|
def empty(self):
|
|
"""Estimate whether this object contains zero polygons.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
``True`` if this object contains zero polygons.
|
|
|
|
"""
|
|
return len(self.polygons) == 0
|
|
|
|
def on_(self, image):
|
|
"""Project all polygons from one image shape to a new one in-place.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
image : ndarray or tuple of int
|
|
New image onto which the polygons are to be projected.
|
|
May also simply be that new image's shape ``tuple``.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Object containing all projected polygons.
|
|
The object and its items may have been modified in-place.
|
|
|
|
"""
|
|
# pylint: disable=invalid-name
|
|
on_shape = normalize_imglike_shape(image)
|
|
if on_shape[0:2] == self.shape[0:2]:
|
|
self.shape = on_shape # channels may differ
|
|
return self
|
|
|
|
for i, item in enumerate(self.items):
|
|
self.polygons[i] = item.project_(self.shape, on_shape)
|
|
self.shape = on_shape
|
|
return self
|
|
|
|
def on(self, image):
|
|
"""Project all polygons from one image shape to a new one.
|
|
|
|
Parameters
|
|
----------
|
|
image : ndarray or tuple of int
|
|
New image onto which the polygons are to be projected.
|
|
May also simply be that new image's shape ``tuple``.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Object containing all projected polygons.
|
|
|
|
"""
|
|
# pylint: disable=invalid-name
|
|
return self.deepcopy().on_(image)
|
|
|
|
def draw_on_image(self,
|
|
image,
|
|
color=(0, 255, 0), color_face=None,
|
|
color_lines=None, color_points=None,
|
|
alpha=1.0, alpha_face=None,
|
|
alpha_lines=None, alpha_points=None,
|
|
size=1, size_lines=None, size_points=None,
|
|
raise_if_out_of_image=False):
|
|
"""Draw all polygons onto a given image.
|
|
|
|
Parameters
|
|
----------
|
|
image : (H,W,C) ndarray
|
|
The image onto which to draw the bounding boxes.
|
|
This image should usually have the same shape as set in
|
|
``PolygonsOnImage.shape``.
|
|
|
|
color : iterable of int, optional
|
|
The color to use for the whole polygons.
|
|
Must correspond to the channel layout of the image. Usually RGB.
|
|
The values for `color_face`, `color_lines` and `color_points`
|
|
will be derived from this color if they are set to ``None``.
|
|
This argument has no effect if `color_face`, `color_lines`
|
|
and `color_points` are all set anything other than ``None``.
|
|
|
|
color_face : None or iterable of int, optional
|
|
The color to use for the inner polygon areas (excluding perimeters).
|
|
Must correspond to the channel layout of the image. Usually RGB.
|
|
If this is ``None``, it will be derived from ``color * 1.0``.
|
|
|
|
color_lines : None or iterable of int, optional
|
|
The color to use for the lines (aka perimeters/borders) of the
|
|
polygons. Must correspond to the channel layout of the image.
|
|
Usually RGB. If this is ``None``, it will be derived
|
|
from ``color * 0.5``.
|
|
|
|
color_points : None or iterable of int, optional
|
|
The color to use for the corner points of the polygons.
|
|
Must correspond to the channel layout of the image. Usually RGB.
|
|
If this is ``None``, it will be derived from ``color * 0.5``.
|
|
|
|
alpha : float, optional
|
|
The opacity of the whole polygons, where ``1.0`` denotes
|
|
completely visible polygons and ``0.0`` invisible ones.
|
|
The values for `alpha_face`, `alpha_lines` and `alpha_points`
|
|
will be derived from this alpha value if they are set to ``None``.
|
|
This argument has no effect if `alpha_face`, `alpha_lines`
|
|
and `alpha_points` are all set anything other than ``None``.
|
|
|
|
alpha_face : None or number, optional
|
|
The opacity of the polygon's inner areas (excluding the perimeters),
|
|
where ``1.0`` denotes completely visible inner areas and ``0.0``
|
|
invisible ones.
|
|
If this is ``None``, it will be derived from ``alpha * 0.5``.
|
|
|
|
alpha_lines : None or number, optional
|
|
The opacity of the polygon's lines (aka perimeters/borders),
|
|
where ``1.0`` denotes completely visible perimeters and ``0.0``
|
|
invisible ones.
|
|
If this is ``None``, it will be derived from ``alpha * 1.0``.
|
|
|
|
alpha_points : None or number, optional
|
|
The opacity of the polygon's corner points, where ``1.0`` denotes
|
|
completely visible corners and ``0.0`` invisible ones.
|
|
Currently this is an on/off choice, i.e. only ``0.0`` or ``1.0``
|
|
are allowed.
|
|
If this is ``None``, it will be derived from ``alpha * 1.0``.
|
|
|
|
size : int, optional
|
|
Size of the polygons.
|
|
The sizes of the line and points are derived from this value,
|
|
unless they are set.
|
|
|
|
size_lines : None or int, optional
|
|
Thickness of the polygon lines (aka perimeter/border).
|
|
If ``None``, this value is derived from `size`.
|
|
|
|
size_points : int, optional
|
|
The size of all corner points. If set to ``C``, each corner point
|
|
will be drawn as a square of size ``C x C``.
|
|
|
|
raise_if_out_of_image : bool, optional
|
|
Whether to raise an error if any polygon is fully
|
|
outside of the image. If set to False, no error will be raised and
|
|
only the parts inside the image will be drawn.
|
|
|
|
Returns
|
|
-------
|
|
(H,W,C) ndarray
|
|
Image with drawn polygons.
|
|
|
|
"""
|
|
for poly in self.polygons:
|
|
image = poly.draw_on_image(
|
|
image,
|
|
color=color,
|
|
color_face=color_face,
|
|
color_lines=color_lines,
|
|
color_points=color_points,
|
|
alpha=alpha,
|
|
alpha_face=alpha_face,
|
|
alpha_lines=alpha_lines,
|
|
alpha_points=alpha_points,
|
|
size=size,
|
|
size_lines=size_lines,
|
|
size_points=size_points,
|
|
raise_if_out_of_image=raise_if_out_of_image
|
|
)
|
|
return image
|
|
|
|
def remove_out_of_image_(self, fully=True, partly=False):
|
|
"""Remove all polygons that are fully/partially OOI in-place.
|
|
|
|
'OOI' is the abbreviation for 'out of image'.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
fully : bool, optional
|
|
Whether to remove polygons that are fully outside of the image.
|
|
|
|
partly : bool, optional
|
|
Whether to remove polygons that are partially outside of the image.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Reduced set of polygons. Those that are fully/partially
|
|
outside of the given image plane are removed.
|
|
The object and its items may have been modified in-place.
|
|
|
|
"""
|
|
self.polygons = [
|
|
poly for poly in self.polygons
|
|
if not poly.is_out_of_image(self.shape, fully=fully, partly=partly)
|
|
]
|
|
return self
|
|
|
|
def remove_out_of_image(self, fully=True, partly=False):
|
|
"""Remove all polygons that are fully/partially outside of an image.
|
|
|
|
Parameters
|
|
----------
|
|
fully : bool, optional
|
|
Whether to remove polygons that are fully outside of the image.
|
|
|
|
partly : bool, optional
|
|
Whether to remove polygons that are partially outside of the image.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Reduced set of polygons. Those that are fully/partially
|
|
outside of the given image plane are removed.
|
|
|
|
"""
|
|
return self.deepcopy().remove_out_of_image_(fully, partly)
|
|
|
|
def remove_out_of_image_fraction_(self, fraction):
|
|
"""Remove all Polys with an OOI fraction of ``>=fraction`` in-place.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
fraction : number
|
|
Minimum out of image fraction that a polygon has to have in
|
|
order to be removed. A fraction of ``1.0`` removes only polygons
|
|
that are ``100%`` outside of the image. A fraction of ``0.0``
|
|
removes all polygons.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Reduced set of polygons, with those that had an out of image
|
|
fraction greater or equal the given one removed.
|
|
The object and its items may have been modified in-place.
|
|
|
|
"""
|
|
return _remove_out_of_image_fraction_(self, fraction)
|
|
|
|
def remove_out_of_image_fraction(self, fraction):
|
|
"""Remove all Polys with an out of image fraction of ``>=fraction``.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
fraction : number
|
|
Minimum out of image fraction that a polygon has to have in
|
|
order to be removed. A fraction of ``1.0`` removes only polygons
|
|
that are ``100%`` outside of the image. A fraction of ``0.0``
|
|
removes all polygons.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Reduced set of polygons, with those that had an out of image
|
|
fraction greater or equal the given one removed.
|
|
|
|
"""
|
|
return self.copy().remove_out_of_image_fraction_(fraction)
|
|
|
|
def clip_out_of_image_(self):
|
|
"""Clip off all parts from all polygons that are OOI in-place.
|
|
|
|
'OOI' is the abbreviation for 'out of image'.
|
|
|
|
.. note::
|
|
|
|
The result can contain fewer polygons than the input did. That
|
|
happens when a polygon is fully outside of the image plane.
|
|
|
|
.. note::
|
|
|
|
The result can also contain *more* polygons than the input
|
|
did. That happens when distinct parts of a polygon are only
|
|
connected by areas that are outside of the image plane and hence
|
|
will be clipped off, resulting in two or more unconnected polygon
|
|
parts that are left in the image plane.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Polygons, clipped to fall within the image dimensions.
|
|
The count of output polygons may differ from the input count.
|
|
The object and its items may have been modified in-place.
|
|
|
|
"""
|
|
self.polygons = [
|
|
poly_clipped
|
|
for poly in self.polygons
|
|
for poly_clipped in poly.clip_out_of_image(self.shape)]
|
|
return self
|
|
|
|
def clip_out_of_image(self):
|
|
"""Clip off all parts from all polygons that are outside of an image.
|
|
|
|
.. note::
|
|
|
|
The result can contain fewer polygons than the input did. That
|
|
happens when a polygon is fully outside of the image plane.
|
|
|
|
.. note::
|
|
|
|
The result can also contain *more* polygons than the input
|
|
did. That happens when distinct parts of a polygon are only
|
|
connected by areas that are outside of the image plane and hence
|
|
will be clipped off, resulting in two or more unconnected polygon
|
|
parts that are left in the image plane.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Polygons, clipped to fall within the image dimensions.
|
|
The count of output polygons may differ from the input count.
|
|
|
|
"""
|
|
return self.copy().clip_out_of_image_()
|
|
|
|
def shift_(self, x=0, y=0):
|
|
"""Move the polygons along the x/y-axis in-place.
|
|
|
|
The origin ``(0, 0)`` is at the top left of the image.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
x : number, optional
|
|
Value to be added to all x-coordinates. Positive values shift
|
|
towards the right images.
|
|
|
|
y : number, optional
|
|
Value to be added to all y-coordinates. Positive values shift
|
|
towards the bottom images.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Shifted polygons.
|
|
|
|
"""
|
|
for i, poly in enumerate(self.polygons):
|
|
self.polygons[i] = poly.shift_(x=x, y=y)
|
|
return self
|
|
|
|
def shift(self, x=0, y=0, top=None, right=None, bottom=None, left=None):
|
|
"""Move the polygons along the x/y-axis.
|
|
|
|
The origin ``(0, 0)`` is at the top left of the image.
|
|
|
|
Parameters
|
|
----------
|
|
x : number, optional
|
|
Value to be added to all x-coordinates. Positive values shift
|
|
towards the right images.
|
|
|
|
y : number, optional
|
|
Value to be added to all y-coordinates. Positive values shift
|
|
towards the bottom images.
|
|
|
|
top : None or int, optional
|
|
Deprecated since 0.4.0.
|
|
Amount of pixels by which to shift all objects *from* the
|
|
top (towards the bottom).
|
|
|
|
right : None or int, optional
|
|
Deprecated since 0.4.0.
|
|
Amount of pixels by which to shift all objects *from* the
|
|
right (towads the left).
|
|
|
|
bottom : None or int, optional
|
|
Deprecated since 0.4.0.
|
|
Amount of pixels by which to shift all objects *from* the
|
|
bottom (towards the top).
|
|
|
|
left : None or int, optional
|
|
Deprecated since 0.4.0.
|
|
Amount of pixels by which to shift all objects *from* the
|
|
left (towards the right).
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Shifted polygons.
|
|
|
|
"""
|
|
x, y = _normalize_shift_args(
|
|
x, y, top=top, right=right, bottom=bottom, left=left)
|
|
return self.deepcopy().shift_(x=x, y=y)
|
|
|
|
def subdivide_(self, points_per_edge):
|
|
"""Interpolate ``N`` points on each polygon.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
points_per_edge : int
|
|
Number of points to interpolate on each edge.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Subdivided polygons.
|
|
|
|
"""
|
|
for i, poly in enumerate(self.polygons):
|
|
self.polygons[i] = poly.subdivide_(points_per_edge)
|
|
return self
|
|
|
|
def subdivide(self, points_per_edge):
|
|
"""Interpolate ``N`` points on each polygon.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
points_per_edge : int
|
|
Number of points to interpolate on each edge.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Subdivided polygons.
|
|
|
|
"""
|
|
return self.deepcopy().subdivide_(points_per_edge)
|
|
|
|
def to_xy_array(self):
|
|
"""Convert all polygon coordinates to one array of shape ``(N,2)``.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Returns
|
|
-------
|
|
(N, 2) ndarray
|
|
Array containing all xy-coordinates of all polygons within this
|
|
instance.
|
|
|
|
"""
|
|
if self.empty:
|
|
return np.zeros((0, 2), dtype=np.float32)
|
|
return np.concatenate([poly.exterior for poly in self.polygons])
|
|
|
|
def fill_from_xy_array_(self, xy):
|
|
"""Modify the corner coordinates of all polygons in-place.
|
|
|
|
.. note::
|
|
|
|
This currently expects that `xy` contains exactly as many
|
|
coordinates as the polygons within this instance have corner
|
|
points. Otherwise, an ``AssertionError`` will be raised.
|
|
|
|
.. warning::
|
|
|
|
This does not validate the new coordinates or repair the resulting
|
|
polygons. If bad coordinates are provided, the result will be
|
|
invalid polygons (e.g. self-intersections).
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
xy : (N, 2) ndarray or iterable of iterable of number
|
|
XY-Coordinates of ``N`` corner points. ``N`` must match the
|
|
number of corner points in all polygons within this instance.
|
|
|
|
Returns
|
|
-------
|
|
PolygonsOnImage
|
|
This instance itself, with updated coordinates.
|
|
Note that the instance was modified in-place.
|
|
|
|
"""
|
|
xy = np.array(xy, dtype=np.float32)
|
|
|
|
# note that np.array([]) is (0,), not (0, 2)
|
|
assert xy.shape[0] == 0 or (xy.ndim == 2 and xy.shape[-1] == 2), ( # pylint: disable=unsubscriptable-object
|
|
"Expected input array to have shape (N,2), "
|
|
"got shape %s." % (xy.shape,))
|
|
|
|
counter = 0
|
|
for poly in self.polygons:
|
|
nb_points = len(poly.exterior)
|
|
assert counter + nb_points <= len(xy), (
|
|
"Received fewer points than there are corner points in the "
|
|
"exteriors of all polygons. Got %d points, expected %d." % (
|
|
len(xy), sum([len(p.exterior) for p in self.polygons])))
|
|
|
|
poly.exterior[:, ...] = xy[counter:counter+nb_points]
|
|
counter += nb_points
|
|
|
|
assert counter == len(xy), (
|
|
"Expected to get exactly as many xy-coordinates as there are "
|
|
"points in the exteriors of all polygons within this instance. "
|
|
"Got %d points, could only assign %d points." % (
|
|
len(xy), counter,))
|
|
|
|
return self
|
|
|
|
def to_keypoints_on_image(self):
|
|
"""Convert the polygons to one ``KeypointsOnImage`` instance.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.kps.KeypointsOnImage
|
|
A keypoints instance containing ``N`` coordinates for a total
|
|
of ``N`` points in all exteriors of the polygons within this
|
|
container. Order matches the order in ``polygons``.
|
|
|
|
"""
|
|
from . import KeypointsOnImage
|
|
if self.empty:
|
|
return KeypointsOnImage([], shape=self.shape)
|
|
exteriors = np.concatenate(
|
|
[poly.exterior for poly in self.polygons],
|
|
axis=0)
|
|
return KeypointsOnImage.from_xy_array(exteriors, shape=self.shape)
|
|
|
|
def invert_to_keypoints_on_image_(self, kpsoi):
|
|
"""Invert the output of ``to_keypoints_on_image()`` in-place.
|
|
|
|
This function writes in-place into this ``PolygonsOnImage``
|
|
instance.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Parameters
|
|
----------
|
|
kpsoi : imgaug.augmentables.kps.KeypointsOnImages
|
|
Keypoints to convert back to polygons, i.e. the outputs
|
|
of ``to_keypoints_on_image()``.
|
|
|
|
Returns
|
|
-------
|
|
PolygonsOnImage
|
|
Polygons container with updated coordinates.
|
|
Note that the instance is also updated in-place.
|
|
|
|
"""
|
|
polys = self.polygons
|
|
exteriors = [poly.exterior for poly in polys]
|
|
nb_points_exp = sum([len(exterior) for exterior in exteriors])
|
|
assert len(kpsoi.keypoints) == nb_points_exp, (
|
|
"Expected %d coordinates, got %d." % (
|
|
nb_points_exp, len(kpsoi.keypoints)))
|
|
|
|
xy_arr = kpsoi.to_xy_array()
|
|
|
|
counter = 0
|
|
for poly in polys:
|
|
exterior = poly.exterior
|
|
exterior[:, :] = xy_arr[counter:counter+len(exterior), :]
|
|
counter += len(exterior)
|
|
self.shape = kpsoi.shape
|
|
return self
|
|
|
|
def copy(self, polygons=None, shape=None):
|
|
"""Create a shallow copy of this object.
|
|
|
|
Parameters
|
|
----------
|
|
polygons : None or list of imgaug.augmentables.polys.Polygons, optional
|
|
List of polygons on the image.
|
|
If not ``None``, then the ``polygons`` attribute of the copied
|
|
object will be set to this value.
|
|
|
|
shape : None or tuple of int or ndarray, optional
|
|
The shape of the image on which the objects are placed.
|
|
Either an image with shape ``(H,W,[C])`` or a tuple denoting
|
|
such an image shape.
|
|
If not ``None``, then the ``shape`` attribute of the copied object
|
|
will be set to this value.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Shallow copy.
|
|
|
|
"""
|
|
if polygons is None:
|
|
polygons = self.polygons[:]
|
|
if shape is None:
|
|
# use tuple() here in case the shape was provided as a list
|
|
shape = tuple(self.shape)
|
|
|
|
return PolygonsOnImage(polygons, shape)
|
|
|
|
def deepcopy(self, polygons=None, shape=None):
|
|
"""Create a deep copy of this object.
|
|
|
|
Parameters
|
|
----------
|
|
polygons : None or list of imgaug.augmentables.polys.Polygons, optional
|
|
List of polygons on the image.
|
|
If not ``None``, then the ``polygons`` attribute of the copied
|
|
object will be set to this value.
|
|
|
|
shape : None or tuple of int or ndarray, optional
|
|
The shape of the image on which the objects are placed.
|
|
Either an image with shape ``(H,W,[C])`` or a tuple denoting
|
|
such an image shape.
|
|
If not ``None``, then the ``shape`` attribute of the copied object
|
|
will be set to this value.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.PolygonsOnImage
|
|
Deep copy.
|
|
|
|
"""
|
|
# Manual copy is far faster than deepcopy, so use manual copy here.
|
|
if polygons is None:
|
|
polygons = [poly.deepcopy() for poly in self.polygons]
|
|
if shape is None:
|
|
# use tuple() here in case the shape was provided as a list
|
|
shape = tuple(self.shape)
|
|
|
|
return PolygonsOnImage(polygons, shape)
|
|
|
|
def __getitem__(self, indices):
|
|
"""Get the polygon(s) with given indices.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Returns
|
|
-------
|
|
list of imgaug.augmentables.polys.Polygon
|
|
Polygon(s) with given indices.
|
|
|
|
"""
|
|
return self.polygons[indices]
|
|
|
|
def __iter__(self):
|
|
"""Iterate over the polygons in this container.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Yields
|
|
------
|
|
Polygon
|
|
A polygon in this container.
|
|
The order is identical to the order in the polygon list
|
|
provided upon class initialization.
|
|
|
|
"""
|
|
return iter(self.polygons)
|
|
|
|
def __len__(self):
|
|
"""Get the number of items in this instance.
|
|
|
|
Added in 0.4.0.
|
|
|
|
Returns
|
|
-------
|
|
int
|
|
Number of items in this instance.
|
|
|
|
"""
|
|
return len(self.items)
|
|
|
|
def __repr__(self):
|
|
return self.__str__()
|
|
|
|
def __str__(self):
|
|
return "PolygonsOnImage(%s, shape=%s)" % (
|
|
str(self.polygons), self.shape)
|
|
|
|
|
|
def _convert_points_to_shapely_line_string(points, closed=False,
|
|
interpolate=0):
|
|
# load shapely lazily, which makes the dependency more optional
|
|
import shapely.geometry
|
|
|
|
if len(points) <= 1:
|
|
raise Exception(
|
|
"Conversion to shapely line string requires at least two points, "
|
|
"but points input contains only %d points." % (len(points),))
|
|
|
|
points_tuples = [(point[0], point[1]) for point in points]
|
|
|
|
# interpolate points between each consecutive pair of points
|
|
if interpolate > 0:
|
|
points_tuples = interpolate_points(points_tuples, interpolate)
|
|
|
|
# close if requested and not yet closed
|
|
# used here intentionally `points` instead of `points_tuples`
|
|
if closed and len(points) > 1:
|
|
points_tuples.append(points_tuples[0])
|
|
|
|
return shapely.geometry.LineString(points_tuples)
|
|
|
|
|
|
class _ConcavePolygonRecoverer(object):
|
|
def __init__(self, threshold_duplicate_points=1e-4, noise_strength=1e-4,
|
|
oversampling=0.01, max_segment_difference=1e-4):
|
|
self.threshold_duplicate_points = threshold_duplicate_points
|
|
self.noise_strength = noise_strength
|
|
self.oversampling = oversampling
|
|
self.max_segment_difference = max_segment_difference
|
|
|
|
# this limits the maximum amount of points after oversampling, i.e.
|
|
# if N points are input into oversampling, then M oversampled points
|
|
# are generated such that N+M <= this value
|
|
self.oversample_up_to_n_points_max = 75
|
|
|
|
# ----
|
|
# parameters for _fit_best_valid_polygon()
|
|
# ----
|
|
# how many changes may be done max to the initial (convex hull) polygon
|
|
# before simply returning the result
|
|
self.fit_n_changes_max = 100
|
|
# for how many iterations the optimization loop may run max
|
|
# before simply returning the result
|
|
self.fit_n_iters_max = 3
|
|
# how far (wrt. to their position in the input list) two points may be
|
|
# apart max to consider adding an edge between them (in the first loop
|
|
# iteration and the ones after that)
|
|
self.fit_max_dist_first_iter = 1
|
|
self.fit_max_dist_other_iters = 2
|
|
# The fit loop first generates candidate edges and then modifies the
|
|
# polygon based on these candidates. This limits the maximum amount
|
|
# of considered candidates. If the number is less than the possible
|
|
# number of candidates, they are randomly subsampled. Values beyond
|
|
# 100 significantly increase runtime (for polygons that reach that
|
|
# number).
|
|
self.fit_n_candidates_before_sort_max = 100
|
|
|
|
# If abs(x) or abs(y) of any coordinate of a polygon is beyond this
|
|
# value, no intersection points will be computed anymore. That is done,
|
|
# because the underlying library to find these points uses float
|
|
# values as keys and may therefore start to encounter inaccuracies
|
|
# leading to exceptions within that library.
|
|
self.limit_coords_values_for_inter_search = 50000
|
|
|
|
# Rounding of coordinates to use before feeding them into the
|
|
# library to search for intersection points. Note that the library
|
|
# was set to also use a corresponding eps of 1e-4.
|
|
self.decimals = 4
|
|
|
|
def recover_from(self, new_exterior, old_polygon, random_state=0):
|
|
assert isinstance(new_exterior, list) or (
|
|
ia.is_np_array(new_exterior)
|
|
and new_exterior.ndim == 2
|
|
and new_exterior.shape[1] == 2), (
|
|
"Expected exterior as list or (N,2) ndarray, got type %s." % (
|
|
type(new_exterior),))
|
|
assert len(new_exterior) >= 3, \
|
|
"Cannot recover a concave polygon from less than three points."
|
|
|
|
# create Polygon instance, if it is already valid then just return
|
|
# immediately
|
|
polygon = old_polygon.deepcopy(exterior=new_exterior)
|
|
if polygon.is_valid:
|
|
return polygon
|
|
|
|
random_state = iarandom.RNG.create_if_not_rng_(random_state)
|
|
rss = random_state.duplicate(3)
|
|
|
|
# remove consecutive duplicate points
|
|
new_exterior = self._remove_consecutive_duplicate_points(new_exterior)
|
|
|
|
# check that points are not all identical or on a line
|
|
new_exterior = self._fix_polygon_is_line(new_exterior, rss[0])
|
|
|
|
# jitter duplicate points
|
|
new_exterior = self._jitter_duplicate_points(new_exterior, rss[1])
|
|
|
|
# generate intersection points
|
|
segment_add_points = self._generate_intersection_points(
|
|
new_exterior, decimals=self.decimals)
|
|
|
|
# oversample points around intersections
|
|
if self.oversampling is not None and self.oversampling > 0:
|
|
segment_add_points = self._oversample_intersection_points(
|
|
new_exterior, segment_add_points)
|
|
|
|
# integrate new points into exterior
|
|
new_exterior_inter = self._insert_intersection_points(
|
|
new_exterior, segment_add_points)
|
|
|
|
# find best fit polygon, starting from convext polygon
|
|
new_exterior_concave_ids = self._fit_best_valid_polygon(
|
|
new_exterior_inter, rss[2])
|
|
new_exterior_concave = [
|
|
new_exterior_inter[idx] for idx in new_exterior_concave_ids]
|
|
|
|
# TODO return new_exterior_concave here instead of polygon, leave it to
|
|
# caller to decide what to do with it
|
|
return old_polygon.deepcopy(exterior=new_exterior_concave)
|
|
|
|
def _remove_consecutive_duplicate_points(self, points):
|
|
result = []
|
|
for point in points:
|
|
if result:
|
|
dist = np.linalg.norm(
|
|
np.float32(point) - np.float32(result[-1]))
|
|
is_same = (dist < self.threshold_duplicate_points)
|
|
if not is_same:
|
|
result.append(point)
|
|
else:
|
|
result.append(point)
|
|
if len(result) >= 2:
|
|
dist = np.linalg.norm(
|
|
np.float32(result[0]) - np.float32(result[-1]))
|
|
is_same = (dist < self.threshold_duplicate_points)
|
|
result = result[0:-1] if is_same else result
|
|
return result
|
|
|
|
# fix polygons for which all points are on a line
|
|
def _fix_polygon_is_line(self, exterior, random_state):
|
|
assert len(exterior) >= 3, (
|
|
"Can only fix line-like polygons with an exterior containing at "
|
|
"least 3 points. Got one with %d points." % (len(exterior),))
|
|
noise_strength = self.noise_strength
|
|
while self._is_polygon_line(exterior):
|
|
noise = random_state.uniform(
|
|
-noise_strength, noise_strength, size=(len(exterior), 2)
|
|
).astype(np.float32)
|
|
exterior = [(point[0] + noise_i[0], point[1] + noise_i[1])
|
|
for point, noise_i in zip(exterior, noise)]
|
|
noise_strength = noise_strength * 10
|
|
assert noise_strength > 0, (
|
|
"Expected noise strength to be >0, got %.4f." % (
|
|
noise_strength,))
|
|
return exterior
|
|
|
|
@classmethod
|
|
def _is_polygon_line(cls, exterior):
|
|
vec_down = np.float32([0, 1])
|
|
point1 = exterior[0]
|
|
angles = set()
|
|
for point2 in exterior[1:]:
|
|
vec = np.float32(point2) - np.float32(point1)
|
|
angle = ia.angle_between_vectors(vec_down, vec)
|
|
angles.add(int(angle * 1000))
|
|
return len(angles) <= 1
|
|
|
|
def _jitter_duplicate_points(self, exterior, random_state):
|
|
def _find_duplicates(exterior_with_duplicates):
|
|
points_map = collections.defaultdict(list)
|
|
|
|
for i, point in enumerate(exterior_with_duplicates):
|
|
# we use 10/x here to be a bit more lenient, the precise
|
|
# distance test is further below
|
|
x = int(np.round(point[0]
|
|
* ((1/10) / self.threshold_duplicate_points)))
|
|
y = int(np.round(point[1]
|
|
* ((1/10) / self.threshold_duplicate_points)))
|
|
for direction0 in [-1, 0, 1]:
|
|
for direction1 in [-1, 0, 1]:
|
|
points_map[(x+direction0, y+direction1)].append(i)
|
|
|
|
duplicates = [False] * len(exterior_with_duplicates)
|
|
for key in points_map:
|
|
candidates = points_map[key]
|
|
for i, p0_idx in enumerate(candidates):
|
|
p0_idx = candidates[i]
|
|
point0 = exterior_with_duplicates[p0_idx]
|
|
if duplicates[p0_idx]:
|
|
continue
|
|
|
|
for j in range(i+1, len(candidates)):
|
|
p1_idx = candidates[j]
|
|
point1 = exterior_with_duplicates[p1_idx]
|
|
if duplicates[p1_idx]:
|
|
continue
|
|
|
|
dist = np.sqrt(
|
|
(point0[0] - point1[0])**2
|
|
+ (point0[1] - point1[1])**2)
|
|
if dist < self.threshold_duplicate_points:
|
|
duplicates[p1_idx] = True
|
|
|
|
return duplicates
|
|
|
|
noise_strength = self.noise_strength
|
|
assert noise_strength > 0, (
|
|
"Expected noise strength to be >0, got %.4f." % (noise_strength,))
|
|
exterior = exterior[:]
|
|
converged = False
|
|
while not converged:
|
|
duplicates = _find_duplicates(exterior)
|
|
if any(duplicates):
|
|
noise = random_state.uniform(
|
|
-self.noise_strength,
|
|
self.noise_strength,
|
|
size=(len(exterior), 2)
|
|
).astype(np.float32)
|
|
|
|
for i, is_duplicate in enumerate(duplicates):
|
|
if is_duplicate:
|
|
exterior[i] = (
|
|
exterior[i][0] + noise[i][0],
|
|
exterior[i][1] + noise[i][1])
|
|
|
|
noise_strength *= 10
|
|
else:
|
|
converged = True
|
|
|
|
return exterior
|
|
|
|
# TODO remove?
|
|
@classmethod
|
|
def _calculate_circumference(cls, points):
|
|
assert len(points) >= 3, (
|
|
"Need at least 3 points on the exterior to compute the "
|
|
"circumference. Got %d." % (len(points),))
|
|
points = np.array(points, dtype=np.float32)
|
|
points_matrix = np.zeros((len(points), 4), dtype=np.float32)
|
|
points_matrix[:, 0:2] = points
|
|
points_matrix[0:-1, 2:4] = points_matrix[1:, 0:2]
|
|
points_matrix[-1, 2:4] = points_matrix[0, 0:2]
|
|
distances = np.linalg.norm(
|
|
points_matrix[:, 0:2] - points_matrix[:, 2:4], axis=1)
|
|
return np.sum(distances)
|
|
|
|
def _generate_intersection_points(self, exterior,
|
|
one_point_per_intersection=True,
|
|
decimals=4):
|
|
# pylint: disable=broad-except
|
|
largest_value = np.max(np.abs(np.array(exterior, dtype=np.float32)))
|
|
too_large_values = (
|
|
largest_value > self.limit_coords_values_for_inter_search)
|
|
if too_large_values:
|
|
ia.warn(
|
|
"Encountered during polygon repair a polygon with extremely "
|
|
"large coordinate values beyond %d. Will skip intersection "
|
|
"point computation for that polygon. This avoids exceptions "
|
|
"and is -- due to the extreme distortion -- likely pointless "
|
|
"anyways (i.e. the polygon is already broken beyond repair). "
|
|
"Try using weaker augmentation parameters to avoid such "
|
|
"large coordinate values." % (
|
|
self.limit_coords_values_for_inter_search,)
|
|
)
|
|
return [[] for _ in range(len(exterior))]
|
|
|
|
if ia.is_np_array(exterior):
|
|
exterior = list(exterior)
|
|
assert isinstance(exterior, list), (
|
|
"Expected 'exterior' to be a list or a ndarray. "
|
|
"Got type %s." % (type(exterior),))
|
|
assert all([len(point) == 2 for point in exterior]), (
|
|
"Expected 'exterior' to contain (x,y) coordinate pairs. "
|
|
"Got lengths %s." % (
|
|
", ".join([str(len(point)) for point in exterior])))
|
|
if len(exterior) <= 0:
|
|
return []
|
|
|
|
# use (*[i][0], *[i][1]) formulation here instead of just *[i],
|
|
# because this way we convert numpy arrays to tuples of floats, which
|
|
# is required by isect_segments_include_segments
|
|
segments = [
|
|
(
|
|
(
|
|
np.round(float(exterior[i][0]), decimals),
|
|
np.round(float(exterior[i][1]), decimals)
|
|
),
|
|
(
|
|
np.round(float(exterior[(i + 1) % len(exterior)][0]),
|
|
decimals),
|
|
np.round(float(exterior[(i + 1) % len(exterior)][1]),
|
|
decimals)
|
|
)
|
|
)
|
|
for i in range(len(exterior))
|
|
]
|
|
|
|
# returns [(point, [(segment_p0, segment_p1), ..]), ...]
|
|
from imgaug.external.poly_point_isect_py2py3 import (
|
|
isect_segments_include_segments)
|
|
|
|
try:
|
|
intersections = isect_segments_include_segments(segments)
|
|
except Exception as exc:
|
|
# Exceptions in the segment intersection search can at least
|
|
# happen due to large float coords (the library uses
|
|
# floats as indices, which is bound to cause inaccuracies).
|
|
# Usually such exceptions should not appear, as too large
|
|
# coordinate values are already caught at the start of this
|
|
# function. For the case that there are more errors, this block
|
|
# will prevent a full crash.
|
|
ia.warn(
|
|
"Encountered exception %s during polygon repair in segment "
|
|
"intersection computation. Will skip that step." % (
|
|
str(exc),))
|
|
traceback.print_exc()
|
|
return [[] for _ in range(len(exterior))]
|
|
|
|
# estimate to which segment the found intersection points belong
|
|
segments_add_points = [[] for _ in range(len(segments))]
|
|
for point, associated_segments in intersections:
|
|
# the intersection point may be associated with multiple segments,
|
|
# but we only want to add it once, so pick the first segment
|
|
if one_point_per_intersection:
|
|
associated_segments = [associated_segments[0]]
|
|
|
|
for seg_inter_p0, seg_inter_p1 in associated_segments:
|
|
diffs = []
|
|
dists = []
|
|
for seg_p0, seg_p1 in segments:
|
|
dist_p0p0 = np.linalg.norm(seg_p0 - np.array(seg_inter_p0))
|
|
dist_p1p1 = np.linalg.norm(seg_p1 - np.array(seg_inter_p1))
|
|
dist_p0p1 = np.linalg.norm(seg_p0 - np.array(seg_inter_p1))
|
|
dist_p1p0 = np.linalg.norm(seg_p1 - np.array(seg_inter_p0))
|
|
diff = min(dist_p0p0 + dist_p1p1, dist_p0p1 + dist_p1p0)
|
|
diffs.append(diff)
|
|
dists.append(np.linalg.norm(
|
|
(seg_p0[0] - point[0], seg_p0[1] - point[1])
|
|
))
|
|
|
|
min_diff = np.min(diffs)
|
|
if min_diff < self.max_segment_difference:
|
|
idx = int(np.argmin(diffs))
|
|
segments_add_points[idx].append((point, dists[idx]))
|
|
else:
|
|
ia.warn(
|
|
"Couldn't find fitting segment in "
|
|
"_generate_intersection_points(). Ignoring "
|
|
"intersection point.")
|
|
|
|
# sort intersection points by their distance to point 0 in each segment
|
|
# (clockwise ordering, this does something only for segments with
|
|
# >=2 intersection points)
|
|
segment_add_points_sorted = []
|
|
for idx in range(len(segments_add_points)):
|
|
points = [t[0] for t in segments_add_points[idx]]
|
|
dists = [t[1] for t in segments_add_points[idx]]
|
|
if len(points) < 2:
|
|
segment_add_points_sorted.append(points)
|
|
else:
|
|
both = sorted(zip(points, dists), key=lambda t: t[1])
|
|
# keep points, drop distances
|
|
segment_add_points_sorted.append([a for a, _b in both])
|
|
return segment_add_points_sorted
|
|
|
|
def _oversample_intersection_points(self, exterior, segment_add_points):
|
|
# segment_add_points must be sorted
|
|
|
|
if self.oversampling is None or self.oversampling <= 0:
|
|
return segment_add_points
|
|
|
|
segment_add_points_sorted_overs = [
|
|
[] for _ in range(len(segment_add_points))]
|
|
|
|
n_points = len(exterior)
|
|
for i, last in enumerate(exterior):
|
|
for j, p_inter in enumerate(segment_add_points[i]):
|
|
direction = (p_inter[0] - last[0], p_inter[1] - last[1])
|
|
|
|
if j == 0:
|
|
# previous point was non-intersection, place 1 new point
|
|
oversample = [1.0 - self.oversampling]
|
|
else:
|
|
# previous point was intersection, place 2 new points
|
|
oversample = [self.oversampling, 1.0 - self.oversampling]
|
|
|
|
for dist in oversample:
|
|
point_over = (last[0] + dist * direction[0],
|
|
last[1] + dist * direction[1])
|
|
segment_add_points_sorted_overs[i].append(point_over)
|
|
segment_add_points_sorted_overs[i].append(p_inter)
|
|
last = p_inter
|
|
|
|
is_last_in_group = (j == len(segment_add_points[i]) - 1)
|
|
if is_last_in_group:
|
|
# previous point was oversampled, next point is
|
|
# non-intersection, place 1 new point between the two
|
|
exterior_point = exterior[(i + 1) % len(exterior)]
|
|
direction = (exterior_point[0] - last[0],
|
|
exterior_point[1] - last[1])
|
|
segment_add_points_sorted_overs[i].append(
|
|
(last[0] + self.oversampling * direction[0],
|
|
last[1] + self.oversampling * direction[1])
|
|
)
|
|
last = segment_add_points_sorted_overs[i][-1]
|
|
|
|
n_points += len(segment_add_points_sorted_overs[i])
|
|
if n_points > self.oversample_up_to_n_points_max:
|
|
return segment_add_points_sorted_overs
|
|
|
|
return segment_add_points_sorted_overs
|
|
|
|
@classmethod
|
|
def _insert_intersection_points(cls, exterior, segment_add_points):
|
|
# segment_add_points must be sorted
|
|
|
|
assert len(exterior) == len(segment_add_points), (
|
|
"Expected one entry in 'segment_add_points' for every point in "
|
|
"the exterior. Got %d (segment_add_points) and %d (exterior) "
|
|
"entries instead." % (len(segment_add_points), len(exterior)))
|
|
exterior_interp = []
|
|
for i, point0 in enumerate(exterior):
|
|
point0 = exterior[i]
|
|
exterior_interp.append(point0)
|
|
for p_inter in segment_add_points[i]:
|
|
exterior_interp.append(p_inter)
|
|
return exterior_interp
|
|
|
|
def _fit_best_valid_polygon(self, points, random_state):
|
|
if len(points) < 2:
|
|
return None
|
|
|
|
def _compute_distance_point_to_line(point, line_start, line_end):
|
|
x_diff = line_end[0] - line_start[0]
|
|
y_diff = line_end[1] - line_start[1]
|
|
num = abs(
|
|
y_diff*point[0] - x_diff*point[1]
|
|
+ line_end[0]*line_start[1] - line_end[1]*line_start[0]
|
|
)
|
|
den = np.sqrt(y_diff**2 + x_diff**2)
|
|
if den == 0:
|
|
return np.sqrt(
|
|
(point[0] - line_start[0])**2
|
|
+ (point[1] - line_start[1])**2)
|
|
return num / den
|
|
|
|
poly = Polygon(points)
|
|
if poly.is_valid:
|
|
return sm.xrange(len(points))
|
|
|
|
hull = scipy.spatial.ConvexHull(points)
|
|
points_kept = list(hull.vertices)
|
|
points_left = [i for i in range(len(points)) if i not in points_kept]
|
|
|
|
iteration = 0
|
|
n_changes = 0
|
|
converged = False
|
|
while not converged:
|
|
candidates = []
|
|
|
|
# estimate distance metrics for points-segment pairs:
|
|
# (1) distance (in vertices) between point and segment-start-point
|
|
# in original input point chain
|
|
# (2) euclidean distance between point and segment/line
|
|
# TODO this can be done more efficiently by caching the values and
|
|
# only computing distances to segments that have changed in
|
|
# the last iteration
|
|
# TODO these distances are not really the best metrics here.
|
|
# Something like IoU between new and old (invalid) polygon
|
|
# would be better, but can probably only be computed for
|
|
# pairs of valid polygons. Maybe something based on pointwise
|
|
# distances, where the points are sampled on the edges (not
|
|
# edge vertices themselves). Maybe something based on drawing
|
|
# the perimeter on images or based on distance maps.
|
|
point_kept_idx_to_pos = {
|
|
point_idx: i for i, point_idx in enumerate(points_kept)}
|
|
|
|
# generate all possible combinations from <points_kept> and
|
|
# <points_left>
|
|
combos = np.transpose([
|
|
np.tile(
|
|
np.int32(points_left), len(np.int32(points_kept))
|
|
),
|
|
np.repeat(
|
|
np.int32(points_kept), len(np.int32(points_left))
|
|
)
|
|
])
|
|
combos = np.concatenate(
|
|
(combos, np.zeros((combos.shape[0], 3), dtype=np.int32)),
|
|
axis=1)
|
|
|
|
# copy columns 0, 1 into 2, 3 so that 2 is always the lower value
|
|
mask = combos[:, 0] < combos[:, 1]
|
|
combos[:, 2:4] = combos[:, 0:2]
|
|
combos[mask, 2] = combos[mask, 1]
|
|
combos[mask, 3] = combos[mask, 0]
|
|
|
|
# distance (in indices) between each pair of <point_kept> and
|
|
# <point_left>
|
|
combos[:, 4] = np.minimum(
|
|
combos[:, 3] - combos[:, 2],
|
|
len(points) - combos[:, 3] + combos[:, 2]
|
|
)
|
|
|
|
# limit candidates
|
|
max_dist = self.fit_max_dist_other_iters
|
|
if iteration > 0:
|
|
max_dist = self.fit_max_dist_first_iter
|
|
candidate_rows = combos[combos[:, 4] <= max_dist]
|
|
do_limit = (
|
|
self.fit_n_candidates_before_sort_max is not None
|
|
and len(candidate_rows) > self.fit_n_candidates_before_sort_max)
|
|
if do_limit:
|
|
random_state.shuffle(candidate_rows)
|
|
candidate_rows = candidate_rows[
|
|
0:self.fit_n_candidates_before_sort_max]
|
|
|
|
for row in candidate_rows:
|
|
point_left_idx = row[0]
|
|
point_kept_idx = row[1]
|
|
in_points_kept_pos = point_kept_idx_to_pos[point_kept_idx]
|
|
segment_start_idx = point_kept_idx
|
|
segment_end_idx = points_kept[
|
|
(in_points_kept_pos+1) % len(points_kept)]
|
|
segment_start = points[segment_start_idx]
|
|
segment_end = points[segment_end_idx]
|
|
if iteration == 0:
|
|
dist_eucl = 0
|
|
else:
|
|
dist_eucl = _compute_distance_point_to_line(
|
|
points[point_left_idx], segment_start, segment_end)
|
|
candidates.append(
|
|
(point_left_idx, point_kept_idx, row[4], dist_eucl))
|
|
|
|
# Sort computed distances first by minimal vertex-distance (see
|
|
# above, metric 1) (ASC), then by euclidean distance
|
|
# (metric 2) (ASC).
|
|
candidate_ids = np.arange(len(candidates))
|
|
candidate_ids = sorted(
|
|
candidate_ids,
|
|
key=lambda idx: (candidates[idx][2], candidates[idx][3]))
|
|
if self.fit_n_changes_max is not None:
|
|
candidate_ids = candidate_ids[:self.fit_n_changes_max]
|
|
|
|
# Iterate over point-segment pairs in sorted order. For each such
|
|
# candidate: Add the point to the already collected points,
|
|
# create a polygon from that and check if the polygon is valid.
|
|
# If it is, add the point to the output list and recalculate
|
|
# distance metrics. If it isn't valid, proceed with the next
|
|
# candidate until no more candidates are left.
|
|
#
|
|
# small change: this now no longer breaks upon the first found
|
|
# point that leads to a valid polygon, but checks all candidates
|
|
# instead
|
|
is_valid = False
|
|
done = set()
|
|
for candidate_idx in candidate_ids:
|
|
point_left_idx = candidates[candidate_idx][0]
|
|
point_kept_idx = candidates[candidate_idx][1]
|
|
if (point_left_idx, point_kept_idx) not in done:
|
|
in_points_kept_idx = [
|
|
i
|
|
for i, point_idx
|
|
in enumerate(points_kept)
|
|
if point_idx == point_kept_idx
|
|
][0]
|
|
points_kept_hypothesis = points_kept[:]
|
|
points_kept_hypothesis.insert(
|
|
in_points_kept_idx+1,
|
|
point_left_idx)
|
|
poly_hypothesis = Polygon([
|
|
points[idx] for idx in points_kept_hypothesis])
|
|
if poly_hypothesis.is_valid:
|
|
is_valid = True
|
|
points_kept = points_kept_hypothesis
|
|
points_left = [point_idx
|
|
for point_idx
|
|
in points_left
|
|
if point_idx != point_left_idx]
|
|
n_changes += 1
|
|
if n_changes >= self.fit_n_changes_max:
|
|
return points_kept
|
|
done.add((point_left_idx, point_kept_idx))
|
|
done.add((point_kept_idx, point_left_idx))
|
|
|
|
# none of the left points could be used to create a valid polygon?
|
|
# (this automatically covers the case of no points being left)
|
|
if not is_valid and iteration > 0:
|
|
converged = True
|
|
|
|
iteration += 1
|
|
has_reached_iters_max = (
|
|
self.fit_n_iters_max is not None
|
|
and iteration > self.fit_n_iters_max)
|
|
if has_reached_iters_max:
|
|
break
|
|
|
|
return points_kept
|
|
|
|
|
|
# TODO remove this? was previously only used by Polygon.clip_*(), but that
|
|
# doesn't use it anymore
|
|
class MultiPolygon(object):
|
|
"""
|
|
Class that represents several polygons.
|
|
|
|
Parameters
|
|
----------
|
|
geoms : list of imgaug.augmentables.polys.Polygon
|
|
List of the polygons.
|
|
|
|
"""
|
|
def __init__(self, geoms):
|
|
"""Create a new MultiPolygon instance."""
|
|
assert (
|
|
len(geoms) == 0
|
|
or all([isinstance(el, Polygon) for el in geoms])), (
|
|
"Expected 'geoms' to a list of Polygon instances. "
|
|
"Got types %s." % (", ".join([str(el) for el in geoms])))
|
|
self.geoms = geoms
|
|
|
|
@staticmethod
|
|
def from_shapely(geometry, label=None):
|
|
"""Create a MultiPolygon from a shapely object.
|
|
|
|
This also creates all necessary ``Polygon`` s contained in this
|
|
``MultiPolygon``.
|
|
|
|
Parameters
|
|
----------
|
|
geometry : shapely.geometry.MultiPolygon or shapely.geometry.Polygon or shapely.geometry.collection.GeometryCollection
|
|
The object to convert to a MultiPolygon.
|
|
|
|
label : None or str, optional
|
|
A label assigned to all Polygons within the MultiPolygon.
|
|
|
|
Returns
|
|
-------
|
|
imgaug.augmentables.polys.MultiPolygon
|
|
The derived MultiPolygon.
|
|
|
|
"""
|
|
# load shapely lazily, which makes the dependency more optional
|
|
import shapely.geometry
|
|
|
|
if isinstance(geometry, shapely.geometry.MultiPolygon):
|
|
return MultiPolygon([
|
|
Polygon.from_shapely(poly, label=label)
|
|
for poly
|
|
in geometry.geoms])
|
|
if isinstance(geometry, shapely.geometry.Polygon):
|
|
return MultiPolygon([Polygon.from_shapely(geometry, label=label)])
|
|
if isinstance(geometry,
|
|
shapely.geometry.collection.GeometryCollection):
|
|
assert all([
|
|
isinstance(poly, shapely.geometry.Polygon)
|
|
for poly
|
|
in geometry.geoms]), (
|
|
"Expected the geometry collection to only contain shapely "
|
|
"polygons. Got types %s." % (
|
|
", ".join([str(type(v)) for v in geometry.geoms])))
|
|
return MultiPolygon([
|
|
Polygon.from_shapely(poly, label=label)
|
|
for poly
|
|
in geometry.geoms])
|
|
|
|
raise Exception(
|
|
"Unknown datatype '%s'. Expected shapely.geometry.Polygon or "
|
|
"shapely.geometry.MultiPolygon or "
|
|
"shapely.geometry.collections.GeometryCollection." % (
|
|
type(geometry),))
|