Skip to main content
Glama
transform.py15.8 kB
"""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)] >>> """ from __future__ import annotations 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: float) -> float: 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: float = 0, y: float = 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: float = 1, y: float | None = 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: float): """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]> >>> """ c = _normSinCos(math.cos(angle)) s = _normSinCos(math.sin(angle)) return self.transform((c, s, -s, c, 0, 0)) def skew(self, x: float = 0, y: float = 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]> >>> """ 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) -> str: """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) -> bool: """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) -> str: return "<%s [%g %g %g %g %g %g]>" % ((self.__class__.__name__,) + self) Identity = Transform() def Offset(x: float = 0, y: float = 0) -> Transform: """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: float, y: float | None = None) -> Transform: """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 def __bool__(self): return ( self.translateX != 0 or self.translateY != 0 or self.rotation != 0 or self.scaleX != 1 or self.scaleY != 1 or self.skewX != 0 or self.skewY != 0 or self.tCenterX != 0 or self.tCenterY != 0 ) @classmethod def fromTransform(self, transform): """Return a DecomposedTransform() equivalent of this transformation. The returned solution always has skewY = 0, and angle in the (-180, 180]. :Example: >>> DecomposedTransform.fromTransform(Transform(3, 0, 0, 2, 0, 0)) DecomposedTransform(translateX=0, translateY=0, rotation=0.0, scaleX=3.0, scaleY=2.0, skewX=0.0, skewY=0.0, tCenterX=0, tCenterY=0) >>> DecomposedTransform.fromTransform(Transform(0, 0, 0, 1, 0, 0)) DecomposedTransform(translateX=0, translateY=0, rotation=0.0, scaleX=0.0, scaleY=1.0, skewX=0.0, skewY=0.0, tCenterX=0, tCenterY=0) >>> DecomposedTransform.fromTransform(Transform(0, 0, 1, 1, 0, 0)) DecomposedTransform(translateX=0, translateY=0, rotation=-45.0, scaleX=0.0, scaleY=1.4142135623730951, skewX=0.0, skewY=0.0, tCenterX=0, tCenterY=0) """ # 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 = 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 = math.atan((a * c + b * d) / (r * r)) 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) else: # a = b = c = d = 0 pass return DecomposedTransform( x, y, math.degrees(rotation), scaleX * sx, scaleY, math.degrees(skewX) * sx, 0.0, 0, 0, ) def toTransform(self) -> Transform: """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)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Lillard01/chatExcel-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server