transform.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. """Affine 2D transformation matrix class.
  2. The Transform class implements various transformation matrix operations,
  3. both on the matrix itself, as well as on 2D coordinates.
  4. Transform instances are effectively immutable: all methods that operate on the
  5. transformation itself always return a new instance. This has as the
  6. interesting side effect that Transform instances are hashable, ie. they can be
  7. used as dictionary keys.
  8. This module exports the following symbols:
  9. Transform
  10. this is the main class
  11. Identity
  12. Transform instance set to the identity transformation
  13. Offset
  14. Convenience function that returns a translating transformation
  15. Scale
  16. Convenience function that returns a scaling transformation
  17. The DecomposedTransform class implements a transformation with separate
  18. translate, rotation, scale, skew, and transformation-center components.
  19. :Example:
  20. >>> t = Transform(2, 0, 0, 3, 0, 0)
  21. >>> t.transformPoint((100, 100))
  22. (200, 300)
  23. >>> t = Scale(2, 3)
  24. >>> t.transformPoint((100, 100))
  25. (200, 300)
  26. >>> t.transformPoint((0, 0))
  27. (0, 0)
  28. >>> t = Offset(2, 3)
  29. >>> t.transformPoint((100, 100))
  30. (102, 103)
  31. >>> t.transformPoint((0, 0))
  32. (2, 3)
  33. >>> t2 = t.scale(0.5)
  34. >>> t2.transformPoint((100, 100))
  35. (52.0, 53.0)
  36. >>> import math
  37. >>> t3 = t2.rotate(math.pi / 2)
  38. >>> t3.transformPoint((0, 0))
  39. (2.0, 3.0)
  40. >>> t3.transformPoint((100, 100))
  41. (-48.0, 53.0)
  42. >>> t = Identity.scale(0.5).translate(100, 200).skew(0.1, 0.2)
  43. >>> t.transformPoints([(0, 0), (1, 1), (100, 100)])
  44. [(50.0, 100.0), (50.550167336042726, 100.60135501775433), (105.01673360427253, 160.13550177543362)]
  45. >>>
  46. """
  47. from __future__ import annotations
  48. import math
  49. from typing import NamedTuple
  50. from dataclasses import dataclass
  51. __all__ = ["Transform", "Identity", "Offset", "Scale", "DecomposedTransform"]
  52. _EPSILON = 1e-15
  53. _ONE_EPSILON = 1 - _EPSILON
  54. _MINUS_ONE_EPSILON = -1 + _EPSILON
  55. def _normSinCos(v: float) -> float:
  56. if abs(v) < _EPSILON:
  57. v = 0
  58. elif v > _ONE_EPSILON:
  59. v = 1
  60. elif v < _MINUS_ONE_EPSILON:
  61. v = -1
  62. return v
  63. class Transform(NamedTuple):
  64. """2x2 transformation matrix plus offset, a.k.a. Affine transform.
  65. Transform instances are immutable: all transforming methods, eg.
  66. rotate(), return a new Transform instance.
  67. :Example:
  68. >>> t = Transform()
  69. >>> t
  70. <Transform [1 0 0 1 0 0]>
  71. >>> t.scale(2)
  72. <Transform [2 0 0 2 0 0]>
  73. >>> t.scale(2.5, 5.5)
  74. <Transform [2.5 0 0 5.5 0 0]>
  75. >>>
  76. >>> t.scale(2, 3).transformPoint((100, 100))
  77. (200, 300)
  78. Transform's constructor takes six arguments, all of which are
  79. optional, and can be used as keyword arguments::
  80. >>> Transform(12)
  81. <Transform [12 0 0 1 0 0]>
  82. >>> Transform(dx=12)
  83. <Transform [1 0 0 1 12 0]>
  84. >>> Transform(yx=12)
  85. <Transform [1 0 12 1 0 0]>
  86. Transform instances also behave like sequences of length 6::
  87. >>> len(Identity)
  88. 6
  89. >>> list(Identity)
  90. [1, 0, 0, 1, 0, 0]
  91. >>> tuple(Identity)
  92. (1, 0, 0, 1, 0, 0)
  93. Transform instances are comparable::
  94. >>> t1 = Identity.scale(2, 3).translate(4, 6)
  95. >>> t2 = Identity.translate(8, 18).scale(2, 3)
  96. >>> t1 == t2
  97. 1
  98. But beware of floating point rounding errors::
  99. >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6)
  100. >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3)
  101. >>> t1
  102. <Transform [0.2 0 0 0.3 0.08 0.18]>
  103. >>> t2
  104. <Transform [0.2 0 0 0.3 0.08 0.18]>
  105. >>> t1 == t2
  106. 0
  107. Transform instances are hashable, meaning you can use them as
  108. keys in dictionaries::
  109. >>> d = {Scale(12, 13): None}
  110. >>> d
  111. {<Transform [12 0 0 13 0 0]>: None}
  112. But again, beware of floating point rounding errors::
  113. >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6)
  114. >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3)
  115. >>> t1
  116. <Transform [0.2 0 0 0.3 0.08 0.18]>
  117. >>> t2
  118. <Transform [0.2 0 0 0.3 0.08 0.18]>
  119. >>> d = {t1: None}
  120. >>> d
  121. {<Transform [0.2 0 0 0.3 0.08 0.18]>: None}
  122. >>> d[t2]
  123. Traceback (most recent call last):
  124. File "<stdin>", line 1, in ?
  125. KeyError: <Transform [0.2 0 0 0.3 0.08 0.18]>
  126. """
  127. xx: float = 1
  128. xy: float = 0
  129. yx: float = 0
  130. yy: float = 1
  131. dx: float = 0
  132. dy: float = 0
  133. def transformPoint(self, p):
  134. """Transform a point.
  135. :Example:
  136. >>> t = Transform()
  137. >>> t = t.scale(2.5, 5.5)
  138. >>> t.transformPoint((100, 100))
  139. (250.0, 550.0)
  140. """
  141. (x, y) = p
  142. xx, xy, yx, yy, dx, dy = self
  143. return (xx * x + yx * y + dx, xy * x + yy * y + dy)
  144. def transformPoints(self, points):
  145. """Transform a list of points.
  146. :Example:
  147. >>> t = Scale(2, 3)
  148. >>> t.transformPoints([(0, 0), (0, 100), (100, 100), (100, 0)])
  149. [(0, 0), (0, 300), (200, 300), (200, 0)]
  150. >>>
  151. """
  152. xx, xy, yx, yy, dx, dy = self
  153. return [(xx * x + yx * y + dx, xy * x + yy * y + dy) for x, y in points]
  154. def transformVector(self, v):
  155. """Transform an (dx, dy) vector, treating translation as zero.
  156. :Example:
  157. >>> t = Transform(2, 0, 0, 2, 10, 20)
  158. >>> t.transformVector((3, -4))
  159. (6, -8)
  160. >>>
  161. """
  162. (dx, dy) = v
  163. xx, xy, yx, yy = self[:4]
  164. return (xx * dx + yx * dy, xy * dx + yy * dy)
  165. def transformVectors(self, vectors):
  166. """Transform a list of (dx, dy) vector, treating translation as zero.
  167. :Example:
  168. >>> t = Transform(2, 0, 0, 2, 10, 20)
  169. >>> t.transformVectors([(3, -4), (5, -6)])
  170. [(6, -8), (10, -12)]
  171. >>>
  172. """
  173. xx, xy, yx, yy = self[:4]
  174. return [(xx * dx + yx * dy, xy * dx + yy * dy) for dx, dy in vectors]
  175. def translate(self, x: float = 0, y: float = 0):
  176. """Return a new transformation, translated (offset) by x, y.
  177. :Example:
  178. >>> t = Transform()
  179. >>> t.translate(20, 30)
  180. <Transform [1 0 0 1 20 30]>
  181. >>>
  182. """
  183. return self.transform((1, 0, 0, 1, x, y))
  184. def scale(self, x: float = 1, y: float | None = None):
  185. """Return a new transformation, scaled by x, y. The 'y' argument
  186. may be None, which implies to use the x value for y as well.
  187. :Example:
  188. >>> t = Transform()
  189. >>> t.scale(5)
  190. <Transform [5 0 0 5 0 0]>
  191. >>> t.scale(5, 6)
  192. <Transform [5 0 0 6 0 0]>
  193. >>>
  194. """
  195. if y is None:
  196. y = x
  197. return self.transform((x, 0, 0, y, 0, 0))
  198. def rotate(self, angle: float):
  199. """Return a new transformation, rotated by 'angle' (radians).
  200. :Example:
  201. >>> import math
  202. >>> t = Transform()
  203. >>> t.rotate(math.pi / 2)
  204. <Transform [0 1 -1 0 0 0]>
  205. >>>
  206. """
  207. c = _normSinCos(math.cos(angle))
  208. s = _normSinCos(math.sin(angle))
  209. return self.transform((c, s, -s, c, 0, 0))
  210. def skew(self, x: float = 0, y: float = 0):
  211. """Return a new transformation, skewed by x and y.
  212. :Example:
  213. >>> import math
  214. >>> t = Transform()
  215. >>> t.skew(math.pi / 4)
  216. <Transform [1 0 1 1 0 0]>
  217. >>>
  218. """
  219. return self.transform((1, math.tan(y), math.tan(x), 1, 0, 0))
  220. def transform(self, other):
  221. """Return a new transformation, transformed by another
  222. transformation.
  223. :Example:
  224. >>> t = Transform(2, 0, 0, 3, 1, 6)
  225. >>> t.transform((4, 3, 2, 1, 5, 6))
  226. <Transform [8 9 4 3 11 24]>
  227. >>>
  228. """
  229. xx1, xy1, yx1, yy1, dx1, dy1 = other
  230. xx2, xy2, yx2, yy2, dx2, dy2 = self
  231. return self.__class__(
  232. xx1 * xx2 + xy1 * yx2,
  233. xx1 * xy2 + xy1 * yy2,
  234. yx1 * xx2 + yy1 * yx2,
  235. yx1 * xy2 + yy1 * yy2,
  236. xx2 * dx1 + yx2 * dy1 + dx2,
  237. xy2 * dx1 + yy2 * dy1 + dy2,
  238. )
  239. def reverseTransform(self, other):
  240. """Return a new transformation, which is the other transformation
  241. transformed by self. self.reverseTransform(other) is equivalent to
  242. other.transform(self).
  243. :Example:
  244. >>> t = Transform(2, 0, 0, 3, 1, 6)
  245. >>> t.reverseTransform((4, 3, 2, 1, 5, 6))
  246. <Transform [8 6 6 3 21 15]>
  247. >>> Transform(4, 3, 2, 1, 5, 6).transform((2, 0, 0, 3, 1, 6))
  248. <Transform [8 6 6 3 21 15]>
  249. >>>
  250. """
  251. xx1, xy1, yx1, yy1, dx1, dy1 = self
  252. xx2, xy2, yx2, yy2, dx2, dy2 = other
  253. return self.__class__(
  254. xx1 * xx2 + xy1 * yx2,
  255. xx1 * xy2 + xy1 * yy2,
  256. yx1 * xx2 + yy1 * yx2,
  257. yx1 * xy2 + yy1 * yy2,
  258. xx2 * dx1 + yx2 * dy1 + dx2,
  259. xy2 * dx1 + yy2 * dy1 + dy2,
  260. )
  261. def inverse(self):
  262. """Return the inverse transformation.
  263. :Example:
  264. >>> t = Identity.translate(2, 3).scale(4, 5)
  265. >>> t.transformPoint((10, 20))
  266. (42, 103)
  267. >>> it = t.inverse()
  268. >>> it.transformPoint((42, 103))
  269. (10.0, 20.0)
  270. >>>
  271. """
  272. if self == Identity:
  273. return self
  274. xx, xy, yx, yy, dx, dy = self
  275. det = xx * yy - yx * xy
  276. xx, xy, yx, yy = yy / det, -xy / det, -yx / det, xx / det
  277. dx, dy = -xx * dx - yx * dy, -xy * dx - yy * dy
  278. return self.__class__(xx, xy, yx, yy, dx, dy)
  279. def toPS(self) -> str:
  280. """Return a PostScript representation
  281. :Example:
  282. >>> t = Identity.scale(2, 3).translate(4, 5)
  283. >>> t.toPS()
  284. '[2 0 0 3 8 15]'
  285. >>>
  286. """
  287. return "[%s %s %s %s %s %s]" % self
  288. def toDecomposed(self) -> "DecomposedTransform":
  289. """Decompose into a DecomposedTransform."""
  290. return DecomposedTransform.fromTransform(self)
  291. def __bool__(self) -> bool:
  292. """Returns True if transform is not identity, False otherwise.
  293. :Example:
  294. >>> bool(Identity)
  295. False
  296. >>> bool(Transform())
  297. False
  298. >>> bool(Scale(1.))
  299. False
  300. >>> bool(Scale(2))
  301. True
  302. >>> bool(Offset())
  303. False
  304. >>> bool(Offset(0))
  305. False
  306. >>> bool(Offset(2))
  307. True
  308. """
  309. return self != Identity
  310. def __repr__(self) -> str:
  311. return "<%s [%g %g %g %g %g %g]>" % ((self.__class__.__name__,) + self)
  312. Identity = Transform()
  313. def Offset(x: float = 0, y: float = 0) -> Transform:
  314. """Return the identity transformation offset by x, y.
  315. :Example:
  316. >>> Offset(2, 3)
  317. <Transform [1 0 0 1 2 3]>
  318. >>>
  319. """
  320. return Transform(1, 0, 0, 1, x, y)
  321. def Scale(x: float, y: float | None = None) -> Transform:
  322. """Return the identity transformation scaled by x, y. The 'y' argument
  323. may be None, which implies to use the x value for y as well.
  324. :Example:
  325. >>> Scale(2, 3)
  326. <Transform [2 0 0 3 0 0]>
  327. >>>
  328. """
  329. if y is None:
  330. y = x
  331. return Transform(x, 0, 0, y, 0, 0)
  332. @dataclass
  333. class DecomposedTransform:
  334. """The DecomposedTransform class implements a transformation with separate
  335. translate, rotation, scale, skew, and transformation-center components.
  336. """
  337. translateX: float = 0
  338. translateY: float = 0
  339. rotation: float = 0 # in degrees, counter-clockwise
  340. scaleX: float = 1
  341. scaleY: float = 1
  342. skewX: float = 0 # in degrees, clockwise
  343. skewY: float = 0 # in degrees, counter-clockwise
  344. tCenterX: float = 0
  345. tCenterY: float = 0
  346. def __bool__(self):
  347. return (
  348. self.translateX != 0
  349. or self.translateY != 0
  350. or self.rotation != 0
  351. or self.scaleX != 1
  352. or self.scaleY != 1
  353. or self.skewX != 0
  354. or self.skewY != 0
  355. or self.tCenterX != 0
  356. or self.tCenterY != 0
  357. )
  358. @classmethod
  359. def fromTransform(self, transform):
  360. """Return a DecomposedTransform() equivalent of this transformation.
  361. The returned solution always has skewY = 0, and angle in the (-180, 180].
  362. :Example:
  363. >>> DecomposedTransform.fromTransform(Transform(3, 0, 0, 2, 0, 0))
  364. DecomposedTransform(translateX=0, translateY=0, rotation=0.0, scaleX=3.0, scaleY=2.0, skewX=0.0, skewY=0.0, tCenterX=0, tCenterY=0)
  365. >>> DecomposedTransform.fromTransform(Transform(0, 0, 0, 1, 0, 0))
  366. DecomposedTransform(translateX=0, translateY=0, rotation=0.0, scaleX=0.0, scaleY=1.0, skewX=0.0, skewY=0.0, tCenterX=0, tCenterY=0)
  367. >>> DecomposedTransform.fromTransform(Transform(0, 0, 1, 1, 0, 0))
  368. DecomposedTransform(translateX=0, translateY=0, rotation=-45.0, scaleX=0.0, scaleY=1.4142135623730951, skewX=0.0, skewY=0.0, tCenterX=0, tCenterY=0)
  369. """
  370. # Adapted from an answer on
  371. # https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix
  372. a, b, c, d, x, y = transform
  373. sx = math.copysign(1, a)
  374. if sx < 0:
  375. a *= sx
  376. b *= sx
  377. delta = a * d - b * c
  378. rotation = 0
  379. scaleX = scaleY = 0
  380. skewX = 0
  381. # Apply the QR-like decomposition.
  382. if a != 0 or b != 0:
  383. r = math.sqrt(a * a + b * b)
  384. rotation = math.acos(a / r) if b >= 0 else -math.acos(a / r)
  385. scaleX, scaleY = (r, delta / r)
  386. skewX = math.atan((a * c + b * d) / (r * r))
  387. elif c != 0 or d != 0:
  388. s = math.sqrt(c * c + d * d)
  389. rotation = math.pi / 2 - (
  390. math.acos(-c / s) if d >= 0 else -math.acos(c / s)
  391. )
  392. scaleX, scaleY = (delta / s, s)
  393. else:
  394. # a = b = c = d = 0
  395. pass
  396. return DecomposedTransform(
  397. x,
  398. y,
  399. math.degrees(rotation),
  400. scaleX * sx,
  401. scaleY,
  402. math.degrees(skewX) * sx,
  403. 0.0,
  404. 0,
  405. 0,
  406. )
  407. def toTransform(self) -> Transform:
  408. """Return the Transform() equivalent of this transformation.
  409. :Example:
  410. >>> DecomposedTransform(scaleX=2, scaleY=2).toTransform()
  411. <Transform [2 0 0 2 0 0]>
  412. >>>
  413. """
  414. t = Transform()
  415. t = t.translate(
  416. self.translateX + self.tCenterX, self.translateY + self.tCenterY
  417. )
  418. t = t.rotate(math.radians(self.rotation))
  419. t = t.scale(self.scaleX, self.scaleY)
  420. t = t.skew(math.radians(self.skewX), math.radians(self.skewY))
  421. t = t.translate(-self.tCenterX, -self.tCenterY)
  422. return t
  423. if __name__ == "__main__":
  424. import sys
  425. import doctest
  426. sys.exit(doctest.testmod().failed)