"""
Coordinates the fundamental "point" datatype. They do not contain metadata,
only geometry. See the `Points` data type for a structure that maintains
metadata on top of coordinate data.
"""
import numpy as np
import ubelt as ub
import kwarray
from kwimage.structs import _generic
try:
from packaging.version import parse as LooseVersion
except ImportError:
from distutils.version import LooseVersion
# try:
# from line_profiler import profile
# except Exception:
# from ubelt import identity as profile
try:
import imgaug
_HAS_IMGAUG_FLIP_BUG = LooseVersion(imgaug.__version__) <= LooseVersion('0.2.9') and not hasattr(imgaug.augmenters.size, '_crop_and_pad_kpsoi')
_HAS_IMGAUG_XY_ARRAY = LooseVersion(imgaug.__version__) >= LooseVersion('0.2.9')
except ImportError:
_HAS_IMGAUG_FLIP_BUG = None
_HAS_IMGAUG_XY_ARRAY = None
__docstubs__ = """
from kwimage._typing import SKImageGeometricTransform
"""
[docs]
class Coords(_generic.Spatial, ub.NiceRepr):
"""
A data structure to store n-dimensional coordinate geometry.
Currently it is up to the user to maintain what coordinate system this
geometry belongs to.
Note:
This class was designed to hold coordinates in r/c format, but in
general this class is anostic to dimension ordering as long as you are
consistent. However, there are two places where this matters:
(1) drawing and (2) gdal/imgaug-warping. In these places we will
assume x/y for legacy reasons. This may change in the future.
The term axes with resepct to ``Coords`` always refers to the final
numpy axis. In other words the final numpy-axis represents ALL of the
coordinate-axes.
CommandLine:
xdoctest -m kwimage.structs.coords Coords
Example:
>>> from kwimage.structs.coords import * # NOQA
>>> import kwarray
>>> rng = kwarray.ensure_rng(0)
>>> self = Coords.random(num=4, dim=3, rng=rng)
>>> print('self = {}'.format(self))
self = <Coords(data=
array([[0.5488135 , 0.71518937, 0.60276338],
[0.54488318, 0.4236548 , 0.64589411],
[0.43758721, 0.891773 , 0.96366276],
[0.38344152, 0.79172504, 0.52889492]]))>
>>> matrix = rng.rand(4, 4)
>>> self.warp(matrix)
<Coords(data=
array([[0.71037426, 1.25229659, 1.39498435],
[0.60799503, 1.26483447, 1.42073131],
[0.72106004, 1.39057144, 1.38757508],
[0.68384299, 1.23914654, 1.29258196]]))>
>>> self.translate(3, inplace=True)
<Coords(data=
array([[3.5488135 , 3.71518937, 3.60276338],
[3.54488318, 3.4236548 , 3.64589411],
[3.43758721, 3.891773 , 3.96366276],
[3.38344152, 3.79172504, 3.52889492]]))>
>>> self.translate(3, inplace=True)
<Coords(data=
array([[6.5488135 , 6.71518937, 6.60276338],
[6.54488318, 6.4236548 , 6.64589411],
[6.43758721, 6.891773 , 6.96366276],
[6.38344152, 6.79172504, 6.52889492]]))>
>>> self.scale(2)
<Coords(data=
array([[13.09762701, 13.43037873, 13.20552675],
[13.08976637, 12.8473096 , 13.29178823],
[12.87517442, 13.783546 , 13.92732552],
[12.76688304, 13.58345008, 13.05778984]]))>
>>> # xdoctest: +REQUIRES(module:torch)
>>> self.tensor()
>>> self.tensor().tensor().numpy().numpy()
>>> self.numpy()
>>> #self.draw_on()
"""
# __slots__ = ('data', 'meta',) # turn on when no longer developing
def __init__(self, data=None, meta=None):
if isinstance(data, self.__class__):
# Avoid runtime checks and assume the user is doing the right thing
# if data and meta are explicitly specified
meta = data.meta
data = data.data
if meta is None:
meta = {}
self.data = data
self.meta = meta
def __array__(self):
return np.asarray(self.data)
def __nice__(self):
data_repr = repr(self.data)
if '\n' in data_repr:
data_repr = ub.indent('\n' + data_repr.lstrip('\n'), ' ')
return 'data={}'.format(data_repr)
__repr__ = ub.NiceRepr.__str__
def __len__(self):
return len(self.data)
@property
def dtype(self):
return self.data.dtype
@property
def dim(self):
return self.data.shape[-1]
@property
def shape(self):
return self.data.shape
[docs]
def copy(self):
newdata = self._impl.copy(self.data)
newmeta = self.meta.copy()
new = self.__class__(newdata, newmeta)
return new
[docs]
@classmethod
def random(Coords, num=1, dim=2, rng=None, meta=None):
"""
Makes random coordinates; typically for testing purposes
"""
rng = kwarray.ensure_rng(rng)
self = Coords(data=rng.rand(num, dim), meta=meta)
return self
[docs]
def is_numpy(self):
"""
Returns:
bool
"""
return self._impl.is_numpy
[docs]
def is_tensor(self):
"""
Returns:
bool
"""
return self._impl.is_tensor
[docs]
def compress(self, flags, axis=0, inplace=False):
"""
Filters items based on a boolean criterion
Args:
flags (ArrayLike):
true for items to be kept.
Extended type: ArrayLike[bool].
axis (int): you usually want this to be 0
inplace (bool):
if True, modifies this object
Returns:
Coords: filtered coords
Example:
>>> import kwimage
>>> self = kwimage.Coords.random(10, rng=0)
>>> self.compress([True] * len(self))
>>> self.compress([False] * len(self))
<Coords(data=array([], shape=(0, 2), dtype=float64))>
>>> # xdoctest: +REQUIRES(module:torch)
>>> self = self.tensor()
>>> self.compress([True] * len(self))
>>> self.compress([False] * len(self))
"""
new = self if inplace else self.__class__(self.data, self.meta)
new.data = self._impl.compress(new.data, flags, axis=axis)
return new
[docs]
def take(self, indices, axis=0, inplace=False):
"""
Takes a subset of items at specific indices
Args:
indices (ArrayLike):
indexes of items to take.
Extended type ArrayLike[int].
axis (int): you usually want this to be 0
inplace (bool): if True, modifies this object
Returns:
Coords: filtered coords
Example:
>>> import kwimage
>>> self = kwimage.Coords(np.array([[25, 30, 15, 10]]))
>>> self.take([0])
<Coords(data=array([[25, 30, 15, 10]]))>
>>> self.take([])
<Coords(data=array([], shape=(0, 4), dtype=...))>
"""
new = self if inplace else self.__class__(self.data, self.meta)
new.data = self._impl.take(new.data, indices, axis=axis)
return new
[docs]
def astype(self, dtype, inplace=False):
"""
Changes the data type
Args:
dtype : new type
inplace (bool): if True, modifies this object
Returns:
Coords: modified coordinates
"""
new = self if inplace else self.__class__(self.data, self.meta)
new.data = self._impl.astype(new.data, dtype, copy=not inplace)
return new
[docs]
def round(self, decimals=0, inplace=False):
"""
Rounds data to the specified decimal place
Args:
inplace (bool): if True, modifies this object
decimals (int): number of decimal places to round to
Returns:
Coords: modified coordinates
Example:
>>> import kwimage
>>> self = kwimage.Coords.random(3).scale(10)
>>> self.round()
"""
new = self if inplace else self.copy()
new.data = self._impl.round(new.data, decimals=decimals)
return new
[docs]
def view(self, *shape):
"""
Passthrough method to view or reshape
Args:
*shape : new shape of the data
Returns:
Coords: modified coordinates
Example:
>>> self = Coords.random(6, dim=4).numpy()
>>> assert list(self.view(3, 2, 4).data.shape) == [3, 2, 4]
>>> # xdoctest: +REQUIRES(module:torch)
>>> self = Coords.random(6, dim=4).tensor()
>>> assert list(self.view(3, 2, 4).data.shape) == [3, 2, 4]
"""
data_ = self._impl.view(self.data, *shape)
return self.__class__(data_, self.meta)
[docs]
@classmethod
def concatenate(cls, coords, axis=0):
"""
Concatenates lists of coordinates together
Args:
coords (Sequence[Coords]): list of coords to concatenate
axis (int): axis to stack on. Defaults to 0.
Returns:
Coords: stacked coords
CommandLine:
xdoctest -m kwimage.structs.coords Coords.concatenate
Example:
>>> coords = [Coords.random(3) for _ in range(3)]
>>> new = Coords.concatenate(coords)
>>> assert len(new) == 9
>>> assert np.all(new.data[3:6] == coords[1].data)
"""
if len(coords) == 0:
raise ValueError('need at least one box to concatenate')
if axis != 0:
raise ValueError('can only concatenate along axis=0')
first = coords[0]
impl = first._impl
datas = [b.data for b in coords]
newdata = impl.cat(datas, axis=axis)
new = cls(newdata)
return new
@property
def device(self):
"""
If the backend is torch returns the data device, otherwise None
"""
try:
return self.data.device
except AttributeError:
return None
# @ub.memoize_property
@property
def _impl(self):
"""
Returns the internal tensor/numpy ArrayAPI implementation
"""
return kwarray.ArrayAPI.coerce(self.data)
[docs]
def tensor(self, device=ub.NoParam):
"""
Converts numpy to tensors. Does not change memory if possible.
Returns:
Coords: modified coordinates
Example:
>>> # xdoctest: +REQUIRES(module:torch)
>>> self = Coords.random(3).numpy()
>>> newself = self.tensor()
>>> self.data[0, 0] = 0
>>> assert newself.data[0, 0] == 0
>>> self.data[0, 0] = 1
>>> assert self.data[0, 0] == 1
"""
newdata = self._impl.tensor(self.data, device)
new = self.__class__(newdata, self.meta)
return new
[docs]
def numpy(self):
"""
Converts tensors to numpy. Does not change memory if possible.
Returns:
Coords: modified coordinates
Example:
>>> # xdoctest: +REQUIRES(module:torch)
>>> self = Coords.random(3).tensor()
>>> newself = self.numpy()
>>> self.data[0, 0] = 0
>>> assert newself.data[0, 0] == 0
>>> self.data[0, 0] = 1
>>> assert self.data[0, 0] == 1
"""
newdata = self._impl.numpy(self.data)
new = self.__class__(newdata, self.meta)
return new
[docs]
def reorder_axes(self, new_order, inplace=False):
"""
Change the ordering of the coordinate axes.
Args:
new_order (Tuple[int]): ``new_order[i]`` should specify
which axes in the original coordinates should be mapped to the
``i-th`` position in the returned axes.
inplace (bool): if True, modifies data inplace
Returns:
Coords: modified coordinates
Note:
This is the ordering of the "columns" in final numpy axis, not the
numpy axes themselves.
Example:
>>> from kwimage.structs.coords import * # NOQA
>>> self = Coords(data=np.array([
>>> [7, 11],
>>> [13, 17],
>>> [21, 23],
>>> ]))
>>> new = self.reorder_axes((1, 0))
>>> print('new = {!r}'.format(new))
new = <Coords(data=
array([[11, 7],
[17, 13],
[23, 21]]))>
Example:
>>> from kwimage.structs.coords import * # NOQA
>>> self = Coords.random(10, rng=0)
>>> new = self.reorder_axes((1, 0))
>>> # Remapping using 1, 0 reverses the axes
>>> assert np.all(new.data[:, 0] == self.data[:, 1])
>>> assert np.all(new.data[:, 1] == self.data[:, 0])
>>> # Remapping using 0, 1 does nothing
>>> eye = self.reorder_axes((0, 1))
>>> assert np.all(eye.data == self.data)
>>> # Remapping using 0, 0, destroys the 1-th column
>>> bad = self.reorder_axes((0, 0))
>>> assert np.all(bad.data[:, 0] == self.data[:, 0])
>>> assert np.all(bad.data[:, 1] == self.data[:, 0])
"""
impl = self._impl
new = self if inplace else self.__class__(impl.copy(self.data), self.meta)
if True:
# --- Method 1 - Slicing ---
# This will use slicing tricks to avoid a copy operation, but the
# data.flags will be modified and contiguous-ness is not preserved
new.data = new.data[..., new_order]
if False:
# --- Method 2 - Overwrite ---
# This will cause a copy operation, but the data.flags will remain
# the same, i.e. contiguous arrays will remain contiguous.
new.data[..., :] = new.data[..., new_order]
if False:
# Benchmark different methods, using slicing tricks seems
# to have the best default behavior
import timerit
ti = timerit.Timerit(100, bestof=10, verbose=2)
for timer in ti.reset('method2-apply'):
new = self.copy()
with timer:
new.data[..., :] = new.data[..., new_order]
for timer in ti.reset('method1-apply'):
new = self.copy()
with timer:
new.data = new.data[..., new_order]
for timer in ti.reset('method2-use'):
new = self.copy()
new.data[..., :] = new.data[..., new_order]
with timer:
new.data += 10
for timer in ti.reset('method1-use'):
new = self.copy()
new.data = new.data[..., new_order]
with timer:
new.data += 10
return new
# @profile
[docs]
def warp(self, transform, input_dims=None, output_dims=None,
inplace=False):
"""
Generalized coordinate transform.
Args:
transform (SKImageGeometricTransform | ArrayLike | Augmenter | Callable):
scikit-image tranform, a 3x3 transformation matrix,
an imgaug Augmenter, or generic callable which transforms
an NxD ndarray.
input_dims (Tuple): shape of the image these objects correspond to
(only needed / used when transform is an imgaug augmenter)
output_dims (Tuple): unused in non-raster structures, only exists
for compatibility.
inplace (bool): if True, modifies data inplace
Returns:
Coords: modified coordinates
Note:
Let D = self.dims
transformation matrices can be either:
* (D + 1) x (D + 1) # for homog
* D x D # for scale / rotate
* D x (D + 1) # for affine
Example:
>>> from kwimage.structs.coords import * # NOQA
>>> import skimage
>>> self = Coords.random(10, rng=0)
>>> transform = skimage.transform.AffineTransform(scale=(2, 2))
>>> new = self.warp(transform)
>>> assert np.all(new.data == self.scale(2).data)
Doctest:
>>> self = Coords.random(10, rng=0)
>>> assert np.all(self.warp(np.eye(3)).data == self.data)
>>> assert np.all(self.warp(np.eye(2)).data == self.data)
Doctest:
>>> # xdoctest: +REQUIRES(module:osgeo)
>>> from osgeo import osr
>>> wgs84_crs = osr.SpatialReference()
>>> wgs84_crs.ImportFromEPSG(4326)
>>> dst_crs = osr.SpatialReference()
>>> dst_crs.ImportFromEPSG(2927)
>>> transform = osr.CoordinateTransformation(wgs84_crs, dst_crs)
>>> self = Coords.random(10, rng=0)
>>> new = self.warp(transform)
>>> assert np.all(new.data != self.data)
>>> # Alternative using generic func
>>> def _gdal_coord_tranform(pts):
... return np.array([transform.TransformPoint(x, y, 0)[0:2]
... for x, y in pts])
>>> alt = self.warp(_gdal_coord_tranform)
>>> assert np.all(alt.data != self.data)
>>> assert np.all(alt.data == new.data)
Doctest:
>>> # can use a generic function
>>> def func(xy):
... return np.zeros_like(xy)
>>> self = Coords.random(10, rng=0)
>>> assert np.all(self.warp(func).data == 0)
"""
import kwimage
from kwimage._typing import SKImageGeometricTransform
impl = self._impl
new = self if inplace else self.__class__(impl.copy(self.data), self.meta)
if transform is None:
return new
elif _generic.isinstance_arraytypes(transform):
matrix = transform
elif isinstance(transform, kwimage.Linear):
matrix = np.asarray(transform)
elif isinstance(transform, SKImageGeometricTransform):
matrix = transform.params
else:
### Try to accept imgaug tranforms ###
try:
import imgaug
except ImportError:
pass
# import warnings
# warnings.warn('imgaug is not installed')
else:
if isinstance(transform, imgaug.augmenters.Augmenter):
return new._warp_imgaug(transform, input_dims, inplace=True)
### Try to accept GDAL tranforms ###
try:
from osgeo import osr
except ImportError:
pass
# import warnings
# warnings.warn('osgeo is not installed')
else:
if isinstance(transform, osr.CoordinateTransformation):
# NOTE: We are expecting lon/lat here for wgs84
new_pts = []
for x, y in new.data:
x, y, z = transform.TransformPoint(x, y, 0)
if z != 0:
raise AssertionError('z = {}'.format(z))
new_pts.append((x, y))
new.data = np.array(new_pts, dtype=new.data.dtype)
return new
### Try to accept generic callable transforms ###
if callable(transform):
new.data = transform(new.data)
return new
raise TypeError(type(transform))
if impl.numel(new.data) > 0:
# Handles projective coordinates correctly
new.data = kwimage.warp_points(matrix, new.data)
return new
[docs]
def _warp_imgaug(self, augmenter, input_dims, inplace=False):
"""
Warps by applying an augmenter from the imgaug library
NOTE:
We are assuming you are using X/Y coordinates here.
Args:
augmenter (imgaug.augmenters.Augmenter):
input_dims (Tuple): h/w of the input image
inplace (bool): if True, modifies data inplace
CommandLine:
xdoctest -m ~/code/kwimage/kwimage/structs/coords.py Coords._warp_imgaug
Example:
>>> # xdoctest: +REQUIRES(module:imgaug)
>>> from kwimage.structs.coords import * # NOQA
>>> import imgaug
>>> input_dims = (10, 10)
>>> self = Coords.random(10).scale(input_dims)
>>> augmenter = imgaug.augmenters.Fliplr(p=1)
>>> new = self._warp_imgaug(augmenter, input_dims)
>>> # y coordinate should not change
>>> assert np.allclose(self.data[:, 1], new.data[:, 1])
>>> assert np.allclose(input_dims[0] - self.data[:, 0], new.data[:, 0])
>>> # xdoctest: +REQUIRES(--show)
>>> import kwplot
>>> kwplot.autompl()
>>> kwplot.figure(fnum=1, doclf=True)
>>> from matplotlib import pyplot as pl
>>> ax = plt.gca()
>>> ax.set_xlim(0, input_dims[0])
>>> ax.set_ylim(0, input_dims[1])
>>> self.draw(color='red', alpha=.4, radius=0.1)
>>> new.draw(color='blue', alpha=.4, radius=0.1)
Example:
>>> # xdoctest: +REQUIRES(module:imgaug)
>>> from kwimage.structs.coords import * # NOQA
>>> import imgaug
>>> input_dims = (32, 32)
>>> inplace = 0
>>> self = Coords.random(1000, rng=142).scale(input_dims).scale(.8)
>>> self.data = self.data.astype(np.int32).astype(np.float32)
>>> augmenter = imgaug.augmenters.CropAndPad(px=(-4, 4), keep_size=1).to_deterministic()
>>> new = self._warp_imgaug(augmenter, input_dims)
>>> # Change should be linear
>>> norm1 = (self.data - self.data.min(axis=0)) / (self.data.max(axis=0) - self.data.min(axis=0))
>>> norm2 = (new.data - new.data.min(axis=0)) / (new.data.max(axis=0) - new.data.min(axis=0))
>>> diff = norm1 - norm2
>>> assert np.allclose(diff, 0, atol=1e-6, rtol=1e-4)
>>> #assert np.allclose(self.data[:, 1], new.data[:, 1])
>>> #assert np.allclose(input_dims[0] - self.data[:, 0], new.data[:, 0])
>>> # xdoctest: +REQUIRES(--show)
>>> import kwimage
>>> im = kwimage.imresize(kwimage.grab_test_image(), dsize=input_dims[::-1])
>>> new_im = augmenter.augment_image(im)
>>> import kwplot
>>> plt = kwplot.autoplt()
>>> kwplot.figure(fnum=1, doclf=True)
>>> kwplot.imshow(im, pnum=(1, 2, 1), fnum=1)
>>> self.draw(color='red', alpha=.8, radius=0.5)
>>> kwplot.imshow(new_im, pnum=(1, 2, 2), fnum=1)
>>> new.draw(color='blue', alpha=.8, radius=0.5, coord_axes=[1, 0])
"""
impl = self._impl
new = self if inplace else self.__class__(impl.copy(self.data), self.meta)
kpoi = new.to_imgaug(input_dims=input_dims)
# print('kpoi = {!r}'.format(kpoi))
new_kpoi = augmenter.augment_keypoints(kpoi)
# print('new_kpoi = {!r}'.format(new_kpoi))
dtype = new.data.dtype
if hasattr(new_kpoi, 'to_xy_array'):
# imgaug.__version__ >= 0.2.9
xy = new_kpoi.to_xy_array().astype(dtype)
else:
xy = np.array([[kp.x, kp.y] for kp in new_kpoi.keypoints], dtype=dtype)
new.data = xy
return new
# @profile
[docs]
def to_imgaug(self, input_dims):
"""
Translate to an imgaug object
Returns:
imgaug.KeypointsOnImage: imgaug data structure
Example:
>>> # xdoctest: +REQUIRES(module:imgaug)
>>> import kwimage
>>> import numpy as np
>>> self = kwimage.Coords.random(10)
>>> input_dims = (10, 10)
>>> kpoi = self.to_imgaug(input_dims)
>>> new = kwimage.Coords.from_imgaug(kpoi)
>>> assert np.allclose(new.data, self.data)
"""
import imgaug
if _HAS_IMGAUG_FLIP_BUG:
# Hack to fix imgaug bug
h, w = input_dims
input_dims = (int(h + 1.0), int(w + 1.0))
input_dims = tuple(map(int, input_dims))
if _HAS_IMGAUG_XY_ARRAY:
if hasattr(imgaug, 'Keypoints'):
# make use of new proposal when/if it lands
kps = imgaug.Keypoints(self.data)
kpoi = imgaug.KeypointsOnImage(kps, shape=input_dims)
else:
kpoi = imgaug.KeypointsOnImage.from_xy_array(
self.data, shape=input_dims)
else:
kps = [imgaug.Keypoint(x, y) for x, y in self.data]
kpoi = imgaug.KeypointsOnImage(kps, shape=input_dims)
return kpoi
# Err... not sure abou this function. Punting.
# def to_shapely(self):
# """
# A shapely representation of these coordinates.
# Note:
# This does not return shapely.coords.CoordinateSequence which is
# something more fundamental than what we are doing here. Our
# interpretation of coordinates is still geometric. Unfortunately
# there isn't a perfect correspondence, but LineString is close.
# Returns:
# shapely.geometry.MultiLineString
# Example:
# >>> import kwimage
# >>> self = kwimage.Coords.random(10)
# >>> geom = self.to_shapely()
# >>>
# """
# from shapely import geometry
# new = geometry.LineString(self.data)
# return new
[docs]
@classmethod
def from_imgaug(cls, kpoi):
if _HAS_IMGAUG_XY_ARRAY:
xy = kpoi.to_xy_array()
else:
xy = np.array([[kp.x, kp.y] for kp in kpoi.keypoints])
self = cls(xy)
return self
# @profile
[docs]
def scale(self, factor, about=None, output_dims=None, inplace=False):
"""
Scale coordinates by a factor
Args:
factor (float | Tuple[float, float]):
scale factor as either a scalar or per-dimension tuple.
about (Tuple | None):
if unspecified scales about the origin (0, 0), otherwise the
rotation is about this point.
output_dims (Tuple): unused in non-raster spatial structures
inplace (bool): if True, modifies data inplace
Returns:
Coords: modified coordinates
Example:
>>> from kwimage.structs.coords import * # NOQA
>>> self = Coords.random(10, rng=0)
>>> new = self.scale(10)
>>> assert new.data.max() <= 10
>>> self = Coords.random(10, rng=0)
>>> self.data = (self.data * 10).astype(int)
>>> new = self.scale(10)
>>> assert new.data.dtype.kind == 'i'
>>> new = self.scale(10.0)
>>> assert new.data.dtype.kind == 'f'
"""
impl = self._impl
new = self if inplace else self.__class__(impl.copy(self.data), self.meta)
data = new.data
# if not inplace:
# data = new.data = impl.copy(data)
if impl.numel(data) > 0:
dim = self.dim
if not ub.iterable(factor):
factor_ = impl.asarray([factor] * dim)
elif isinstance(factor, (list, tuple)):
factor_ = impl.asarray(factor)
else:
factor_ = factor
if self._impl.dtype_kind(data) != 'f' and self._impl.dtype_kind(factor_) == 'f':
data = self._impl.astype(data, factor_.dtype)
assert factor_.shape == (dim,)
if about is None:
data *= factor_
else:
about_ = self._rectify_about(about)
data -= about_
data *= factor_
data += about_
new.data = data
return new
# @profile
[docs]
def translate(self, offset, output_dims=None, inplace=False):
"""
Shift the coordinates
Args:
offset (float | Tuple[float, float]):
transation offset as either a scalar or a per-dimension tuple.
output_dims (Tuple): unused in non-raster spatial structures
inplace (bool): if True, modifies data inplace
Returns:
Coords: modified coordinates
Example:
>>> from kwimage.structs.coords import * # NOQA
>>> self = Coords.random(10, dim=3, rng=0)
>>> new = self.translate(10)
>>> assert new.data.min() >= 10
>>> assert new.data.max() <= 11
>>> Coords.random(3, dim=3, rng=0)
>>> Coords.random(3, dim=3, rng=0).translate((1, 2, 3))
"""
impl = self._impl
new = self if inplace else self.__class__(impl.copy(self.data), self.meta)
data = new.data
if not inplace:
data = new.data = impl.copy(data)
if impl.numel(data) > 0:
dim = self.dim
if not ub.iterable(offset):
offset_ = impl.asarray([offset] * dim)
elif isinstance(offset, (list, tuple)):
offset_ = np.array(offset)
else:
offset_ = offset
assert offset_.shape == (dim,)
offset_ = impl.astype(offset_, data.dtype)
data += offset_
return new
# @profile
[docs]
def rotate(self, theta, about=None, output_dims=None, inplace=False):
"""
Rotate the coordinates about a point.
Args:
theta (float):
rotation angle in radians
about (Tuple | None):
if unspecified rotates about the origin (0, 0), otherwise the
rotation is about this point.
output_dims (Tuple): unused in non-raster spatial structures
inplace (bool): if True, modifies data inplace
Returns:
Coords: modified coordinates
TODO:
- [ ] Generalized ND Rotations?
References:
https://math.stackexchange.com/questions/197772/gen-rot-matrix
Example:
>>> from kwimage.structs.coords import * # NOQA
>>> self = Coords.random(10, dim=2, rng=0)
>>> theta = np.pi / 2
>>> new = self.rotate(theta)
>>> # Test rotate agrees with warp
>>> sin_ = np.sin(theta)
>>> cos_ = np.cos(theta)
>>> rot_ = np.array([[cos_, -sin_], [sin_, cos_]])
>>> new2 = self.warp(rot_)
>>> assert np.allclose(new.data, new2.data)
>>> #
>>> # Rotate about a custom point
>>> theta = np.pi / 2
>>> new3 = self.rotate(theta, about=(0.5, 0.5))
>>> #
>>> # Rotate about the center of mass
>>> about = self.data.mean(axis=0)
>>> new4 = self.rotate(theta, about=about)
>>> # xdoctest: +REQUIRES(--show)
>>> # xdoctest: +REQUIRES(module:kwplot)
>>> import kwplot
>>> kwplot.figure(fnum=1, doclf=True)
>>> plt = kwplot.autoplt()
>>> self.draw(radius=0.01, color='blue', alpha=.5, coord_axes=[1, 0], setlim='grow')
>>> plt.gca().set_aspect('equal')
>>> new3.draw(radius=0.01, color='red', alpha=.5, coord_axes=[1, 0], setlim='grow')
"""
if self.dim != 2:
raise NotImplementedError('only 2D rotations for now')
dtype = self.dtype
if isinstance(about, str):
raise NotImplementedError(about)
if about is None:
sin_ = np.sin(theta)
cos_ = np.cos(theta)
rot_ = np.array([[cos_, -sin_],
[sin_, cos_]], dtype=dtype)
else:
about_ = self._rectify_about(about)
"""
# Construct a general closed-form affine matrix about a point
# Shows the symbolic construction of the code
# https://groups.google.com/forum/#!topic/sympy/k1HnZK_bNNA
import sympy
sx, sy, theta, shear_y, shear_x, tx, ty, x0, y0 = sympy.symbols(
'sx, sy, theta, shear_y, shear_x, tx, ty, x0, y0')
# Construct an general origin centered affine matrix
sin_ = sympy.sin(theta)
cos_ = sympy.cos(theta)
R = np.array([[cos_, -sin_, 0],
[sin_, cos_, 0],
[ 0, 0, 1]])
H = np.array([[ 1, shear_x, 0],
[shear_y, 1, 0],
[ 0, 0, 1]])
S = np.array([[sx, 0, 0],
[ 0, sy, 0],
[ 0, 0, 1]])
T = np.array([[1, 0, tx],
[0, 1, ty],
[0, 0, 1]])
# combine simple transformations into an affine transform
Aff_0 = sympy.Matrix(T @ S @ R @ H)
Aff_0 = sympy.simplify(Aff_0)
print(ub.hzcat(['Aff_0 = ', repr(Aff_0)]))
# move to center xy0, apply affine transform, then move back
tr1 = np.array([[1, 0, -x0],
[0, 1, -y0],
[0, 0, 1]])
tr2 = np.array([[1, 0, x0],
[0, 1, y0],
[0, 0, 1]])
AffAbout = tr2 @ Aff_0 @ tr1
AffAbout = sympy.simplify(AffAbout)
print(ub.hzcat(['AffAbout = ', repr(AffAbout)]))
# Get the special case for rotation about
print(repr(AffAbout.subs(dict(shear_x=0, shear_y=0, sx=1, sy=1, tx=0, ty=0))))
"""
x0, y0 = about_
sin_ = np.sin(theta)
cos_ = np.cos(theta)
rot_ = np.array([
[ cos_, -sin_, -x0 * cos_ + y0 * sin_ + x0],
[ sin_, cos_, -x0 * sin_ - y0 * cos_ + y0],
[ 0, 0, 1]])
return self.warp(rot_, output_dims=output_dims, inplace=inplace)
[docs]
def _rectify_about(self, about):
"""
Ensures that about returns a specified point. Allows for special keys
like center to be used.
Example:
>>> from kwimage.structs.coords import * # NOQA
>>> self = Coords.random(10, dim=2, rng=0)
"""
if about is None:
about_ = None
else:
if isinstance(about, str):
if about == 'origin':
about_ = (0, 0)
else:
raise KeyError(about)
else:
about_ = about if ub.iterable(about) else [about] * self.dim
return about_
[docs]
def fill(self, image, value, coord_axes=None, interp='bilinear'):
"""
Sets sub-coordinate locations in a grid to a particular value
Args:
coord_axes (Tuple): specify which image axes each coordinate dim
corresponds to. For 2D images, if you are storing r/c data,
set to [0,1], if you are storing x/y data, set to [1,0].
Returns:
ndarray: image with coordinates rasterized on it
"""
import kwimage
index = self.data
image = kwimage.subpixel_setvalue(image, index, value,
coord_axes=coord_axes, interp=interp)
return image
[docs]
def soft_fill(self, image, coord_axes=None, radius=5):
"""
Used for drawing keypoint truth in heatmaps
Args:
coord_axes (Tuple): specify which image axes each coordinate dim
corresponds to. For 2D images, if you are storing r/c data,
set to [0,1], if you are storing x/y data, set to [1,0].
In other words the i-th entry in coord_axes specifies which
row-major spatial dimension the i-th column of a coordinate
corresponds to. The index is the coordinate dimension and the
value is the axes dimension.
Returns:
ndarray: image with coordinates rasterized on it
References:
https://stackoverflow.com/questions/54726703/generating-keypoint-heatmaps-in-tensorflow
Example:
>>> from kwimage.structs.coords import * # NOQA
>>> s = 64
>>> self = Coords.random(10, meta={'shape': (s, s)}).scale(s)
>>> # Put points on edges to to verify "edge cases"
>>> self.data[1] = [0, 0] # top left
>>> self.data[2] = [s, s] # bottom right
>>> self.data[3] = [0, s + 10] # bottom left
>>> self.data[4] = [-3, s // 2] # middle left
>>> self.data[5] = [s + 1, -1] # top right
>>> # Put points in the middle to verify overlap blending
>>> self.data[6] = [32.5, 32.5] # middle
>>> self.data[7] = [34.5, 34.5] # middle
>>> fill_value = 1
>>> coord_axes = [1, 0]
>>> radius = 10
>>> image1 = np.zeros((s, s))
>>> self.soft_fill(image1, coord_axes=coord_axes, radius=radius)
>>> radius = 3.0
>>> image2 = np.zeros((s, s))
>>> self.soft_fill(image2, coord_axes=coord_axes, radius=radius)
>>> # xdoctest: +REQUIRES(--show)
>>> # xdoctest: +REQUIRES(module:kwplot)
>>> import kwplot
>>> kwplot.autompl()
>>> kwplot.imshow(image1, pnum=(1, 2, 1))
>>> kwplot.imshow(image2, pnum=(1, 2, 2))
"""
import scipy.stats
if radius <= 0:
raise ValueError('radius must be positive')
# OH! How I HATE the squeeze function!
SCIPY_STILL_USING_SQUEEZE_FUNC = True
blend_mode = 'maximum'
image_ndims = len(image.shape)
for pt in self.data:
# Find a grid of coordinates on the image to fill for this point
low = np.floor(pt - radius).astype(int)
high = np.ceil(pt + radius).astype(int)
grid = np.dstack(np.mgrid[tuple(
slice(s, t) for s, t in zip(low, high))])
# Flatten the grid into a list of coordinates to be filled
rows_of_coords = grid.reshape(-1, grid.shape[-1])
# Remove grid coordinates that are out of bounds
lower_bound = np.array([0, 0])
upper_bound = np.array([
image.shape[i] for i in coord_axes
])[None, :]
in_bounds_flags1 = (rows_of_coords >= lower_bound).all(axis=1)
rows_of_coords = rows_of_coords[in_bounds_flags1]
in_bounds_flags2 = (rows_of_coords < upper_bound).all(axis=1)
rows_of_coords = rows_of_coords[in_bounds_flags2]
if len(rows_of_coords) > 0:
# Create a index into the image and insert the columns of
# coordinates to fill into the appropirate dimensions
img_index = [slice(None)] * image_ndims
for axes_idx, coord_col in zip(coord_axes, rows_of_coords.T):
img_index[axes_idx] = coord_col
img_index = tuple(img_index)
# Note: Do we just use kwimage.gaussian_patch for the 2D case
# instead?
# TODO: is there a better method for making a "brush stroke"?
# cov = 0.3 * ((extent - 1) * 0.5 - 1) + 0.8
cov = radius
rv = scipy.stats.multivariate_normal(mean=pt, cov=cov)
new_values = rv.pdf(rows_of_coords)
# the mean will be the maximum values of the normal
# distribution, normalize by that.
max_val = float(rv.pdf(pt))
if SCIPY_STILL_USING_SQUEEZE_FUNC:
# If multivariate_normal was implemented right we would not
# need to check for scalar values
# See: https://github.com/scipy/scipy/issues/7689
if len(rows_of_coords) == 1:
if len(new_values.shape) != 0:
import warnings
warnings.warn(ub.paragraph(
'''
Scipy fixed the bug in multivariate_normal!
We can remove this stupid hack!
'''))
else:
# Ensure new_values is always a list of scalars
new_values = new_values[None]
new_values = new_values / max_val
# Blend the sampled values onto the existing pixels
prev_values = image[img_index]
# HACK: wont generalize?
if len(prev_values.shape) != len(new_values.shape):
new_values = new_values[:, None]
if blend_mode == 'maximum':
blended = np.maximum(prev_values, new_values)
else:
raise KeyError(blend_mode)
# Draw the blended pixels inplace
image[img_index] = blended
return image
[docs]
def draw_on(self, image=None, fill_value=1, coord_axes=[1, 0],
interp='bilinear'):
"""
Note:
unlike other methods, the defaults assume x/y internal data
Args:
coord_axes (Tuple): specify which image axes each coordinate dim
corresponds to. For 2D images, if you are storing r/c data,
set to [0,1], if you are storing x/y data, set to [1,0].
In other words the i-th entry in coord_axes specifies which
row-major spatial dimension the i-th column of a coordinate
corresponds to. The index is the coordinate dimension and the
value is the axes dimension.
Returns:
ndarray: image with coordinates drawn on it
Example:
>>> # xdoctest: +REQUIRES(module:kwplot)
>>> from kwimage.structs.coords import * # NOQA
>>> s = 256
>>> self = Coords.random(10, meta={'shape': (s, s)}).scale(s)
>>> self.data[0] = [10, 10]
>>> self.data[1] = [20, 40]
>>> image = np.zeros((s, s))
>>> fill_value = 1
>>> image = self.draw_on(image, fill_value, coord_axes=[1, 0], interp='bilinear')
>>> # image = self.draw_on(image, fill_value, coord_axes=[0, 1], interp='nearest')
>>> # image = self.draw_on(image, fill_value, coord_axes=[1, 0], interp='bilinear')
>>> # image = self.draw_on(image, fill_value, coord_axes=[1, 0], interp='nearest')
>>> # xdoctest: +REQUIRES(--show)
>>> # xdoctest: +REQUIRES(module:kwplot)
>>> import kwplot
>>> kwplot.autompl()
>>> kwplot.figure(fnum=1, doclf=True)
>>> kwplot.imshow(image)
>>> self.draw(radius=3, alpha=.5, coord_axes=[1, 0])
"""
# import kwimage
if image is None:
shape_ = self._impl.max(self.data, axis=0).astype(int)
shape = tuple((shape_ + 1).tolist())
image = self._impl.zeros(self.meta.get('shape', shape))
image = self.fill(image, fill_value, coord_axes=coord_axes,
interp=interp)
return image
[docs]
def draw(self, color='blue', ax=None, alpha=None, coord_axes=[1, 0],
radius=1, setlim=False):
"""
Draw these coordinates via matplotlib
Note:
unlike other methods, the defaults assume x/y internal data
Args:
setlim (bool): if True ensures the limits of the axes contains the
polygon
coord_axes (Tuple): specify which image axes each coordinate dim
corresponds to. For 2D images,
if you are storing r/c data, set to [0,1],
if you are storing x/y data, set to [1,0].
Returns:
List[mpl.collections.PatchCollection]: drawn matplotlib objects
Example:
>>> # xdoctest: +REQUIRES(module:kwplot)
>>> from kwimage.structs.coords import * # NOQA
>>> self = Coords.random(10)
>>> # xdoctest: +REQUIRES(--show)
>>> import kwplot
>>> plt = kwplot.autoplt()
>>> self.draw(radius=0.05, alpha=0.8)
>>> plt.gca().set_xlim(0, 1)
>>> plt.gca().set_ylim(0, 1)
>>> plt.gca().set_aspect('equal')
"""
import matplotlib as mpl
import kwimage
from matplotlib import pyplot as plt
if ax is None:
ax = plt.gca()
data = self.data
if self.dim != 2:
raise NotImplementedError('need 2d for mpl')
# More grouped patches == more efficient runtime
if alpha is None:
alpha = [1.0] * len(data)
elif not ub.iterable(alpha):
alpha = [alpha] * len(data)
ptcolors = [kwimage.Color(color, alpha=a).as01('rgba') for a in alpha]
color_groups = ub.group_items(range(len(ptcolors)), ptcolors)
default_centerkw = {
'radius': radius,
'fill': True
}
centerkw = default_centerkw.copy()
collections = []
for pcolor, idxs in color_groups.items():
yx_list = [row[coord_axes] for row in data[idxs]]
patches = [
mpl.patches.Circle((x, y), ec=None, fc=pcolor, **centerkw)
for y, x in yx_list
]
col = mpl.collections.PatchCollection(patches, match_original=True)
collections.append(col)
ax.add_collection(col)
if setlim:
x1, y1 = self.data.min(axis=0) - radius
x2, y2 = self.data.max(axis=0) + radius
if setlim == 'grow':
# only allow growth
x1_, x2_ = ax.get_xlim()
y1_, y2_ = ax.get_ylim()
x1 = min(x1_, x1)
x2 = max(x2_, x2)
y1 = min(y1_, y1)
y2 = max(y2_, y2)
ax.set_xlim(x1, x2)
ax.set_ylim(y1, y2)
return collections
if __name__ == '__main__':
"""
CommandLine:
python -m kwimage.structs.coords all
"""
import xdoctest
xdoctest.doctest_module(__file__)