Source code for kwimage.transform

"""
Objects for representing and manipulating image transforms.
"""
import ubelt as ub
import numpy as np
import kwarray
import skimage.transform


[docs]class Transform(ub.NiceRepr): pass
[docs]class Matrix(Transform): """ Base class for matrix-based transform. Example: >>> from kwimage.transform import * # NOQA >>> ms = {} >>> ms['random()'] = Matrix.random() >>> ms['eye()'] = Matrix.eye() >>> ms['random(3)'] = Matrix.random(3) >>> ms['random(4, 4)'] = Matrix.random(4, 4) >>> ms['eye(3)'] = Matrix.eye(3) >>> ms['explicit'] = Matrix(np.array([[1.618]])) >>> for k, m in ms.items(): >>> print('----') >>> print(f'{k} = {m}') >>> print(f'{k}.inv() = {m.inv()}') >>> print(f'{k}.T = {m.T}') >>> print(f'{k}.det() = {m.det()}') """ def __init__(self, matrix): self.matrix = matrix
[docs] def __nice__(self): return repr(self.matrix)
[docs] def __repr__(self): return self.__str__()
@property
[docs] def shape(self): if self.matrix is None: # Default shape is hard coded here, can be overrided (e.g. Affine) return (2, 2) return self.matrix.shape
[docs] def __json__(self): if self.matrix is None: return {'type': 'matrix', 'matrix': None} else: return {'type': 'matrix', 'matrix': self.matrix.tolist()}
@classmethod
[docs] def coerce(cls, data=None, **kwargs): """ Example: >>> Matrix.coerce({'type': 'matrix', 'matrix': [[1, 0, 0], [0, 1, 0]]}) >>> Matrix.coerce(np.eye(3)) >>> Matrix.coerce(None) """ if data is None and not kwargs: return cls(matrix=None) if data is None: data = kwargs if isinstance(data, np.ndarray): self = cls(matrix=data) elif isinstance(data, cls): self = data elif data.__class__.__name__ == cls.__name__: self = data elif isinstance(data, dict): keys = set(data.keys()) if 'matrix' in keys: self = cls(matrix=np.array(data['matrix'])) else: raise KeyError(', '.join(list(data.keys()))) else: raise TypeError(type(data)) return self
[docs] def __array__(self): """ Allow this object to be passed to np.asarray References: https://numpy.org/doc/stable/user/basics.dispatch.html """ if self.matrix is None: return np.eye(*self.shape) return self.matrix
[docs] def __imatmul__(self, other): if isinstance(other, np.ndarray): other_matrix = other else: other_matrix = other.matrix if self.matrix is None: self.matrix = other_matrix else: self.matrix @= other_matrix
[docs] def __matmul__(self, other): """ Example: >>> m = {} >>> # Works, and returns a Matrix >>> m[len(m)] = x = Matrix.random() @ np.eye(2) >>> assert isinstance(x, Matrix) >>> m[len(m)] = x = Matrix.random() @ None >>> assert isinstance(x, Matrix) >>> # Works, and returns an ndarray >>> m[len(m)] = x = np.eye(3) @ Matrix.random(3) >>> assert isinstance(x, np.ndarray) >>> # These do not work >>> # m[len(m)] = None @ Matrix.random() >>> # m[len(m)] = np.eye(3) @ None >>> print('m = {}'.format(ub.repr2(m))) """ if other is None: return self if self.matrix is None: return self.__class__.coerce(other) if isinstance(other, np.ndarray): return self.__class__(self.matrix @ other) elif other.matrix is None: return self elif isinstance(other, self.__class__): # Prefer using the type of the left-hand-side, but try # not to break group rules. return self.__class__(self.matrix @ other.matrix) elif isinstance(self, other.__class__): return other.__class__(self.matrix @ other.matrix) else: raise TypeError('{} @ {}'.format(type(self), type(other)))
[docs] def inv(self): """ Returns the inverse of this matrix Returns: Matrix """ if self.matrix is None: return self.__class__(None) else: return self.__class__(np.linalg.inv(self.matrix))
@property
[docs] def T(self): """ Transpose the underlying matrix """ if self.matrix is None: return self else: return self.__class__(self.matrix.T)
[docs] def det(self): """ Compute the determinant of the underlying matrix Returns: float """ if self.matrix is None: return 1. else: return np.linalg.det(self.matrix)
@classmethod
[docs] def eye(cls, shape=None, rng=None): """ Construct an identity """ self = cls(None) if isinstance(shape, int): shape = (shape, shape) if shape is None: shape = self.shape self.matrix = np.eye(*shape) return self
@classmethod
[docs] def random(cls, shape=None, rng=None): import kwarray rng = kwarray.ensure_rng(rng) self = cls(None) if isinstance(shape, int): shape = (shape, shape) if shape is None: shape = self.shape self.matrix = rng.rand(*shape) return self
[docs]class Linear(Matrix): pass
[docs]class Projective(Linear): """ Currently just a stub class that may be used to implement projective / homography transforms in the future. """ pass
[docs]class Affine(Projective): """ Helper for making affine transform matrices. Example: >>> self = Affine(np.eye(3)) >>> m1 = np.eye(3) @ self >>> m2 = self @ np.eye(3) Example: >>> from kwimage.transform import * # NOQA >>> m = {} >>> # Works, and returns a Affine >>> m[len(m)] = x = Affine.random() @ np.eye(3) >>> assert isinstance(x, Affine) >>> m[len(m)] = x = Affine.random() @ None >>> assert isinstance(x, Affine) >>> # Works, and returns an ndarray >>> m[len(m)] = x = np.eye(3) @ Affine.random(3) >>> assert isinstance(x, np.ndarray) >>> # Works, and returns an Matrix >>> m[len(m)] = x = Affine.random() @ Matrix.random(3) >>> assert isinstance(x, Matrix) >>> m[len(m)] = x = Matrix.random(3) @ Affine.random() >>> assert isinstance(x, Matrix) >>> print('m = {}'.format(ub.repr2(m))) """ @property
[docs] def shape(self): return (3, 3)
[docs] def __getitem__(self, index): if self.matrix is None: return np.asarray(self)[index] return self.matrix[index]
[docs] def __json__(self): if self.matrix is None: return {'type': 'affine', 'matrix': None} else: return {'type': 'affine', 'matrix': self.matrix.tolist()}
[docs] def concise(self): """ Return a concise coercable dictionary representation of this matrix Returns: Dict[str, object]: a small serializable dict that can be passed to :func:`Affine.coerce` to reconstruct this object. Returns: Dict: dictionary with consise parameters Example: >>> self = Affine.random(rng=0, scale=1) >>> params = self.concise() >>> assert np.allclose(Affine.coerce(params).matrix, self.matrix) >>> print('params = {}'.format(ub.repr2(params, nl=1, precision=2))) params = { 'offset': (0.08, 0.38), 'theta': 0.08, 'type': 'affine', } Example: >>> self = Affine.random(rng=0, scale=2, offset=0) >>> params = self.concise() >>> assert np.allclose(Affine.coerce(params).matrix, self.matrix) >>> print('params = {}'.format(ub.repr2(params, nl=1, precision=2))) params = { 'scale': 2.00, 'theta': 0.04, 'type': 'affine', } """ params = self.decompose() params['type'] = 'affine' if np.allclose(params['offset'], (0, 0)): params.pop('offset') elif ub.allsame(params['offset']): params['offset'] = params['offset'][0] if np.allclose(params['scale'], (1, 1)): params.pop('scale') elif ub.allsame(params['scale']): params['scale'] = params['scale'][0] if np.allclose(params['shear'], 0): params.pop('shear') if np.allclose(params['theta'], 0): params.pop('theta') return params
@classmethod
[docs] def coerce(cls, data=None, **kwargs): """ Attempt to coerce the data into an affine object Args: data : some data we attempt to coerce to an Affine matrix **kwargs : some data we attempt to coerce to an Affine matrix, mutually exclusive with `data`. Returns: Affine Example: >>> import kwimage >>> kwimage.Affine.coerce({'type': 'affine', 'matrix': [[1, 0, 0], [0, 1, 0]]}) >>> kwimage.Affine.coerce({'scale': 2}) >>> kwimage.Affine.coerce({'offset': 3}) >>> kwimage.Affine.coerce(np.eye(3)) >>> kwimage.Affine.coerce(None) >>> kwimage.Affine.coerce(skimage.transform.AffineTransform(scale=30)) """ if data is None and not kwargs: return cls(matrix=None) if data is None: data = kwargs if isinstance(data, np.ndarray): self = cls(matrix=data) elif isinstance(data, cls): self = data elif isinstance(data, skimage.transform.AffineTransform): self = cls(matrix=data.params) elif data.__class__.__name__ == cls.__name__: self = data elif isinstance(data, dict): keys = set(data.keys()) known_params = {'scale', 'shear', 'offset', 'theta', 'type'} params = ub.dict_isect(data, known_params) if 'matrix' in keys: self = cls(matrix=np.array(data['matrix'])) elif len(known_params & keys): params.pop('type', None) self = cls.affine(**params) else: raise KeyError(', '.join(list(data.keys()))) else: raise TypeError(type(data)) return self
[docs] def decompose(self): """ Decompose the affine matrix into its individual scale, translation, rotation, and skew parameters. Returns: Dict: decomposed offset, scale, theta, and shear params References: https://math.stackexchange.com/questions/612006/decompose-affine Example: >>> self = Affine.random() >>> params = self.decompose() >>> recon = Affine.coerce(**params) >>> params2 = recon.decompose() >>> pt = np.vstack([np.random.rand(2, 1), [1]]) >>> result1 = self.matrix[0:2] @ pt >>> result2 = recon.matrix[0:2] @ pt >>> assert np.allclose(result1, result2) >>> self = Affine.scale(0.001) @ Affine.random() >>> params = self.decompose() >>> self.det() """ a11, a12, a13, a21, a22, a23 = self.matrix.ravel()[0:6] sx = np.sqrt(a11 ** 2 + a21 ** 2) theta = np.arctan2(a21, a11) sin_t = np.sin(theta) cos_t = np.cos(theta) msy = a12 * cos_t + a22 * sin_t if abs(cos_t) < abs(sin_t): sy = (msy * cos_t - a12) / sin_t else: sy = (a22 - msy * sin_t) / cos_t m = msy / sy tx, ty = a13, a23 params = { 'offset': (tx, ty), 'scale': (sx, sy), 'shear': m, 'theta': theta, } return params
@classmethod
[docs] def scale(cls, scale): """ Create a scale Affine object Args: scale (float | Tuple[float, float]): x, y scale factor Returns: Affine """ return cls.affine(scale=scale)
@classmethod
[docs] def translate(cls, offset): """ Create a translation Affine object Args: offset (float | Tuple[float, float]): x, y translation factor Returns: Affine """ return cls.affine(offset=offset)
@classmethod
[docs] def rotate(cls, theta): """ Create a rotation Affine object Args: theta (float): counter-clockwise rotation angle in radians Returns: Affine """ return cls.affine(theta=theta)
@classmethod
[docs] def random(cls, rng=None, **kw): """ Create a random Affine object Args: rng : random number generator **kw: passed to :func:`Affine.random_params`. can contain coercable random distributions for scale, offset, about, theta, and shear. Returns: Affine """ params = cls.random_params(rng=rng, **kw) self = cls.affine(**params) return self
@classmethod
[docs] def random_params(cls, rng=None, **kw): """ Args: rng : random number generator **kw: can contain coercable random distributions for scale, offset, about, theta, and shear. Returns: Dict: affine parameters suitable to be passed to Affine.affine TODO: - [ ] improve kwargs parameterization """ from kwarray import distributions import numbers TN = distributions.TruncNormal rng = kwarray.ensure_rng(rng) def _coerce_distri(arg): if isinstance(arg, numbers.Number): dist = distributions.Constant(arg, rng=rng) elif isinstance(arg, tuple) and len(arg) == 2: lo, hi = arg dist = distributions.Uniform(lo, hi, rng=rng) else: raise NotImplementedError return dist if 'scale' in kw: if ub.iterable(kw['scale']): raise NotImplementedError else: xscale_dist = _coerce_distri(kw['scale']) yscale_dist = xscale_dist else: scale_kw = dict(mean=1, std=1, low=1, high=2) xscale_dist = TN(**scale_kw, rng=rng) yscale_dist = TN(**scale_kw, rng=rng) if 'offset' in kw: if ub.iterable(kw['offset']): raise NotImplementedError else: xoffset_dist = _coerce_distri(kw['offset']) yoffset_dist = xoffset_dist else: offset_kw = dict(mean=0, std=1, low=-1, high=1) xoffset_dist = TN(**offset_kw, rng=rng) yoffset_dist = TN(**offset_kw, rng=rng) if 'about' in kw: if ub.iterable(kw['about']): raise NotImplementedError else: xabout_dist = _coerce_distri(kw['about']) yabout_dist = xabout_dist else: xabout_dist = distributions.Constant(0, rng=rng) yabout_dist = distributions.Constant(0, rng=rng) if 'theta' in kw: theta_dist = _coerce_distri(kw['theta']) else: theta_kw = dict(mean=0, std=1, low=-np.pi / 8, high=np.pi / 8) theta_dist = TN(**theta_kw, rng=rng) if 'shear' in kw: shear_dist = _coerce_distri(kw['shear']) else: shear_dist = distributions.Constant(0, rng=rng) # scale_kw = dict(mean=1, std=1, low=0, high=2) # offset_kw = dict(mean=0, std=1, low=-1, high=1) # theta_kw = dict(mean=0, std=1, low=-6.28, high=6.28) # TODO: distributions.Distribution.coerce() # offset_dist = distributions.Constant(0) # theta_dist = distributions.Constant(0) # todo better parametarization params = dict( scale=(xscale_dist.sample(), yscale_dist.sample()), offset=(xoffset_dist.sample(), yoffset_dist.sample()), theta=theta_dist.sample(), shear=shear_dist.sample(), about=(xabout_dist.sample(), yabout_dist.sample()), ) return params
@classmethod
[docs] def affine(cls, scale=None, offset=None, theta=None, shear=None, about=None): """ Create an affine matrix from high-level parameters Args: scale (float | Tuple[float, float]): x, y scale factor offset (float | Tuple[float, float]): x, y translation factor theta (float): counter-clockwise rotation angle in radians shear (float): counter-clockwise shear angle in radians about (float | Tuple[float, float]): x, y location of the origin Returns: Affine: the constructed Affine object Example: >>> rng = kwarray.ensure_rng(None) >>> scale = rng.randn(2) * 10 >>> offset = rng.randn(2) * 10 >>> about = rng.randn(2) * 10 >>> theta = rng.randn() * 10 >>> shear = rng.randn() * 10 >>> # Create combined matrix from all params >>> F = Affine.affine( >>> scale=scale, offset=offset, theta=theta, shear=shear, >>> about=about) >>> # Test that combining components matches >>> S = Affine.affine(scale=scale) >>> T = Affine.affine(offset=offset) >>> R = Affine.affine(theta=theta) >>> H = Affine.affine(shear=shear) >>> O = Affine.affine(offset=about) >>> # combine (note shear must be on the RHS of rotation) >>> alt = O @ T @ R @ H @ S @ O.inv() >>> print('F = {}'.format(ub.repr2(F.matrix.tolist(), nl=1))) >>> print('alt = {}'.format(ub.repr2(alt.matrix.tolist(), nl=1))) >>> assert np.all(np.isclose(alt.matrix, F.matrix)) >>> pt = np.vstack([np.random.rand(2, 1), [[1]]]) >>> warp_pt1 = (F.matrix @ pt) >>> warp_pt2 = (alt.matrix @ pt) >>> assert np.allclose(warp_pt2, warp_pt1) Sympy: >>> # xdoctest: +SKIP >>> import sympy >>> # Shows the symbolic construction of the code >>> # https://groups.google.com/forum/#!topic/sympy/k1HnZK_bNNA >>> from sympy.abc import theta >>> x0, y0, sx, sy, theta, shear, tx, ty = sympy.symbols( >>> 'x0, y0, sx, sy, theta, shear, tx, ty') >>> # move the center to 0, 0 >>> tr1_ = np.array([[1, 0, -x0], >>> [0, 1, -y0], >>> [0, 0, 1]]) >>> # Define core components of the affine transform >>> S = np.array([ # scale >>> [sx, 0, 0], >>> [ 0, sy, 0], >>> [ 0, 0, 1]]) >>> H = np.array([ # shear >>> [1, -sympy.sin(shear), 0], >>> [0, sympy.cos(shear), 0], >>> [0, 0, 1]]) >>> R = np.array([ # rotation >>> [sympy.cos(theta), -sympy.sin(theta), 0], >>> [sympy.sin(theta), sympy.cos(theta), 0], >>> [ 0, 0, 1]]) >>> T = np.array([ # translation >>> [ 1, 0, tx], >>> [ 0, 1, ty], >>> [ 0, 0, 1]]) >>> # Contruct the affine 3x3 about the origin >>> aff0 = np.array(sympy.simplify(T @ R @ H @ S)) >>> # move 0, 0 back to the specified origin >>> tr2_ = np.array([[1, 0, x0], >>> [0, 1, y0], >>> [0, 0, 1]]) >>> # combine transformations >>> aff = tr2_ @ aff0 @ tr1_ >>> print('aff = {}'.format(ub.repr2(aff.tolist(), nl=1))) """ scale_ = 1 if scale is None else scale offset_ = 0 if offset is None else offset shear_ = 0 if shear is None else shear theta_ = 0 if theta is None else theta about_ = 0 if about is None else about sx, sy = _ensure_iterablen(scale_, 2) tx, ty = _ensure_iterablen(offset_, 2) x0, y0 = _ensure_iterablen(about_, 2) # Make auxially varables to reduce the number of sin/cos calls cos_theta = np.cos(theta_) sin_theta = np.sin(theta_) cos_shear_p_theta = np.cos(shear_ + theta_) sin_shear_p_theta = np.sin(shear_ + theta_) sx_cos_theta = sx * cos_theta sx_sin_theta = sx * sin_theta sy_sin_shear_p_theta = sy * sin_shear_p_theta sy_cos_shear_p_theta = sy * cos_shear_p_theta tx_ = tx + x0 - (x0 * sx_cos_theta) + (y0 * sy_sin_shear_p_theta) ty_ = ty + y0 - (x0 * sx_sin_theta) - (y0 * sy_cos_shear_p_theta) # Sympy simplified expression mat = np.array([[sx_cos_theta, -sy_sin_shear_p_theta, tx_], [sx_sin_theta, sy_cos_shear_p_theta, ty_], [ 0, 0, 1]]) self = cls(mat) return self
[docs]def _ensure_iterablen(scalar, n): try: iter(scalar) except TypeError: return [scalar] * n return scalar