Source code for kwimage.transform

"""
Objects for representing and manipulating image transforms.

XDEV_PROFILE=1 xdoctest ~/code/kwimage/kwimage/transform.py
"""
import ubelt as ub
import numpy as np
import kwarray
import skimage.transform
import math
from kwimage import _internal

__all__ = [
    'Transform', 'Matrix', 'Linear', 'Affine', 'Projective'
]


__docstubs__ = """
import affine
"""

try:
    from line_profiler import profile
except Exception:
    profile = ub.identity


[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 def __nice__(self): prefix = '<{}('.format(self.__class__.__name__) if isinstance(self.matrix, np.ndarray): return np.array2string(self.matrix, separator=', ', prefix=prefix) elif self.matrix is None: return 'eye' else: return ub.urepr(self.matrix.tolist(), nl=1) def __repr__(self): return self.__str__() @property 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 def __json__(self): if self.matrix is None: return {'type': 'matrix', 'matrix': None} else: return {'type': 'matrix', 'matrix': self.matrix.tolist()}
[docs] @classmethod 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
def __array__(self): """ Allow this object to be passed to np.asarray. See [NumpyDispatch]_ for details. References: ..[NumpyDispatch] https://numpy.org/doc/stable/user/basics.dispatch.html """ if self.matrix is None: return np.eye(*self.shape) return self.matrix 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 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.urepr(m))) Example: >>> # Test with rationals >>> # xdoctest: +REQUIRES(module:sympy) >>> import kwimage >>> a = kwimage.Matrix.random((3, 3)).rationalize() >>> b = kwimage.Matrix.random((3, 3)).rationalize() >>> c = kwimage.Matrix.random((3, 3)) >>> assert not c.is_rational() >>> assert (a @ c).is_rational() >>> assert (c @ a).is_rational() Ignore: %timeit c.inv() %timeit a.inv() """ 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 is_rational(self): """ TODO: rename to "is_symbolic" """ if sympy is None: return False return isinstance(self.matrix, sympy.Matrix) and isinstance(self.matrix[0, 0], (sympy.Rational, sympy.core.symbol.Symbol, sympy.core.basic.Basic))
[docs] def inv(self): """ Returns the inverse of this matrix Returns: Matrix Example: >>> # Test with rationals >>> # xdoctest: +REQUIRES(module:sympy) >>> import kwimage >>> self = kwimage.Matrix.random((3, 3)).rationalize() >>> inv = self.inv() >>> eye = self @ inv >>> eye.isclose_identity(0, 0) """ if self.matrix is None: return self.__class__(None) else: try: inv_mat = np.linalg.inv(self.matrix) except (np.linalg.LinAlgError, np.core._exceptions.UFuncTypeError): if self.is_rational(): # inv_mat = mp.inverse(self.matrix) # handle object arrays (rationals) inv_mat = self.matrix.inv() else: raise return self.__class__(inv_mat)
@property 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: try: det = np.linalg.det(self.matrix) except np.core._exceptions.UFuncTypeError: # handle object arrays (rationals) det = self.matrix.det() return det
[docs] @classmethod 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
[docs] @classmethod 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
def __getitem__(self, index): if self.matrix is None: return np.asarray(self)[index] return self.matrix[index]
[docs] def rationalize(self): """ Convert the underlying matrix to a rational type to avoid floating point errors. This does decrease efficiency. TODO: - [ ] mpmath for arbitrary precision? It doesn't seem to do inverses correct whereas sympy does. Ignore: from sympy import Rational from fractions import Fraction float_mat = np.random.rand(3, 3) float_num = float_mat[0, 0] frac_num = Fraction(float_num) rat_num = Rational(float_num) rat_mat_v1 = float_mat.astype(Rational, subok=False) frac_mat_v1 = float_mat.astype(Fraction, subok=False) rat_num_v1 = rat_mat_v1[0, 0] frac_num_v1 = frac_mat_v1[0, 0] print(f'{type(float_num)=}') print(f'{type(rat_num)=}') print(f'{type(frac_num)=}') print(f'{type(rat_num_v1)=}') print(f'{type(frac_num_v1)=}') flat_rat = list(map(Rational, float_mat.ravel().tolist())) from sympy import Matrix rat_mat_v2 = Matrix(flat_rat).reshape(*float_mat.shape) Example: >>> # xdoctest: +REQUIRES(module:sympy) >>> import kwimage >>> self = kwimage.Matrix.random((3, 3)) >>> mat = self.rationalize() >>> mat2 = kwimage.Matrix.random((3, 3)) >>> mat3 = mat @ mat2 >>> #assert 'sympy' in mat3.matrix.__class__.__module__ >>> mat3 = mat2 @ mat >>> #assert 'sympy' in mat3.matrix.__class__.__module__ >>> assert not mat.isclose_identity() >>> assert (mat @ mat.inv()).isclose_identity(rtol=0, atol=0) """ if self.matrix is None: new_mat = self.matrix else: new_mat = _RationalNDArray.from_numpy(self.matrix) new = self.__class__(new_mat) return new
[docs] def astype(self, dtype): """ Convert the underlying matrix to a rational type to avoid floating point errors. This does decrease efficiency. Args: dtype (type): """ if self.matrix is None: new_mat = self.matrix else: new_mat = self.matrix.astype(dtype) new = self.__class__(new_mat) return new
[docs] @profile def isclose_identity(self, rtol=1e-05, atol=1e-08): """ Returns true if the matrix is nearly the identity. """ if self.matrix is None: return True else: eye = np.eye(*self.matrix.shape) try: return np.allclose(self.matrix, eye, rtol=rtol, atol=atol) except TypeError: if self.is_rational(): residual = np.array(self.matrix - eye).astype(float) return np.allclose(residual, 0, rtol=rtol, atol=atol) else: raise
[docs] class Linear(Matrix): pass
[docs] class Projective(Linear): """ A thin wraper around a 3x3 matrix that represent a projective transform Implements methods for: * creating random projective transforms * decomposing the matrix * finding a best-fit transform between corresponding points * TODO: - [ ] fully rational transform Example: >>> import kwimage >>> import math >>> image = kwimage.grab_test_image() >>> theta = 0.123 * math.tau >>> components = { >>> 'rotate': kwimage.Projective.projective(theta=theta), >>> 'scale': kwimage.Projective.projective(scale=0.5), >>> 'shear': kwimage.Projective.projective(shearx=0.2), >>> 'translation': kwimage.Projective.projective(offset=(100, 200)), >>> 'rotate+translate': kwimage.Projective.projective(theta=0.123 * math.tau, about=(256, 256)), >>> 'perspective': kwimage.Projective.projective(uv=(0.0003, 0.0007)), >>> 'random-composed': kwimage.Projective.random(scale=(0.5, 1.5), translate=(-20, 20), theta=(-theta, theta), shearx=(0, .4), rng=900558176210808600), >>> } >>> warp_stack = [] >>> for key, mat in components.items(): ... warp = kwimage.warp_projective(image, mat) ... warp = kwimage.draw_text_on_image( ... warp, ... ub.urepr(mat.matrix, nl=1, nobr=1, precision=4, si=1, sv=1, with_dtype=0), ... org=(1, 1), ... valign='top', halign='left', ... fontScale=0.8, color='kw_green', ... border={'thickness': 3}, ... ) ... warp = kwimage.draw_header_text(warp, key, color='kw_blue') ... warp_stack.append(warp) >>> warp_canvas = kwimage.stack_images_grid(warp_stack, chunksize=4, pad=10, bg_value='kitware_gray') >>> # xdoctest: +REQUIRES(module:sympy) >>> import sympy >>> # Shows the symbolic construction of the code >>> # https://groups.google.com/forum/#!topic/sympy/k1HnZK_bNNA >>> from sympy.abc import theta >>> params = x0, y0, sx, sy, theta, shearx, tx, ty, u, v = sympy.symbols( >>> 'x0, y0, sx, sy, theta, ex, tx, ty, u, v') >>> # move the center to 0, 0 >>> tr1_ = sympy.Matrix([[1, 0, -x0], >>> [0, 1, -y0], >>> [0, 0, 1]]) >>> P = sympy.Matrix([ # projective part >>> [ 1, 0, 0], >>> [ 0, 1, 0], >>> [ u, v, 1]]) >>> # Define core components of the affine transform >>> S = sympy.Matrix([ # scale >>> [sx, 0, 0], >>> [ 0, sy, 0], >>> [ 0, 0, 1]]) >>> E = sympy.Matrix([ # x-shear >>> [1, shearx, 0], >>> [0, 1, 0], >>> [0, 0, 1]]) >>> R = sympy.Matrix([ # rotation >>> [sympy.cos(theta), -sympy.sin(theta), 0], >>> [sympy.sin(theta), sympy.cos(theta), 0], >>> [ 0, 0, 1]]) >>> T = sympy.Matrix([ # translation >>> [ 1, 0, tx], >>> [ 0, 1, ty], >>> [ 0, 0, 1]]) >>> # move 0, 0 back to the specified origin >>> tr2_ = sympy.Matrix([[1, 0, x0], >>> [0, 1, y0], >>> [0, 0, 1]]) >>> # combine transformations >>> homog_ = sympy.MatMul(tr2_, T, R, E, S, P, tr1_) >>> #with sympy.evaluate(False): >>> # homog_ = sympy.MatMul(tr2_, T, R, E, S, P, tr1_) >>> # sympy.pprint(homog_) >>> homog = homog_.doit() >>> #sympy.pprint(homog) >>> print('homog = {}'.format(ub.urepr(homog.tolist(), nl=1))) >>> # This could be prettier >>> texts = { >>> 'Translation': sympy.pretty(R, use_unicode=0), >>> 'Rotation': sympy.pretty(R, use_unicode=0), >>> 'shEar-X': sympy.pretty(E, use_unicode=0), >>> 'Scale': sympy.pretty(S, use_unicode=0), >>> 'Perspective': sympy.pretty(P, use_unicode=0), >>> } >>> print(ub.urepr(texts, nl=2, sv=1)) >>> equation_stack = [] >>> for text, m in texts.items(): >>> render_canvas = kwimage.draw_text_on_image(None, m, color='kw_green', fontScale=1.0) >>> render_canvas = kwimage.draw_header_text(render_canvas, text, color='kw_blue') >>> render_canvas = kwimage.imresize(render_canvas, scale=1.3) >>> equation_stack.append(render_canvas) >>> equation_canvas = kwimage.stack_images(equation_stack, pad=10, axis=1, bg_value='kitware_gray') >>> render_canvas = kwimage.draw_text_on_image(None, sympy.pretty(homog, use_unicode=0), color='kw_green', fontScale=1.0) >>> render_canvas = kwimage.draw_header_text(render_canvas, 'Full Equation With Pre-Shift', color='kw_blue') >>> # xdoctest: -REQUIRES(module:sympy) >>> # xdoctest: +REQUIRES(--show) >>> import kwplot >>> plt = kwplot.autoplt() >>> canvas = kwimage.stack_images([warp_canvas, equation_canvas, render_canvas], pad=20, axis=0, bg_value='kitware_gray', resize='larger') >>> canvas = kwimage.draw_header_text(canvas, 'Projective matrixes can represent', color='kw_blue') >>> kwplot.imshow(canvas) >>> fig = plt.gcf() >>> fig.set_size_inches(13, 13) """ # References: # .. [AffineDecompColab] https://colab.research.google.com/drive/1ImBB-N6P9zlNMCBH9evHD6tjk0dzvy1_
[docs] @classmethod def fit(cls, pts1, pts2): """ Fit an projective transformation between a set of corresponding points. See [HomogEst]_ [SzeleskiBook]_ and [RansacDummies]_ for references on the subject. Args: pts1 (ndarray): An Nx2 array of points in "space 1". pts2 (ndarray): A corresponding Nx2 array of points in "space 2" Returns: Projective : a transform that warps from "space1" to "space2". Note: A projective matrix has 8 degrees of freedome, so at least 8 point pairs are needed. References: .. [HomogEst] http://dip.sun.ac.za/~stefan/TW793/attach/notes/homography_estimation.pdf .. [SzeleskiBook] http://szeliski.org/Book/drafts/SzeliskiBook_20100903_draft.pdf Page 317 .. [RansacDummies] http://vision.ece.ucsb.edu/~zuliani/Research/RANSAC/docs/RANSAC4Dummies.pdf page 53 Example: >>> # Create a set of points, warp them, then recover the warp >>> import kwimage >>> points = kwimage.Points.random(9).scale(64) >>> A1 = kwimage.Affine.affine(scale=0.9, theta=-3.2, offset=(2, 3), about=(32, 32), skew=2.3) >>> A2 = kwimage.Affine.affine(scale=0.8, theta=0.8, offset=(2, 0), about=(32, 32)) >>> A12_real = A2 @ A1.inv() >>> points1 = points.warp(A1) >>> points2 = points.warp(A2) >>> # Make the correspondence non-affine >>> points2.data['xy'].data[0, 0] += 3.5 >>> points2.data['xy'].data[3, 1] += 8.5 >>> # Recover the warp >>> pts1, pts2 = points1.xy, points2.xy >>> A_recovered = kwimage.Projective.fit(pts1, pts2) >>> #assert np.all(np.isclose(A_recovered.matrix, A12_real.matrix)) >>> # xdoctest: +REQUIRES(--show) >>> import cv2 >>> import kwplot >>> kwplot.autompl() >>> base1 = np.zeros((96, 96, 3)) >>> base1[32:-32, 5:-5] = 0.5 >>> base2 = np.zeros((96, 96, 3)) >>> img1 = points1.draw_on(base1, radius=3, color='blue') >>> img2 = points2.draw_on(base2, radius=3, color='green') >>> img1_warp = kwimage.warp_projective(img1, A_recovered.matrix, dsize=img1.shape[0:2][::-1]) >>> canvas = kwimage.stack_images([img1, img2, img1_warp], pad=10, axis=1, bg_value=(1., 1., 1.)) >>> kwplot.imshow(canvas) """ if 0: import cv2 inlier_method = 'all' inlier_method_lut = { 'all': 0, 'lmeds': cv2.LMEDS, 'ransac': cv2.RANSAC, 'prosac': cv2.RHO, } cv2_method = inlier_method_lut[inlier_method] # This probably does the point normaliztion internally, # but I'm not sure H, mask = cv2.findHomography(pts1, pts2, method=cv2_method) return Projective(H) else: def whiten_xy_points(xy_m): """ whitens points to mean=0, stddev=1 and returns transformation """ mu_xy = xy_m.mean(axis=1) # center of mass std_xy = xy_m.std(axis=1) std_xy[std_xy == 0] = 1 # prevent divide by zero tx, ty = -mu_xy / std_xy sx, sy = 1 / std_xy T = np.array([(sx, 0, tx), (0, sy, ty), (0, 0, 1)]) xy_norm = ((xy_m.T - mu_xy) / std_xy).T return xy_norm, T # Hartley Precondition (to reduce sensitivity to noise) xy1_mn, T1 = whiten_xy_points(pts1.T) xy2_mn, T2 = whiten_xy_points(pts2.T) # xy1_mn = pts1.T # xy2_mn = pts2.T x1_mn = xy1_mn[0] y1_mn = xy1_mn[1] x2_mn = xy2_mn[0] y2_mn = xy2_mn[1] num_pts = x1_mn.shape[0] # Concatenate all 2x9 matrices into an Mx9 matrix Mx9 = np.empty((2 * num_pts, 9), dtype=float) for ix in range(num_pts): u2 = x2_mn[ix] v2 = y2_mn[ix] x1 = x1_mn[ix] y1 = y1_mn[ix] (d, e, f) = ( -x1, -y1, -1) (g, h, i) = ( v2 * x1, v2 * y1, v2) (j, k, l) = ( x1, y1, 1) (p, q, r) = (-u2 * x1, -u2 * y1, -u2) Mx9[ix * 2] = (0, 0, 0, d, e, f, g, h, i) Mx9[ix * 2 + 1] = (j, k, l, 0, 0, 0, p, q, r) M = (Mx9.T @ Mx9) # M = Mx9 try: # https://math.stackexchange.com/questions/772039/how-does-the-svd-solve-the-least-squares-problem/2173715#2173715 # http://twistedoakstudios.com/blog/Post7254_visualizing-the-eigenvectors-of-a-rotation USVt = np.linalg.svd(M, full_matrices=True, compute_uv=True) except MemoryError: import scipy.sparse as sps import scipy.sparse.linalg as spsl M_sparse = sps.lil_matrix(M) USVt = spsl.svds(M_sparse) except np.linalg.LinAlgError: raise except Exception: raise # U is the co-domain unitary matrix # V is the domain unitary matrix # s contains the singular values U, s, Vt = USVt # The column of V (row of Vt) corresponding to the lowest singular # value is the solution to the least squares problem H_prime = Vt[8].reshape(3, 3) # Then compute ax = b [aka: x = npl.solve(a, b)] M = np.linalg.inv(T2) @ H_prime @ T1 # Unnormalize # homographies that only differ by a scale factor are equivalent M /= M[2, 2] return Projective(M)
[docs] @classmethod def projective(cls, scale=None, offset=None, shearx=None, theta=None, uv=None, about=None): """ Reconstruct from parameters 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 >>> params = x0, y0, sx, sy, theta, shearx, tx, ty, u, v = sympy.symbols( >>> 'x0, y0, sx, sy, theta, ex, tx, ty, u, v') >>> # move the center to 0, 0 >>> tr1_ = sympy.Matrix([[1, 0, -x0], >>> [0, 1, -y0], >>> [0, 0, 1]]) >>> P = sympy.Matrix([ # projective part >>> [ 1, 0, 0], >>> [ 0, 1, 0], >>> [ u, v, 1]]) >>> # Define core components of the affine transform >>> S = sympy.Matrix([ # scale >>> [sx, 0, 0], >>> [ 0, sy, 0], >>> [ 0, 0, 1]]) >>> E = sympy.Matrix([ # x-shear >>> [1, shearx, 0], >>> [0, 1, 0], >>> [0, 0, 1]]) >>> R = sympy.Matrix([ # rotation >>> [sympy.cos(theta), -sympy.sin(theta), 0], >>> [sympy.sin(theta), sympy.cos(theta), 0], >>> [ 0, 0, 1]]) >>> T = sympy.Matrix([ # translation >>> [ 1, 0, tx], >>> [ 0, 1, ty], >>> [ 0, 0, 1]]) >>> # move 0, 0 back to the specified origin >>> tr2_ = sympy.Matrix([[1, 0, x0], >>> [0, 1, y0], >>> [0, 0, 1]]) >>> # combine transformations >>> with sympy.evaluate(False): >>> homog_ = sympy.MatMul(tr2_, T, R, E, S, P, tr1_) >>> sympy.pprint(homog_) >>> homog = homog_.doit() >>> sympy.pprint(homog) >>> print('homog = {}'.format(ub.urepr(homog.tolist(), nl=1))) Ignore: M = kwimage.Projective.projective(uv=(0, 0.04), about=128) img1 = kwimage.ensure_float01(kwimage.grab_test_image('astro', dsize=(228, 228))) points = kwimage.Points(xy=kwimage.Coords(np.array([ (0, 0), (1, 1), (128, 1), (144, 0), (288, 288), (97, 77), (0, 288), (128, 128), ]))) img1_warp = kwimage.warp_projective(img1, M.matrix, interpolation='linear') warped = points.warp(M.matrix) pic_copy = points.draw_on(img1.copy(), radius=10, color='kitware_green') warp_copy = warped.draw_on(img1_warp.copy(), radius=10, color='kitware_green') stacked, stack_tfs = kwimage.stack_images([pic_copy, warp_copy], return_info=True, axis=1) stacked = kwimage.draw_line_segments_on_image(stacked, points.warp(stack_tfs[0]).xy, warped.warp(stack_tfs[1]).xy) import kwplot kwplot.autompl() kwplot.imshow(stacked) # M.matrix.dot(np.array([[0, 0, 1]]).T) """ import kwimage about_ = 0 if about is None else about if uv is None: uv = 0, 0 x0, y0 = _ensure_iterable2(about_) # About needs to be wrt to this because the projective and affine parts # will be inside it. tr1_ = np.array([[1, 0, -x0], [0, 1, -y0], [0, 0, 1]]) tr2_ = np.array([[1, 0, x0], [0, 1, y0], [0, 0, 1]]) # TODO: add sympy optimization aff_part = kwimage.Affine.affine( scale=scale, offset=offset, shearx=shearx, theta=theta) u, v = uv proj_part = np.array([ [ 1, 0, 0], [ 0, 1, 0], [ u, v, 1], ]) self = kwimage.Projective(tr2_ @ aff_part.matrix @ proj_part @ tr1_) return self
[docs] @classmethod @profile def coerce(cls, data=None, **kwargs): """ Attempt to coerce the data into an Projective object Args: data : some data we attempt to coerce to an Projective matrix **kwargs : some data we attempt to coerce to an Projective matrix, mutually exclusive with `data`. Returns: Projective Example: >>> import kwimage >>> kwimage.Projective.coerce({'type': 'affine', 'matrix': [[1, 0, 0], [0, 1, 0]]}) >>> kwimage.Projective.coerce({'type': 'affine', 'scale': 2}) >>> kwimage.Projective.coerce({'type': 'projective', 'scale': 2}) >>> kwimage.Projective.coerce({'scale': 2}) >>> kwimage.Projective.coerce({'offset': 3}) >>> kwimage.Projective.coerce(np.eye(3)) >>> kwimage.Projective.coerce(None) >>> import skimage >>> kwimage.Projective.coerce(skimage.transform.AffineTransform(scale=30)) >>> kwimage.Projective.coerce(skimage.transform.ProjectiveTransform(matrix=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 isinstance(data, (skimage.transform.AffineTransform, skimage.transform.ProjectiveTransform)): self = cls(matrix=data.params) elif data.__class__.__name__ == cls.__name__: self = data elif isinstance(data, dict): keys = set(data.keys()) if 'matrix' in keys: matrix = np.array(data['matrix']) if matrix.shape[0] == 2: matrix = np.vstack([matrix, [[0, 0, 1.]]]) self = cls(matrix=matrix) else: known_params = {'uv', 'scale', 'offset', 'theta', 'type', 'shearx', 'shear', 'about'} params = {key: data[key] for key in known_params if key in data} if len(keys - known_params) == 0: type_ = params.pop('type', None) # NOQA # if len(keys) == 1: # # Special cases for speed # if keys == {'scale'}: # self = cls.scale(**params) # if keys == {'translate'}: # self = cls.scale(**params) # else: # self = cls.projective(**params) # else: self = cls.projective(**params) else: raise KeyError(', '.join(list(data.keys()))) else: raise TypeError(type(data)) return self
[docs] def is_affine(self): """ If the bottom row is [[0, 0, 1]], then this can be safely turned into an affine matrix. Returns: bool Example: >>> import kwimage >>> kwimage.Projective.coerce(scale=2, uv=[1, 1]).is_affine() False >>> kwimage.Projective.coerce(scale=2, uv=[0, 0]).is_affine() True """ if self.matrix is None: return True else: return np.all(self.matrix[2] == [0, 0, 1])
[docs] def to_skimage(self): """ Returns: skimage.transform.AffineTransform Example: >>> import kwimage >>> self = kwimage.Projective.random() >>> tf = self.to_skimage() >>> # Transform points with kwimage and scikit-image >>> kw_poly = kwimage.Polygon.random() >>> kw_warp_xy = kw_poly.warp(self.matrix).exterior.data >>> sk_warp_xy = tf(kw_poly.exterior.data) >>> assert np.allclose(sk_warp_xy, sk_warp_xy) """ return skimage.transform.ProjectiveTransform(matrix=np.asarray(self))
[docs] @classmethod def random(cls, shape=None, rng=None, **kw): """ Example/ >>> import kwimage >>> self = kwimage.Projective.random() >>> print(f'self={self}') >>> params = self.decompose() >>> aff_part = kwimage.Affine.affine(**ub.dict_diff(params, ['uv'])) >>> proj_part = kwimage.Projective.coerce(uv=params['uv']) >>> # xdoctest: +REQUIRES(module:kwplot) >>> # xdoctest: +REQUIRES(--show) >>> import cv2 >>> import kwplot >>> dsize = (256, 256) >>> kwplot.autompl() >>> img1 = kwimage.grab_test_image(dsize=dsize) >>> img1_affonly = kwimage.warp_projective(img1, aff_part.matrix, dsize=img1.shape[0:2][::-1]) >>> img1_projonly = kwimage.warp_projective(img1, proj_part.matrix, dsize=img1.shape[0:2][::-1]) >>> ### >>> img2 = kwimage.ensure_uint255(kwimage.atleast_3channels(kwimage.checkerboard(dsize=dsize))) >>> img1_fullwarp = kwimage.warp_projective(img1, self.matrix, dsize=img1.shape[0:2][::-1]) >>> img2_affonly = kwimage.warp_projective(img2, aff_part.matrix, dsize=img2.shape[0:2][::-1]) >>> img2_projonly = kwimage.warp_projective(img2, proj_part.matrix, dsize=img2.shape[0:2][::-1]) >>> img2_fullwarp = kwimage.warp_projective(img2, self.matrix, dsize=img2.shape[0:2][::-1]) >>> canvas1 = kwimage.stack_images([img1, img1_projonly, img1_affonly, img1_fullwarp], pad=10, axis=1, bg_value=(0.5, 0.9, 0.1)) >>> canvas2 = kwimage.stack_images([img2, img2_projonly, img2_affonly, img2_fullwarp], pad=10, axis=1, bg_value=(0.5, 0.9, 0.1)) >>> canvas = kwimage.stack_images([canvas1, canvas2], axis=0) >>> kwplot.imshow(canvas) """ import kwimage rng = kwarray.ensure_rng(rng) aff_part = kwimage.Affine.random(shape, rng=rng, **kw) # Random projective part u = 1 / rng.randint(1, 10000) v = 1 / rng.randint(1, 10000) proj_part = np.array([ [ 1, 0, 0], [ 0, 1, 0], [ u, v, 1], ]) self = Projective(aff_part.matrix @ proj_part) return self
[docs] def decompose(self): r""" Based on the analysis done in [ME1319680]_. Returns: Dict: References: .. [ME1319680] https://math.stackexchange.com/questions/1319680 Example: >>> # Create a set of points, warp them, then recover the warp >>> import kwimage >>> points = kwimage.Points.random(9).scale(64) >>> A1 = kwimage.Affine.affine(scale=0.9, theta=-3.2, offset=(2, 3), about=(32, 32), skew=2.3) >>> A2 = kwimage.Affine.affine(scale=0.8, theta=0.8, offset=(2, 0), about=(32, 32)) >>> A12_real = A2 @ A1.inv() >>> points1 = points.warp(A1) >>> points2 = points.warp(A2) >>> # Make the correspondence non-affine >>> points2.data['xy'].data[0, 0] += 3.5 >>> points2.data['xy'].data[3, 1] += 8.5 >>> # Recover the warp >>> pts1, pts2 = points1.xy, points2.xy >>> self = kwimage.Projective.random() >>> self.decompose() Example: >>> # xdoctest: +REQUIRES(module:sympy) >>> from kwimage.transform import * # NOQA >>> import kwimage >>> from kwimage.transform import _RationalNDArray >>> self = kwimage.Projective.random().rationalize() >>> rat_decomp = self.decompose() >>> print('rat_decomp = {}'.format(ub.urepr(rat_decomp, nl=1))) >>> #### >>> import sympy >>> cells = sympy.symbols('h1, h2, h3, h4, h5, h6, h7, h8, h9') >>> matrix = _RationalNDArray(cells).reshape(3, 3) >>> # Symbolic decomposition. Neat. >>> self = kwimage.Projective(matrix) >>> self.decompose() Ignore: >>> from sympy.abc import theta >>> import sympy >>> h1, h2, h3, h4, h5, h6, h7, h8, h9 = sympy.symbols( >>> 'h1, h2, h3, h4, h5, h6, h7, h8, h9') >>> H = sympy.Matrix([[h1, h2, h3], [h4, h5, h6], [h7, h8, 1]]) >>> a1 = h1 - h3 * h7 >>> a2 = h2 - h3 * h8 >>> a3 = h3 >>> a4 = h4 - h6 * h7 >>> a5 = h5 - h6 * h8 >>> a6 = h6 >>> A = sympy.Matrix([[a1, a2, a3], [a4, a5, a6], [0, 0, 1]]) >>> P = sympy.Matrix([[1, 0, 0], [0, 1, 0], [h7, h8, 1]]) >>> assert np.all(np.ravel((A @ P - H).tolist()) == 0) # TODO: Can we get a more concise ane nice sympy decomposition / # recombination sympy.printing.pretty_print(sympy.Eq(H, B @ Q)) bs = b1, b2, b3, b4, b5, b6, b7, b8, b9 = sympy.symbols( 'b1, b2, b3, b4, b5, b6, b7, b8, b9') uvs = u, v = sympy.symbols('u, v') B = sympy.Matrix([[b1, b2, b3], [b4, b5, b6], [0, 0, 1]]) Q = sympy.Matrix([[1, 0, 0], [0, 1, 0], [u, v, 1]]) # Q = sympy.Matrix([[1, 0, 0], [0, 1, 0], [u, v, 1 / (b3*u + b6*v + 1)]]) H2_unnorm = Q @ B H2_norm = H2_unnorm / H2_unnorm.tolist()[-1][-1] expr = sympy.Eq(H, Q @ B) sympy.solve(expr, bs + uvs) A @ P = H A @ P = H = A @ P @ A.inv() @ A A @ P @ A.inv() kwimage.Projective.projective( Ignore: import kwimage import kwplot import sympy plt = kwplot.autoplt() from kwplot.cli import gifify import cv2 check = kwimage.atleast_3channels(kwimage.checkerboard(dsize=(288, 288))) pic = kwimage.grab_test_image('astro', dsize=check.shape[0:2][::-1]) img1 = np.maximum((1 - check) * kwimage.ensure_float01(pic), check) ims = [] # v_coords = np.log(np.logspace(1, 2)) / np.log(10) - 1 v_coords = np.linspace(0, 1.0, 64) ** 6 for v in ub.ProgIter(v_coords): u = 0 v = v I = kwimage.Affine.eye() T = kwimage.Affine.translate(-128) # T = kwimage.Affine.translate(0) H = kwimage.Projective(np.array([[1, 0, 0], [0, 1, 0], [u, v, 1]]).astype(np.float32)) H2 = (T.inv() @ H @ T) C2 = (I @ I) img1_warp = kwimage.warp_projective(img1, H2.matrix, dsize=img1.shape[0:2][::-1]).clip(0, 1) img1_pre = kwimage.warp_projective(img1, C2.matrix, dsize=img1.shape[0:2][::-1]).clip(0, 1) canvas = kwimage.stack_images([img1_pre, img1_warp], pad=10, axis=1, bg_value=(0., 1., 0.)) canvas = kwimage.draw_text_on_image( canvas, 'u={},{}v={}'.format(round(u, 8), chr(10) * 2, round(v, 8)), # org=tuple(img1.shape[0:2][::-1]), # valign='bottom', halign='right', org=(1, 1), valign='top', halign='left', fontScale=0.8, border=True, ) ims.append(canvas) # Hinge animation images = ims dpath = ub.Path.appdir('kwcoco/demo').ensuredir() output_fpath = dpath / 'hinge-v-t.gif' gifify.ffmpeg_animate_images(ims, output_fpath, in_framerate=2) """ import numpy as np h1, h2, h3, h4, h5, h6, h7, h8, h9 = self.matrix.ravel() # assert h9 == 1 a1 = h1 - h3 * h7 a2 = h2 - h3 * h8 a3 = h3 a4 = h4 - h6 * h7 a5 = h5 - h6 * h8 a6 = h6 mcls = _RationalNDArray if self.is_rational() else np.array affine_part = Affine(mcls([ [a1, a2, a3], [a4, a5, a6], [0, 0, 1], ])) decomp = affine_part.decompose() # The line u * x + v * y = 0 is fixed to iteself. # I.e. y = -u/v * x + 0 # The line u * x + v * y = -1 is mapped to the point at infinity # I.e. y = -u/v * x - 1/v u, v = h7, h8 # This transform has to happen first when we re-compose # TODO: I would love to find a more intuitive name or representation # for this. Can I do something to call this a "hinge"? Is there a # representation where I can look at this and get a sense of where the # "hinge" is? decomp['uv'] = (u, v) return decomp
[docs] class Affine(Projective): """ A thin wraper around a 3x3 matrix that represents an affine transform Implements methods for: * creating random affine transforms * decomposing the matrix * finding a best-fit transform between corresponding points * TODO: - [ ] fully rational transform Example: >>> import kwimage >>> import math >>> image = kwimage.grab_test_image() >>> theta = 0.123 * math.tau >>> components = { >>> 'rotate': kwimage.Affine.affine(theta=theta), >>> 'scale': kwimage.Affine.affine(scale=0.5), >>> 'shear': kwimage.Affine.affine(shearx=0.2), >>> 'translation': kwimage.Affine.affine(offset=(100, 200)), >>> 'rotate+translate': kwimage.Affine.affine(theta=0.123 * math.tau, about=(256, 256)), >>> 'random composed': kwimage.Affine.random(scale=(0.5, 1.5), translate=(-20, 20), theta=(-theta, theta), shearx=(0, .4), rng=900558176210808600), >>> } >>> warp_stack = [] >>> for key, aff in components.items(): ... warp = kwimage.warp_affine(image, aff) ... warp = kwimage.draw_text_on_image( ... warp, ... ub.urepr(aff.matrix, nl=1, nobr=1, precision=2, si=1, sv=1, with_dtype=0), ... org=(1, 1), ... valign='top', halign='left', ... fontScale=0.8, color='kw_blue', ... border={'thickness': 3}, ... ) ... warp = kwimage.draw_header_text(warp, key, color='kw_green') ... warp_stack.append(warp) >>> warp_canvas = kwimage.stack_images_grid(warp_stack, chunksize=3, pad=10, bg_value='kitware_gray') >>> # xdoctest: +REQUIRES(module:sympy) >>> import sympy >>> # Shows the symbolic construction of the code >>> # https://groups.google.com/forum/#!topic/sympy/k1HnZK_bNNA >>> from sympy.abc import theta >>> params = x0, y0, sx, sy, theta, shearx, tx, ty = sympy.symbols( >>> 'x0, y0, sx, sy, theta, shearx, tx, ty') >>> theta = sympy.symbols('theta') >>> # 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]]) >>> E = np.array([ # x-shear >>> [1, shearx, 0], >>> [0, 1, 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 @ E @ 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.urepr(aff.tolist(), nl=1))) >>> # This could be prettier >>> texts = { >>> 'Translation': sympy.pretty(R), >>> 'Rotation': sympy.pretty(R), >>> 'shEar-X': sympy.pretty(E), >>> 'Scale': sympy.pretty(S), >>> } >>> print(ub.urepr(texts, nl=2, sv=1)) >>> equation_stack = [] >>> for text, m in texts.items(): >>> render_canvas = kwimage.draw_text_on_image(None, m, color='kw_blue', fontScale=1.0) >>> render_canvas = kwimage.draw_header_text(render_canvas, text, color='kw_green') >>> render_canvas = kwimage.imresize(render_canvas, scale=1.3) >>> equation_stack.append(render_canvas) >>> equation_canvas = kwimage.stack_images(equation_stack, pad=10, axis=1, bg_value='kitware_gray') >>> render_canvas = kwimage.draw_text_on_image(None, sympy.pretty(aff), color='kw_blue', fontScale=1.0) >>> render_canvas = kwimage.draw_header_text(render_canvas, 'Full Equation With Pre-Shift', color='kw_green') >>> # xdoctest: -REQUIRES(module:sympy) >>> # xdoctest: +REQUIRES(--show) >>> import kwplot >>> plt = kwplot.autoplt() >>> canvas = kwimage.stack_images([warp_canvas, equation_canvas, render_canvas], pad=20, axis=0, bg_value='kitware_gray', resize='larger') >>> canvas = kwimage.draw_header_text(canvas, 'Affine matrixes can represent', color='kw_green') >>> kwplot.imshow(canvas) >>> fig = plt.gcf() >>> fig.set_size_inches(13, 13) Example: >>> import kwimage >>> self = kwimage.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() >>> 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.urepr(m))) """ @property def shape(self): return (3, 3) def __json__(self): if self.matrix is None: return {'type': 'affine', 'matrix': None} else: return {'type': 'affine', 'matrix': self.matrix.tolist()}
[docs] @profile 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: >>> import kwimage >>> self = kwimage.Affine.random(rng=0, scale=1) >>> params = self.concise() >>> assert np.allclose(Affine.coerce(params).matrix, self.matrix) >>> print('params = {}'.format(ub.urepr(params, nl=1, precision=2))) params = { 'offset': (0.08, 0.38), 'theta': 0.08, 'type': 'affine', } Example: >>> import kwimage >>> self = kwimage.Affine.random(rng=0, scale=2, offset=0) >>> params = self.concise() >>> assert np.allclose(Affine.coerce(params).matrix, self.matrix) >>> print('params = {}'.format(ub.urepr(params, nl=1, precision=2))) params = { 'scale': 2.00, 'theta': 0.04, 'type': 'affine', } """ params = self.decompose() params['type'] = 'affine' tx, ty = params['offset'] sx, sy = params['scale'] if math.isclose(tx, 0) and math.isclose(ty, 0): params.pop('offset') elif tx == ty: params['offset'] = tx if math.isclose(sy, 1) and math.isclose(sy, 1): params.pop('scale') elif sx == sy: params['scale'] = sx if math.isclose(params['shearx'], 0): params.pop('shearx') if math.isclose(params['theta'], 0): params.pop('theta') return params
[docs] @classmethod def from_shapely(cls, sh_aff): """ Shapely affine tuples are in the format (a, b, d, e, x, y) """ (a, b, d, e, x, y) = sh_aff matrix = np.array([[a, b, x], [d, e, y], [0, 0, 1]]) self = cls(matrix=matrix) return self
[docs] @classmethod def from_affine(cls, aff): a, b, c, d, e, f = aff.a, aff.b, aff.c, aff.d, aff.e, aff.f matrix = np.array([[a, b, c], [d, e, f], [0, 0, 1]]) self = cls(matrix=matrix) return self
[docs] @classmethod def from_gdal(cls, gdal_aff): """ gdal affine tuples are in the format (c, a, b, f, d, e) """ c, a, b, f, d, e = gdal_aff matrix = np.array([[a, b, c], [d, e, f], [0, 0, 1]]) self = cls(matrix=matrix) return self
[docs] @classmethod def from_skimage(cls, sk_aff): """ gdal affine tuples are in the format (c, a, b, f, d, e) """ self = cls(matrix=sk_aff.params) return self
[docs] @classmethod @profile 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 >>> import skimage.transform >>> 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({}) >>> kwimage.Affine.coerce(skimage.transform.AffineTransform(scale=30)) """ if data is None and not kwargs: # Just use a real eye matrix here. 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, tuple): raise ValueError( 'Cannot determine if a tuple is in shapely or gdal order.' 'use from_shapely or from_gdal instead' ) elif isinstance(data, dict): keys = set(data.keys()) if 'matrix' in keys: self = cls(matrix=np.array(data['matrix'])) else: known_params = {'scale', 'offset', 'theta', 'type', 'shearx', 'shear', 'about'} params = {key: data[key] for key in known_params if key in data} unknown_params = keys - known_params if len(unknown_params) == 0: params.pop('type', None) _nkeys = len(keys) if _nkeys == 1: # Special cases for speed if keys == {'scale'}: self = cls.scale(**params) elif keys == {'offset'}: self = cls.translate(**params) else: self = cls.affine(**params) # elif _nkeys == 2: # may not be worth it # # Special cases for speed # if keys == {'scale', 'offset'}: # self = cls._scale_translate(**params) # else: # self = cls.affine(**params) else: self = cls.affine(**params) else: got_known_parms = set(params) - unknown_params raise KeyError( 'Got known params: ' + ', '.join(list(got_known_parms)) + ' ' 'Got unknown params: ' + ', '.join(list(unknown_params))) else: raise TypeError(type(data)) return self
[docs] def eccentricity(self): """ Eccentricity of the ellipse formed by this affine matrix Returns: float: large when there are big scale differences in principle directions or skews. References: .. [WikiConic] https://en.wikipedia.org/wiki/Conic_section .. [GHAffine] https://github.com/rasterio/affine/blob/78c20a0cfbb5ec/affine/__init__.py#L368 Example: >>> import kwimage >>> kwimage.Affine.random(rng=432).eccentricity() """ # Ignore the translation part M = self.matrix[0:2, 0:2] MMt = M @ M.T trace = np.trace(MMt) det = np.linalg.det(MMt) root_delta = np.sqrt((trace * trace) / 4 - det) # scaling defined via affine.Affine. ell1 = np.sqrt(trace / 2 + root_delta) ell2 = np.sqrt(trace / 2 - root_delta) ecc = np.sqrt(ell1 * ell1 - ell2 * ell2) / ell1 return ecc
[docs] def to_affine(self): """ Convert to an affine module Returns: affine.Affine """ import affine a, b, c, d, e, f = self.matrix.ravel()[0:6] aff = affine.Affine(a, b, c, d, e, f) return aff
[docs] def to_gdal(self): """ Convert to a gdal tuple (c, a, b, f, d, e) Returns: Tuple[float, float, float, float, float, float] """ return self.to_affine().to_gdal()
[docs] def to_shapely(self): """ Returns a matrix suitable for shapely.affinity.affine_transform Returns: Tuple[float, float, float, float, float, float] Example: >>> import kwimage >>> self = kwimage.Affine.random() >>> sh_transform = self.to_shapely() >>> # Transform points with kwimage and shapley >>> import shapely >>> from shapely.affinity import affine_transform >>> kw_poly = kwimage.Polygon.random() >>> kw_warp_poly = kw_poly.warp(self) >>> sh_poly = kw_poly.to_shapely() >>> sh_warp_poly = affine_transform(sh_poly, sh_transform) >>> kw_warp_poly_recon = kwimage.Polygon.from_shapely(sh_warp_poly) >>> assert np.allclose(kw_warp_poly_recon.exterior.data, kw_warp_poly_recon.exterior.data) """ # from shapely.affinity import affine_transform a, b, x, d, e, y = self.matrix.ravel()[0:6] sh_transform = (a, b, d, e, x, y) return sh_transform
[docs] def to_skimage(self): """ Returns: skimage.transform.AffineTransform Example: >>> import kwimage >>> self = kwimage.Affine.random() >>> tf = self.to_skimage() >>> # Transform points with kwimage and scikit-image >>> kw_poly = kwimage.Polygon.random() >>> kw_warp_xy = kw_poly.warp(self.matrix).exterior.data >>> sk_warp_xy = tf(kw_poly.exterior.data) >>> assert np.allclose(sk_warp_xy, sk_warp_xy) """ return skimage.transform.AffineTransform(matrix=np.asarray(self))
[docs] @classmethod def scale(cls, scale): """ Create a scale Affine object Args: scale (float | Tuple[float, float]): x, y scale factor Returns: Affine """ scale_ = 1 if scale is None else scale sx, sy = _ensure_iterable2(scale_) # Sympy simplified expression mat = np.array([sx , 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]) mat = mat.reshape(3, 3) # Faster to make a flat array and reshape self = cls(mat) return self
[docs] @classmethod def translate(cls, offset): """ Create a translation Affine object Args: offset (float | Tuple[float, float]): x, y translation factor Returns: Affine Benchmark: >>> # xdoctest: +REQUIRES(--benchmark) >>> # It is ~3x faster to use the more specific method >>> import timerit >>> import kwimage >>> # >>> offset = np.random.rand(2) >>> ti = timerit.Timerit(100, bestof=10, verbose=2) >>> for timer in ti.reset('time'): >>> with timer: >>> kwimage.Affine.translate(offset) >>> # >>> for timer in ti.reset('time'): >>> with timer: >>> kwimage.Affine.affine(offset=offset) """ offset_ = 0 if offset is None else offset tx, ty = _ensure_iterable2(offset_) # Sympy simplified expression mat = np.array([1.0, 0.0, tx, 0.0, 1.0, ty, 0.0, 0.0, 1.0]) mat = mat.reshape(3, 3) # Faster to make a flat array and reshape self = cls(mat) return self
[docs] @classmethod def _scale_translate(cls, scale, offset): """ helper method for speed """ scale_ = 1 if scale is None else scale offset_ = 0 if offset is None else offset sx, sy = _ensure_iterable2(scale_) tx, ty = _ensure_iterable2(offset_) # Sympy simplified expression mat = np.array([sx , 0.0, tx, 0.0, sy, ty, 0.0, 0.0, 1.0]) mat = mat.reshape(3, 3) # Faster to make a flat array and reshape self = cls(mat) return self
[docs] @classmethod 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)
[docs] @classmethod def random(cls, shape=None, 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 shearx. Returns: Affine """ if shape is not None: raise ValueError('cannot specify shape to Affine.random') params = cls.random_params(rng=rng, **kw) self = cls.affine(**params) return self
[docs] @classmethod def random_params(cls, rng=None, **kw): """ Args: rng : random number generator **kw: can contain coercable random distributions for scale, offset, about, theta, and shearx. 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']) and (not isinstance(kw['scale'], tuple) and len(kw['scale']) == 2): raise NotImplementedError else: print(kw['scale']) 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 'shearx' in kw: shear_dist = _coerce_distri(kw['shearx']) 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(), shearx=shear_dist.sample(), about=(xabout_dist.sample(), yabout_dist.sample()), ) return params
[docs] @profile def decompose(self): r""" Decompose the affine matrix into its individual scale, translation, rotation, and skew parameters. Returns: Dict: decomposed offset, scale, theta, and shearx params References: .. [SE612006] https://math.stackexchange.com/questions/612006/decompose-affine .. [SE3521141] https://math.stackexchange.com/a/3521141/353527 .. [SE70357473] https://stackoverflow.com/questions/70357473/how-to-decompose-a-2x2-affine-matrix-with-sympy .. [WikiTranMat] https://en.wikipedia.org/wiki/Transformation_matrix .. [WikiShear] https://en.wikipedia.org/wiki/Shear_mapping Example: >>> from kwimage.transform import * # NOQA >>> 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() Example: >>> # xdoctest: +REQUIRES(module:sympy) >>> # Test decompose with symbolic matrices >>> from kwimage.transform import * # NOQA >>> self = Affine.random().rationalize() >>> self.decompose() Example: >>> # xdoctest: +REQUIRES(module:pandas) >>> from kwimage.transform import * # NOQA >>> import kwimage >>> import pandas as pd >>> # Test consistency of decompose + reconstruct >>> param_grid = list(ub.named_product({ >>> 'theta': np.linspace(-4 * np.pi, 4 * np.pi, 3), >>> 'shearx': np.linspace(- 10 * np.pi, 10 * np.pi, 4), >>> })) >>> def normalize_angle(radian): >>> return np.arctan2(np.sin(radian), np.cos(radian)) >>> for pextra in param_grid: >>> params0 = dict(scale=(3.05, 3.07), offset=(10.5, 12.1), **pextra) >>> self = recon0 = kwimage.Affine.affine(**params0) >>> self.decompose() >>> # Test drift with multiple decompose / reconstructions >>> params_list = [params0] >>> recon_list = [recon0] >>> n = 4 >>> for _ in range(n): >>> prev = recon_list[-1] >>> params = prev.decompose() >>> recon = kwimage.Affine.coerce(**params) >>> params_list.append(params) >>> recon_list.append(recon) >>> params_df = pd.DataFrame(params_list) >>> #print('params_list = {}'.format(ub.urepr(params_list, nl=1, precision=5))) >>> print(params_df) >>> assert ub.allsame(normalize_angle(params_df['theta']), eq=np.isclose) >>> assert ub.allsame(params_df['shearx'], eq=np.allclose) >>> assert ub.allsame(params_df['scale'], eq=np.allclose) >>> assert ub.allsame(params_df['offset'], eq=np.allclose) Ignore: import affine self = Affine.random() a, b, c, d, e, f = self.matrix.ravel()[0:6] aff = affine.Affine(a, b, c, d, e, f) assert np.isclose(self.det(), aff.determinant) params = self.decompose() assert np.isclose(params['theta'], np.deg2rad(aff.rotation_angle)) print(params['scale']) print(aff._scaling) print(self.eccentricity()) print(aff.eccentricity) Ignore: import timerit ti = timerit.Timerit(100, bestof=10, verbose=2) for timer in ti.reset('time'): self = Affine.random() with timer: self.decompose() # Wow: using math instead of numpy for scalars is much faster! for timer in ti.reset('time'): with timer: math.sqrt(a11 * a11 + a21 * a21) for timer in ti.reset('time'): with timer: np.arctan2(a21, a11) for timer in ti.reset('time'): with timer: math.atan2(a21, a11) """ if self.matrix is None: return {'offset': (0., 0.), 'scale': (1., 1.), 'shearx': 0., 'theta': 0., } a11, a12, a13, a21, a22, a23 = self.matrix.ravel()[0:6] if self.is_rational(): math_mod = sympy else: math_mod = math sx = math_mod.sqrt(a11 * a11 + a21 * a21) theta = math_mod.atan2(a21, a11) sin_t = math_mod.sin(theta) cos_t = math_mod.cos(theta) msy = a12 * cos_t + a22 * sin_t try: if abs(cos_t) < abs(sin_t): sy = (msy * cos_t - a12) / sin_t else: sy = (a22 - msy * sin_t) / cos_t except TypeError: # symbolic issue sy = sympy.Piecewise( ((msy * cos_t - a12) / sin_t, abs(cos_t) < abs(sin_t)), ((a22 - msy * sin_t) / cos_t, True) ) shearx = msy / sy tx, ty = a13, a23 params = { 'offset': (tx, ty), 'scale': (sx, sy), 'shearx': shearx, 'theta': theta, } return params
[docs] @classmethod @profile def affine(cls, scale=None, offset=None, theta=None, shear=None, about=None, shearx=None, array_cls=None, math_mod=None, **kwargs): """ 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 shearx (float): shear factor parallel to the x-axis. about (float | Tuple[float, float]): x, y location of the origin shear (float): BROKEN, dont use. counter-clockwise shear angle in radians TODO: - [ ] Add aliases? - origin : alias for about rotation : alias for theta translation : alias for offset Returns: Affine: the constructed Affine object Example: >>> from kwimage.transform import * # NOQA >>> rng = kwarray.ensure_rng(None) >>> scale = rng.randn(2) * 10 >>> offset = rng.randn(2) * 10 >>> about = rng.randn(2) * 10 >>> theta = rng.randn() * 10 >>> shearx = rng.randn() * 10 >>> # Create combined matrix from all params >>> F = Affine.affine( >>> scale=scale, offset=offset, theta=theta, shearx=shearx, >>> about=about) >>> # Test that combining components matches >>> S = Affine.affine(scale=scale) >>> T = Affine.affine(offset=offset) >>> R = Affine.affine(theta=theta) >>> E = Affine.affine(shearx=shearx) >>> O = Affine.affine(offset=about) >>> # combine (note shear must be on the RHS of rotation) >>> alt = O @ T @ R @ E @ S @ O.inv() >>> print('F = {}'.format(ub.urepr(F.matrix.tolist(), nl=1))) >>> print('alt = {}'.format(ub.urepr(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 >>> params = x0, y0, sx, sy, theta, shearx, tx, ty = sympy.symbols( >>> 'x0, y0, sx, sy, theta, shearx, 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]]) >>> E = np.array([ # x-shear >>> [1, shearx, 0], >>> [0, 1, 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 @ E @ 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.urepr(aff.tolist(), nl=1))) Ignore: import timerit ti = timerit.Timerit(10000, bestof=10, verbose=2) for timer in ti.reset('time'): with timer: self = kwimage.Affine.affine(scale=3, offset=2, theta=np.random.rand(), shearx=np.random.rand()) """ if shear is not None and shearx is None: # Hack so old data is readable (this should be ok as long as the # data wasnt reserialized) if not _internal.KWIMAGE_DISABLE_TRANSFORM_WARNINGS: ub.schedule_deprecation( modname='kwimage', name='shear', type='parameter', migration=ub.paragraph( ''' The `shear` parameter is deprecated and will be removed because of a serious bug. Use `shearx` instead. See Issue #8 on https://gitlab.kitware.com/computer-vision/kwimage/-/issues/8 for more details. To ease the impact of this bug we will interpret `shear` as `shearx`, which should result in a correct reconstruction, as long as the data was never reserialized. ''' ), deprecate='0.9.0', error='0.10.0', remove='0.11.0', warncls=UserWarning) shearx = shear shear = None if array_cls is None: array_cls = np.array if math_mod is None: math_mod = math scale_ = 1 if scale is None else scale offset_ = 0 if offset is None else offset xshear_ = 0 if shearx is None else shearx theta_ = 0 if theta is None else theta about_ = 0 if about is None else about sx, sy = _ensure_iterable2(scale_) tx, ty = _ensure_iterable2(offset_) x0, y0 = _ensure_iterable2(about_) cos_theta = math_mod.cos(theta_) sin_theta = math_mod.sin(theta_) sx_cos_theta = sx * cos_theta sx_sin_theta = sx * sin_theta sy_cos_theta = sy * cos_theta sy_sin_theta = sy * sin_theta m_sy_cos_theta = xshear_ * sy_cos_theta m_sy_sin_theta = xshear_ * sy_sin_theta a12 = m_sy_cos_theta - sy_sin_theta a22 = m_sy_sin_theta + sy_cos_theta tx_ = tx + x0 - (x0 * sx_cos_theta) - (y0 * a12) ty_ = ty + y0 - (x0 * sx_sin_theta) - (y0 * a22) mat = array_cls([sx_cos_theta, a12, tx_, sx_sin_theta, a22, ty_, 0, 0, 1]) mat = mat.reshape(3, 3) # Faster to make a flat array and reshape self = cls(mat) return self
[docs] @classmethod def fit(cls, pts1, pts2): """ Fit an affine transformation between a set of corresponding points Args: pts1 (ndarray): An Nx2 array of points in "space 1". pts2 (ndarray): A corresponding Nx2 array of points in "space 2" Returns: Affine : a transform that warps from "space1" to "space2". Note: An affine matrix has 6 degrees of freedom, so at least 3 non-colinear xy-point pairs are needed. References: ..[Lowe04] https://www.cs.ubc.ca/~lowe/papers/ijcv04.pdf page 22 Example: >>> # Create a set of points, warp them, then recover the warp >>> import kwimage >>> points = kwimage.Points.random(6).scale(64) >>> #A1 = kwimage.Affine.affine(scale=0.9, theta=-3.2, offset=(2, 3), about=(32, 32), skew=2.3) >>> #A2 = kwimage.Affine.affine(scale=0.8, theta=0.8, offset=(2, 0), about=(32, 32)) >>> A1 = kwimage.Affine.random() >>> A2 = kwimage.Affine.random() >>> A12_real = A2 @ A1.inv() >>> points1 = points.warp(A1) >>> points2 = points.warp(A2) >>> # Recover the warp >>> pts1, pts2 = points1.xy, points2.xy >>> A_recovered = kwimage.Affine.fit(pts1, pts2) >>> assert np.all(np.isclose(A_recovered.matrix, A12_real.matrix)) >>> # xdoctest: +REQUIRES(--show) >>> import kwplot >>> kwplot.autompl() >>> base1 = np.zeros((96, 96, 3)) >>> base1[32:-32, 5:-5] = 0.5 >>> base2 = np.zeros((96, 96, 3)) >>> img1 = points1.draw_on(base1, radius=3, color='blue') >>> img2 = points2.draw_on(base2, radius=3, color='green') >>> img1_warp = kwimage.warp_affine(img1, A_recovered) >>> canvas = kwimage.stack_images([img1, img2, img1_warp], pad=10, axis=1, bg_value=(1., 1., 1.)) >>> kwplot.imshow(canvas) """ if 0: # Not sure if cv2 has this variant of the affine matrix calc import cv2 inlier_method = 'ransac' inlier_method_lut = { 'lmeds': cv2.LMEDS, 'ransac': cv2.RANSAC, } cv2_method = inlier_method_lut[inlier_method] A, mask = cv2.estimateAffine2D(pts1, pts2, method=cv2_method) x1_mn = pts1[:, 0] y1_mn = pts1[:, 1] x2_mn = pts2[:, 0] y2_mn = pts2[:, 1] num_pts = x1_mn.shape[0] Mx6 = np.empty((2 * num_pts, 6), dtype=float) b = np.empty((2 * num_pts, 1), dtype=float) for ix in range(num_pts): # Loop over inliers # Concatenate all 2x9 matrices into an Mx6 matrix x1 = x1_mn[ix] x2 = x2_mn[ix] y1 = y1_mn[ix] y2 = y2_mn[ix] Mx6[ix * 2] = (x1, y1, 0, 0, 1, 0) Mx6[ix * 2 + 1] = ( 0, 0, x1, y1, 0, 1) b[ix * 2] = x2 b[ix * 2 + 1] = y2 M = Mx6 try: USVt = np.linalg.svd(M, full_matrices=True, compute_uv=True) except MemoryError: import scipy.sparse as sps import scipy.sparse.linalg as spsl M_sparse = sps.lil_matrix(M) USVt = spsl.svds(M_sparse) except np.linalg.LinAlgError: raise except Exception: raise U, s, Vt = USVt # Inefficient, but the math works # We want to solve Ax=b (where A is the Mx6 in this case) # Ax = b # (U S V.T) x = b # x = (U.T inv(S) V) b Sinv = np.zeros((len(Vt), len(U))) Sinv[np.diag_indices(len(s))] = 1 / s a = Vt.T.dot(Sinv).dot(U.T).dot(b).T[0] mat = np.array([ [a[0], a[1], a[4]], [a[2], a[3], a[5]], [ 0, 0, 1], ]) return Affine(mat)
[docs] @classmethod def fliprot(cls, flip_axis=None, rot_k=0, axes=(0, 1), canvas_dsize=None): """ Creates a flip/rotation transform with respect to an image of a given size in the positive quadrent. (i.e. warped data within the specified canvas size will stay in the positive quadrant) Args: flip_axis (int): the axis dimension to flip. I.e. 0 flips the y-axis and 1-flips the x-axis. rot_k (int): number of counterclockwise 90 degree rotations that occur after the flips. axes (Tuple[int, int]): The axis ordering. Unhandled in this version. Dont change this. canvas_dsize (Tuple[int, int]): The width / height of the canvas the fliprot is applied in. Returns: Affine: The affine matrix representing the canvas-aligned flip and rotation. Note: Requiring that the image size is known makes this a place that errors could occur depending on your interpretation of pixels as points or areas. There is probably a better way to describe the issue, but the second doctest shows the issue when trying to use warp-affine's auto-dsize feature. See [MR81]_ for details. References: .. [SO57863376] https://stackoverflow.com/questions/57863376/flip-image-affine .. [MR81] https://gitlab.kitware.com/computer-vision/kwimage/-/merge_requests/81 CommandLine: xdoctest -m kwimage.transform Affine.fliprot:0 --show xdoctest -m kwimage.transform Affine.fliprot:1 --show Example: >>> import kwimage >>> H, W = 64, 128 >>> canvas_dsize = (W, H) >>> box1 = kwimage.Boxes.random(1).scale((W, H)).quantize() >>> ltrb = box1.data >>> rot_k = 4 >>> annot = box1 >>> annot = box1.to_polygons()[0] >>> annot1 = annot.copy() >>> # The first 8 are the cannonically unique group elements >>> fliprot_params = [ >>> {'rot_k': 0, 'flip_axis': None}, >>> {'rot_k': 1, 'flip_axis': None}, >>> {'rot_k': 2, 'flip_axis': None}, >>> {'rot_k': 3, 'flip_axis': None}, >>> {'rot_k': 0, 'flip_axis': (0,)}, >>> {'rot_k': 1, 'flip_axis': (0,)}, >>> {'rot_k': 2, 'flip_axis': (0,)}, >>> {'rot_k': 3, 'flip_axis': (0,)}, >>> # The rest of these dont result in any different data, but we need to test them >>> {'rot_k': 0, 'flip_axis': (1,)}, >>> {'rot_k': 1, 'flip_axis': (1,)}, >>> {'rot_k': 2, 'flip_axis': (1,)}, >>> {'rot_k': 3, 'flip_axis': (1,)}, >>> {'rot_k': 0, 'flip_axis': (0, 1)}, >>> {'rot_k': 1, 'flip_axis': (0, 1)}, >>> {'rot_k': 2, 'flip_axis': (0, 1)}, >>> {'rot_k': 3, 'flip_axis': (0, 1)}, >>> ] >>> results = [] >>> for params in fliprot_params: >>> tf = kwimage.Affine.fliprot(canvas_dsize=canvas_dsize, **params) >>> annot2 = annot.warp(tf) >>> annot3 = annot2.warp(tf.inv()) >>> #annot3 = inv_fliprot_annot(annot2, canvas_dsize=canvas_dsize, **params) >>> results.append({ >>> 'annot2': annot2, >>> 'annot3': annot3, >>> 'params': params, >>> 'tf': tf, >>> 'canvas_dsize': canvas_dsize, >>> }) >>> box = kwimage.Box.coerce([0, 0, W, H], format='xywh') >>> for result in results: >>> params = result['params'] >>> warped = box.warp(result['tf']) >>> print('---') >>> print('params = {}'.format(ub.urepr(params, nl=1))) >>> print('box = {}'.format(ub.urepr(box, nl=1))) >>> print('warped = {}'.format(ub.urepr(warped, nl=1))) >>> print(ub.hzcat(['tf = ', ub.urepr(result['tf'], nl=1)])) >>> # xdoctest: +REQUIRES(--show) >>> import kwplot >>> kwplot.autompl() >>> S = max(W, H) >>> image1 = kwimage.grab_test_image('astro', dsize=(S, S))[:H, :W] >>> pnum_ = kwplot.PlotNums(nCols=4, nSubplots=len(results)) >>> for result in results: >>> #image2 = kwimage.warp_affine(image1.copy(), result['tf'], dsize=(S, S)) # fixme dsize=positive should work here >>> image2 = kwimage.warp_affine(image1.copy(), result['tf'], dsize='positive') # fixme dsize=positive should work here >>> #image3 = kwimage.warp_affine(image2.copy(), result['tf'].inv(), dsize=(S, S)) >>> image3 = kwimage.warp_affine(image2.copy(), result['tf'].inv(), dsize='positive') >>> annot2 = result['annot2'] >>> annot3 = result['annot3'] >>> canvas1 = annot1.draw_on(image1.copy(), edgecolor='kitware_blue', fill=False) >>> canvas2 = annot2.draw_on(image2.copy(), edgecolor='kitware_green', fill=False) >>> canvas3 = annot3.draw_on(image3.copy(), edgecolor='kitware_red', fill=False) >>> canvas = kwimage.stack_images([canvas1, canvas2, canvas3], axis=1, pad=10, bg_value='green') >>> kwplot.imshow(canvas, pnum=pnum_(), title=ub.urepr(result['params'], nl=0, compact=1, nobr=1)) >>> kwplot.show_if_requested() Example: >>> # Second similar test with a very small image to catch small errors >>> import kwimage >>> H, W = 4, 8 >>> canvas_dsize = (W, H) >>> box1 = kwimage.Boxes.random(1).scale((W, H)).quantize() >>> ltrb = box1.data >>> rot_k = 4 >>> annot = box1 >>> annot = box1.to_polygons()[0] >>> annot1 = annot.copy() >>> # The first 8 are the cannonically unique group elements >>> fliprot_params = [ >>> {'rot_k': 0, 'flip_axis': None}, >>> {'rot_k': 1, 'flip_axis': None}, >>> {'rot_k': 2, 'flip_axis': None}, >>> {'rot_k': 3, 'flip_axis': None}, >>> {'rot_k': 0, 'flip_axis': (0,)}, >>> {'rot_k': 1, 'flip_axis': (0,)}, >>> {'rot_k': 2, 'flip_axis': (0,)}, >>> {'rot_k': 3, 'flip_axis': (0,)}, >>> # The rest of these dont result in any different data, but we need to test them >>> {'rot_k': 0, 'flip_axis': (1,)}, >>> {'rot_k': 1, 'flip_axis': (1,)}, >>> {'rot_k': 2, 'flip_axis': (1,)}, >>> {'rot_k': 3, 'flip_axis': (1,)}, >>> {'rot_k': 0, 'flip_axis': (0, 1)}, >>> {'rot_k': 1, 'flip_axis': (0, 1)}, >>> {'rot_k': 2, 'flip_axis': (0, 1)}, >>> {'rot_k': 3, 'flip_axis': (0, 1)}, >>> ] >>> results = [] >>> for params in fliprot_params: >>> tf = kwimage.Affine.fliprot(canvas_dsize=canvas_dsize, **params) >>> annot2 = annot.warp(tf) >>> annot3 = annot2.warp(tf.inv()) >>> #annot3 = inv_fliprot_annot(annot2, canvas_dsize=canvas_dsize, **params) >>> results.append({ >>> 'annot2': annot2, >>> 'annot3': annot3, >>> 'params': params, >>> 'tf': tf, >>> 'canvas_dsize': canvas_dsize, >>> }) >>> box = kwimage.Box.coerce([0, 0, W, H], format='xywh') >>> print('box = {}'.format(ub.urepr(box, nl=1))) >>> for result in results: >>> params = result['params'] >>> warped = box.warp(result['tf']) >>> print('---') >>> print('params = {}'.format(ub.urepr(params, nl=1))) >>> print('warped = {}'.format(ub.urepr(warped, nl=1))) >>> print(ub.hzcat(['tf = ', ub.urepr(result['tf'], nl=1)])) >>> # xdoctest: +REQUIRES(--show) >>> import kwplot >>> kwplot.autompl() >>> S = max(W, H) >>> image1 = np.linspace(.1, .9, W * H).reshape((H, W)) >>> image1 = kwimage.atleast_3channels(image1) >>> image1[0, :, 0] = 1 >>> image1[:, 0, 2] = 1 >>> image1[1, :, 1] = 1 >>> image1[:, 1, 1] = 1 >>> image1[3, :, 0] = 0.5 >>> image1[:, 7, 1] = 0.5 >>> pnum_ = kwplot.PlotNums(nCols=4, nSubplots=len(results)) >>> # NOTE: setting new_dsize='positive' illustrates an issuew with >>> # the pixel interpretation. >>> new_dsize = (S, S) >>> #new_dsize = 'positive' >>> for result in results: >>> image2 = kwimage.warp_affine(image1.copy(), result['tf'], dsize=new_dsize) >>> image3 = kwimage.warp_affine(image2.copy(), result['tf'].inv(), dsize=new_dsize) >>> annot2 = result['annot2'] >>> annot3 = result['annot3'] >>> #canvas1 = annot1.draw_on(image1.copy(), edgecolor='kitware_blue', fill=False) >>> #canvas2 = annot2.draw_on(image2.copy(), edgecolor='kitware_green', fill=False) >>> #canvas3 = annot3.draw_on(image3.copy(), edgecolor='kitware_red', fill=False) >>> canvas = kwimage.stack_images([image1, image2, image3], axis=1, pad=1, bg_value='green') >>> kwplot.imshow(canvas, pnum=pnum_(), title=ub.urepr(result['params'], nl=0, compact=1, nobr=1)) >>> kwplot.show_if_requested() """ import kwimage rot_k = rot_k % 4 # only 4 cases tf = None HALF_OFFSET = 1 if HALF_OFFSET: half1 = kwimage.Affine.translate((.5, .5)) tf = half1 else: tf = kwimage.Affine.eye() if flip_axis is not None: canvas_w, canvas_h = canvas_dsize canvas_dims = (canvas_h, canvas_w) yx2 = [0, 0] # Make the flip matrix F = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) for axis in flip_axis: mdim = 1 - axis F[mdim, mdim] *= -1 # When an axis is flipped we have to translate it to adjust it # back to the first quadrent dim = axes[axis] yx2[dim] = canvas_dims[dim] x2, y2 = yx2[::-1] T2 = kwimage.Affine.translate((x2, y2)) tf_flip = T2 @ F if tf is None: tf = tf_flip else: tf = tf_flip @ tf if rot_k != 0: # Construct the rotation # Should we add this as a rotate90 function that doesn't contain pi # approximations? # tau = np.pi * 2 # theta = -(rot_k * tau / 4) # R = kwimage.Affine.rotate(theta=theta) if rot_k == 1: R = np.array([[0, 1, 0], [-1, 0, 0], [0, 0, 1]]) elif rot_k == 2: R = np.array([[-1, 0, 0], [0, -1, 0], [0, 0, 1]]) elif rot_k == 3: R = np.array([[0, -1, 0], [1, 0, 0], [0, 0, 1]]) else: raise AssertionError # The rotation will be about (0, 0), so to ensure the results stays # in the positive quadrent canvas_w, canvas_h = canvas_dsize if rot_k == 1: x2 = 0 y2 = canvas_w elif rot_k == 2: x2 = canvas_w y2 = canvas_h elif rot_k == 3: x2 = canvas_h y2 = 0 else: raise AssertionError T2 = kwimage.Affine.translate((x2, y2)) # Rotate and translate the data back into the first quadrent tf_rot = T2 @ R if tf is None: tf = tf_rot else: tf = tf_rot @ tf if tf is None: tf = Affine.eye() else: if HALF_OFFSET: half2 = kwimage.Affine.translate((-.5, -.5)) tf = half2 @ tf return tf
try: import sympy _RationalMatrixBase = sympy.Matrix except Exception: sympy = None _RationalMatrixBase = object class _RationalNDArray(_RationalMatrixBase): """ Wraps sympy matrices to make it somewhat more compatible with numpy. Example: >>> # xdoctest: +REQUIRES(module:sympy) >>> from kwimage.transform import * # NOQA >>> from kwimage.transform import _RationalNDArray >>> arr = np.random.rand(3, 3) >>> a = _RationalNDArray.from_numpy(arr) >>> b = np.random.rand(3, 3) >>> c = a @ b >>> c @ c.inv() """ @classmethod def from_numpy(_RationalNDArray, arr): flat_rat = list(map(sympy.Rational, arr.ravel().tolist())) self = _RationalNDArray(flat_rat).reshape(*arr.shape) return self def __matmul__(self, other): if isinstance(other, np.ndarray): other = _RationalNDArray.from_numpy(other) return super().__matmul__(other) def __rmatmul__(self, other): if isinstance(other, np.ndarray): other = _RationalNDArray.from_numpy(other) return super().__matmul__(other) def numpy(self): return np.array(self.tolist()).astype(float) def ravel(self): return self.flat() # Does not seem to be working out # if 0: # try: # from mpmath import matrix as mp_matrix_base # except ImportError: # mp_matrix_base = object # class _mpmatrix(mp_matrix_base): # """ # A compatability layer for mpmath matrix # Example: # >>> # xdoctest: +REQUIRES(module:mpmath) # >>> from kwimage.transform import _mpmatrix # NOQA # >>> A = _mpmatrix(np.random.rand(3, 3)) # >>> B = _mpmatrix(np.random.rand(3, 3)) # >>> C = np.random.rand(3, 3) # >>> A @ B # >>> B @ A # >>> self = A # >>> other = C # >>> A.__matmul__(C) # >>> # C.__matmul__(A) not sure why this fails # """ # @property # def shape(self): # return (self.rows, self.cols) # def __matmul__(self, other): # if isinstance(other, np.ndarray): # other = _mpmatrix(other) # return _mpmatrix(mp_matrix_base.__matmul__(self, other)) # def __rmatmul__(self, other): # if isinstance(other, np.ndarray): # other = _mpmatrix(other) # return _mpmatrix(mp_matrix_base.__matmul__(other, self)) # def numpy(self): # return np.array(self).reshape(*self.shape) # def _ensure_iterablen(scalar, n): # try: # iter(scalar) # except TypeError: # return [scalar] * n # return scalar def _ensure_iterable2(scalar): try: a, b = scalar except TypeError: a = b = scalar return a, b if __name__ == '__main__': """ CommandLine: python -m kwimage.transform all --profile """ import xdoctest xdoctest.doctest_module(__file__)