123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495 |
- """Affine 2D transformation matrix class.
- The Transform class implements various transformation matrix operations,
- both on the matrix itself, as well as on 2D coordinates.
- Transform instances are effectively immutable: all methods that operate on the
- transformation itself always return a new instance. This has as the
- interesting side effect that Transform instances are hashable, ie. they can be
- used as dictionary keys.
- This module exports the following symbols:
- Transform
- this is the main class
- Identity
- Transform instance set to the identity transformation
- Offset
- Convenience function that returns a translating transformation
- Scale
- Convenience function that returns a scaling transformation
- The DecomposedTransform class implements a transformation with separate
- translate, rotation, scale, skew, and transformation-center components.
- :Example:
- >>> t = Transform(2, 0, 0, 3, 0, 0)
- >>> t.transformPoint((100, 100))
- (200, 300)
- >>> t = Scale(2, 3)
- >>> t.transformPoint((100, 100))
- (200, 300)
- >>> t.transformPoint((0, 0))
- (0, 0)
- >>> t = Offset(2, 3)
- >>> t.transformPoint((100, 100))
- (102, 103)
- >>> t.transformPoint((0, 0))
- (2, 3)
- >>> t2 = t.scale(0.5)
- >>> t2.transformPoint((100, 100))
- (52.0, 53.0)
- >>> import math
- >>> t3 = t2.rotate(math.pi / 2)
- >>> t3.transformPoint((0, 0))
- (2.0, 3.0)
- >>> t3.transformPoint((100, 100))
- (-48.0, 53.0)
- >>> t = Identity.scale(0.5).translate(100, 200).skew(0.1, 0.2)
- >>> t.transformPoints([(0, 0), (1, 1), (100, 100)])
- [(50.0, 100.0), (50.550167336042726, 100.60135501775433), (105.01673360427253, 160.13550177543362)]
- >>>
- """
- import math
- from typing import NamedTuple
- from dataclasses import dataclass
- __all__ = ["Transform", "Identity", "Offset", "Scale", "DecomposedTransform"]
- _EPSILON = 1e-15
- _ONE_EPSILON = 1 - _EPSILON
- _MINUS_ONE_EPSILON = -1 + _EPSILON
- def _normSinCos(v):
- if abs(v) < _EPSILON:
- v = 0
- elif v > _ONE_EPSILON:
- v = 1
- elif v < _MINUS_ONE_EPSILON:
- v = -1
- return v
- class Transform(NamedTuple):
- """2x2 transformation matrix plus offset, a.k.a. Affine transform.
- Transform instances are immutable: all transforming methods, eg.
- rotate(), return a new Transform instance.
- :Example:
- >>> t = Transform()
- >>> t
- <Transform [1 0 0 1 0 0]>
- >>> t.scale(2)
- <Transform [2 0 0 2 0 0]>
- >>> t.scale(2.5, 5.5)
- <Transform [2.5 0 0 5.5 0 0]>
- >>>
- >>> t.scale(2, 3).transformPoint((100, 100))
- (200, 300)
- Transform's constructor takes six arguments, all of which are
- optional, and can be used as keyword arguments::
- >>> Transform(12)
- <Transform [12 0 0 1 0 0]>
- >>> Transform(dx=12)
- <Transform [1 0 0 1 12 0]>
- >>> Transform(yx=12)
- <Transform [1 0 12 1 0 0]>
- Transform instances also behave like sequences of length 6::
- >>> len(Identity)
- 6
- >>> list(Identity)
- [1, 0, 0, 1, 0, 0]
- >>> tuple(Identity)
- (1, 0, 0, 1, 0, 0)
- Transform instances are comparable::
- >>> t1 = Identity.scale(2, 3).translate(4, 6)
- >>> t2 = Identity.translate(8, 18).scale(2, 3)
- >>> t1 == t2
- 1
- But beware of floating point rounding errors::
- >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6)
- >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3)
- >>> t1
- <Transform [0.2 0 0 0.3 0.08 0.18]>
- >>> t2
- <Transform [0.2 0 0 0.3 0.08 0.18]>
- >>> t1 == t2
- 0
- Transform instances are hashable, meaning you can use them as
- keys in dictionaries::
- >>> d = {Scale(12, 13): None}
- >>> d
- {<Transform [12 0 0 13 0 0]>: None}
- But again, beware of floating point rounding errors::
- >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6)
- >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3)
- >>> t1
- <Transform [0.2 0 0 0.3 0.08 0.18]>
- >>> t2
- <Transform [0.2 0 0 0.3 0.08 0.18]>
- >>> d = {t1: None}
- >>> d
- {<Transform [0.2 0 0 0.3 0.08 0.18]>: None}
- >>> d[t2]
- Traceback (most recent call last):
- File "<stdin>", line 1, in ?
- KeyError: <Transform [0.2 0 0 0.3 0.08 0.18]>
- """
- xx: float = 1
- xy: float = 0
- yx: float = 0
- yy: float = 1
- dx: float = 0
- dy: float = 0
- def transformPoint(self, p):
- """Transform a point.
- :Example:
- >>> t = Transform()
- >>> t = t.scale(2.5, 5.5)
- >>> t.transformPoint((100, 100))
- (250.0, 550.0)
- """
- (x, y) = p
- xx, xy, yx, yy, dx, dy = self
- return (xx * x + yx * y + dx, xy * x + yy * y + dy)
- def transformPoints(self, points):
- """Transform a list of points.
- :Example:
- >>> t = Scale(2, 3)
- >>> t.transformPoints([(0, 0), (0, 100), (100, 100), (100, 0)])
- [(0, 0), (0, 300), (200, 300), (200, 0)]
- >>>
- """
- xx, xy, yx, yy, dx, dy = self
- return [(xx * x + yx * y + dx, xy * x + yy * y + dy) for x, y in points]
- def transformVector(self, v):
- """Transform an (dx, dy) vector, treating translation as zero.
- :Example:
- >>> t = Transform(2, 0, 0, 2, 10, 20)
- >>> t.transformVector((3, -4))
- (6, -8)
- >>>
- """
- (dx, dy) = v
- xx, xy, yx, yy = self[:4]
- return (xx * dx + yx * dy, xy * dx + yy * dy)
- def transformVectors(self, vectors):
- """Transform a list of (dx, dy) vector, treating translation as zero.
- :Example:
- >>> t = Transform(2, 0, 0, 2, 10, 20)
- >>> t.transformVectors([(3, -4), (5, -6)])
- [(6, -8), (10, -12)]
- >>>
- """
- xx, xy, yx, yy = self[:4]
- return [(xx * dx + yx * dy, xy * dx + yy * dy) for dx, dy in vectors]
- def translate(self, x=0, y=0):
- """Return a new transformation, translated (offset) by x, y.
- :Example:
- >>> t = Transform()
- >>> t.translate(20, 30)
- <Transform [1 0 0 1 20 30]>
- >>>
- """
- return self.transform((1, 0, 0, 1, x, y))
- def scale(self, x=1, y=None):
- """Return a new transformation, scaled by x, y. The 'y' argument
- may be None, which implies to use the x value for y as well.
- :Example:
- >>> t = Transform()
- >>> t.scale(5)
- <Transform [5 0 0 5 0 0]>
- >>> t.scale(5, 6)
- <Transform [5 0 0 6 0 0]>
- >>>
- """
- if y is None:
- y = x
- return self.transform((x, 0, 0, y, 0, 0))
- def rotate(self, angle):
- """Return a new transformation, rotated by 'angle' (radians).
- :Example:
- >>> import math
- >>> t = Transform()
- >>> t.rotate(math.pi / 2)
- <Transform [0 1 -1 0 0 0]>
- >>>
- """
- import math
- c = _normSinCos(math.cos(angle))
- s = _normSinCos(math.sin(angle))
- return self.transform((c, s, -s, c, 0, 0))
- def skew(self, x=0, y=0):
- """Return a new transformation, skewed by x and y.
- :Example:
- >>> import math
- >>> t = Transform()
- >>> t.skew(math.pi / 4)
- <Transform [1 0 1 1 0 0]>
- >>>
- """
- import math
- return self.transform((1, math.tan(y), math.tan(x), 1, 0, 0))
- def transform(self, other):
- """Return a new transformation, transformed by another
- transformation.
- :Example:
- >>> t = Transform(2, 0, 0, 3, 1, 6)
- >>> t.transform((4, 3, 2, 1, 5, 6))
- <Transform [8 9 4 3 11 24]>
- >>>
- """
- xx1, xy1, yx1, yy1, dx1, dy1 = other
- xx2, xy2, yx2, yy2, dx2, dy2 = self
- return self.__class__(
- xx1 * xx2 + xy1 * yx2,
- xx1 * xy2 + xy1 * yy2,
- yx1 * xx2 + yy1 * yx2,
- yx1 * xy2 + yy1 * yy2,
- xx2 * dx1 + yx2 * dy1 + dx2,
- xy2 * dx1 + yy2 * dy1 + dy2,
- )
- def reverseTransform(self, other):
- """Return a new transformation, which is the other transformation
- transformed by self. self.reverseTransform(other) is equivalent to
- other.transform(self).
- :Example:
- >>> t = Transform(2, 0, 0, 3, 1, 6)
- >>> t.reverseTransform((4, 3, 2, 1, 5, 6))
- <Transform [8 6 6 3 21 15]>
- >>> Transform(4, 3, 2, 1, 5, 6).transform((2, 0, 0, 3, 1, 6))
- <Transform [8 6 6 3 21 15]>
- >>>
- """
- xx1, xy1, yx1, yy1, dx1, dy1 = self
- xx2, xy2, yx2, yy2, dx2, dy2 = other
- return self.__class__(
- xx1 * xx2 + xy1 * yx2,
- xx1 * xy2 + xy1 * yy2,
- yx1 * xx2 + yy1 * yx2,
- yx1 * xy2 + yy1 * yy2,
- xx2 * dx1 + yx2 * dy1 + dx2,
- xy2 * dx1 + yy2 * dy1 + dy2,
- )
- def inverse(self):
- """Return the inverse transformation.
- :Example:
- >>> t = Identity.translate(2, 3).scale(4, 5)
- >>> t.transformPoint((10, 20))
- (42, 103)
- >>> it = t.inverse()
- >>> it.transformPoint((42, 103))
- (10.0, 20.0)
- >>>
- """
- if self == Identity:
- return self
- xx, xy, yx, yy, dx, dy = self
- det = xx * yy - yx * xy
- xx, xy, yx, yy = yy / det, -xy / det, -yx / det, xx / det
- dx, dy = -xx * dx - yx * dy, -xy * dx - yy * dy
- return self.__class__(xx, xy, yx, yy, dx, dy)
- def toPS(self):
- """Return a PostScript representation
- :Example:
- >>> t = Identity.scale(2, 3).translate(4, 5)
- >>> t.toPS()
- '[2 0 0 3 8 15]'
- >>>
- """
- return "[%s %s %s %s %s %s]" % self
- def toDecomposed(self) -> "DecomposedTransform":
- """Decompose into a DecomposedTransform."""
- return DecomposedTransform.fromTransform(self)
- def __bool__(self):
- """Returns True if transform is not identity, False otherwise.
- :Example:
- >>> bool(Identity)
- False
- >>> bool(Transform())
- False
- >>> bool(Scale(1.))
- False
- >>> bool(Scale(2))
- True
- >>> bool(Offset())
- False
- >>> bool(Offset(0))
- False
- >>> bool(Offset(2))
- True
- """
- return self != Identity
- def __repr__(self):
- return "<%s [%g %g %g %g %g %g]>" % ((self.__class__.__name__,) + self)
- Identity = Transform()
- def Offset(x=0, y=0):
- """Return the identity transformation offset by x, y.
- :Example:
- >>> Offset(2, 3)
- <Transform [1 0 0 1 2 3]>
- >>>
- """
- return Transform(1, 0, 0, 1, x, y)
- def Scale(x, y=None):
- """Return the identity transformation scaled by x, y. The 'y' argument
- may be None, which implies to use the x value for y as well.
- :Example:
- >>> Scale(2, 3)
- <Transform [2 0 0 3 0 0]>
- >>>
- """
- if y is None:
- y = x
- return Transform(x, 0, 0, y, 0, 0)
- @dataclass
- class DecomposedTransform:
- """The DecomposedTransform class implements a transformation with separate
- translate, rotation, scale, skew, and transformation-center components.
- """
- translateX: float = 0
- translateY: float = 0
- rotation: float = 0 # in degrees, counter-clockwise
- scaleX: float = 1
- scaleY: float = 1
- skewX: float = 0 # in degrees, clockwise
- skewY: float = 0 # in degrees, counter-clockwise
- tCenterX: float = 0
- tCenterY: float = 0
- @classmethod
- def fromTransform(self, transform):
- # Adapted from an answer on
- # https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix
- a, b, c, d, x, y = transform
- sx = math.copysign(1, a)
- if sx < 0:
- a *= sx
- b *= sx
- delta = a * d - b * c
- rotation = 0
- scaleX = scaleY = 0
- skewX = skewY = 0
- # Apply the QR-like decomposition.
- if a != 0 or b != 0:
- r = math.sqrt(a * a + b * b)
- rotation = math.acos(a / r) if b >= 0 else -math.acos(a / r)
- scaleX, scaleY = (r, delta / r)
- skewX, skewY = (math.atan((a * c + b * d) / (r * r)), 0)
- elif c != 0 or d != 0:
- s = math.sqrt(c * c + d * d)
- rotation = math.pi / 2 - (
- math.acos(-c / s) if d >= 0 else -math.acos(c / s)
- )
- scaleX, scaleY = (delta / s, s)
- skewX, skewY = (0, math.atan((a * c + b * d) / (s * s)))
- else:
- # a = b = c = d = 0
- pass
- return DecomposedTransform(
- x,
- y,
- math.degrees(rotation),
- scaleX * sx,
- scaleY,
- math.degrees(skewX) * sx,
- math.degrees(skewY),
- 0,
- 0,
- )
- def toTransform(self):
- """Return the Transform() equivalent of this transformation.
- :Example:
- >>> DecomposedTransform(scaleX=2, scaleY=2).toTransform()
- <Transform [2 0 0 2 0 0]>
- >>>
- """
- t = Transform()
- t = t.translate(
- self.translateX + self.tCenterX, self.translateY + self.tCenterY
- )
- t = t.rotate(math.radians(self.rotation))
- t = t.scale(self.scaleX, self.scaleY)
- t = t.skew(math.radians(self.skewX), math.radians(self.skewY))
- t = t.translate(-self.tCenterX, -self.tCenterY)
- return t
- if __name__ == "__main__":
- import sys
- import doctest
- sys.exit(doctest.testmod().failed)
|