Created
April 29, 2020 09:56
-
-
Save madig/459ba7775aff7b9f3ba16e0aff7b65cc to your computer and use it in GitHub Desktop.
transform named tuple
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""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 | |
Examples: | |
>>> 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)] | |
>>> | |
""" | |
_EPSILON = 1e-15 | |
_ONE_EPSILON = 1 - _EPSILON | |
_MINUS_ONE_EPSILON = -1 + _EPSILON | |
import typing as t | |
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(t.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. | |
Examples: | |
>>> 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) | |
""" | |
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 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 == (1, 0, 0, 1, 0, 0): | |
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: | |
>>> 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 __len__(self): | |
"""Transform instances also behave like sequences of length 6: | |
>>> len(Identity) | |
6 | |
>>> | |
""" | |
return 6 | |
def __getitem__(self, index): | |
"""Transform instances also behave like sequences of length 6: | |
>>> list(Identity) | |
[1, 0, 0, 1, 0, 0] | |
>>> tuple(Identity) | |
(1, 0, 0, 1, 0, 0) | |
>>> | |
""" | |
return self[index] | |
def __ne__(self, other): | |
return not self.__eq__(other) | |
def __eq__(self, other): | |
"""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 | |
>>> | |
""" | |
xx1, xy1, yx1, yy1, dx1, dy1 = self | |
xx2, xy2, yx2, yy2, dx2, dy2 = other | |
return (xx1, xy1, yx1, yy1, dx1, dy1) == (xx2, xy2, yx2, yy2, dx2, dy2) | |
def __hash__(self): | |
"""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]> | |
>>> | |
""" | |
return hash(self) | |
def __bool__(self): | |
"""Returns True if transform is not identity, False otherwise. | |
>>> 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.__affine | |
def __nonzero__(self): | |
return bool(self) | |
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) | |
def test(transf: t.Tuple[float, float, float, float, float, float]): | |
pass | |
test(Transform(1, 2, 3, 4, 5, 6)) | |
test((1, 2, 3, 4, 5, 6)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment