diff --git a/README.rst b/README.rst index 1d7d57d..fcd5b1b 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,7 @@ Matrices describing 2D affine transformation of the plane. :alt: Documentation Status The Affine package is derived from Casey Duncan's Planar package. Please see -the copyright statement in `affine.py `__. +the copyright statement in `src/affine.py `__. Usage ----- diff --git a/pyproject.toml b/pyproject.toml index 7ef0159..706e5e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,10 @@ include = [ "tests/", ] +[tool.pytest.ini_options] +pythonpath = "src" +testpaths = ["tests"] + [tool.ruff.lint] select = [ "B", # flake8-bugbear @@ -56,7 +60,9 @@ select = [ "I", # isort "NPY", # NumPy-specific rules "PT", # flake8-pytest-style + "RET", # flake8-return "RUF", # Ruff-specific rules + "SIM", # flake8-simplify "UP", # pyupgrade ] ignore = [ diff --git a/src/affine.py b/src/affine.py index 33af3c1..4f6fca4 100644 --- a/src/affine.py +++ b/src/affine.py @@ -68,9 +68,9 @@ def cos_sin_deg(deg: float): deg = deg % 360.0 if deg == 90.0: return 0.0, 1.0 - elif deg == 180.0: + if deg == 180.0: return -1.0, 0 - elif deg == 270.0: + if deg == 270.0: return 0, -1.0 rad = math.radians(deg) return math.cos(rad), math.sin(rad) @@ -255,18 +255,13 @@ def rotation(cls, angle: float, pivot=None): ca, sa = cos_sin_deg(angle) if pivot is None: return cls(ca, -sa, 0.0, sa, ca, 0.0) - else: - px, py = pivot - # fmt: off - return cls( - ca, - -sa, - px - px * ca + py * sa, - sa, - ca, - py - px * sa - py * ca, - ) - # fmt: on + px, py = pivot + # fmt: off + return cls( + ca, -sa, px - px * ca + py * sa, + sa, ca, py - px * sa - py * ca, + ) + # fmt: on @classmethod def permutation(cls, *scaling): @@ -426,8 +421,7 @@ def rotation_angle(self) -> float: l1, _ = self._scaling y, x = self.d / l1, self.a / l1 return math.degrees(math.atan2(y, x)) - else: - raise UndefinedRotationError + raise UndefinedRotationError @property def is_identity(self) -> bool: @@ -570,16 +564,9 @@ def __mul__(self, other): ------- Affine or a tuple of two floats """ - sa, sb, sc, sd, se, sf = self.a, self.b, self.c, self.d, self.e, self.f + sa, sb, sc, sd, se, sf = self[:6] if isinstance(other, Affine): - oa, ob, oc, od, oe, of = ( - other.a, - other.b, - other.c, - other.d, - other.e, - other.f, - ) + oa, ob, oc, od, oe, of = other[:6] return self.__class__( sa * oa + sb * od, sa * ob + sb * oe, @@ -588,12 +575,11 @@ def __mul__(self, other): sd * ob + se * oe, sd * oc + se * of + sf, ) - else: - try: - vx, vy = other - return (vx * sa + vy * sb + sc, vx * sd + vy * se + sf) - except (ValueError, TypeError): - return NotImplemented + try: + vx, vy = other + return (vx * sa + vy * sb + sc, vx * sd + vy * se + sf) + except (ValueError, TypeError): + return NotImplemented def __rmul__(self, other): """Right hand multiplication. @@ -608,7 +594,7 @@ def __rmul__(self, other): Returns ------- - Affine + tuple of two floats Notes ----- @@ -626,10 +612,9 @@ def __rmul__(self, other): def __imul__(self, other): """Provide wrapper for `__mul__`, however `other` is not modified in-place.""" - if isinstance(other, Affine) or isinstance(other, tuple): + if isinstance(other, (Affine, tuple)): return self.__mul__(other) - else: - return NotImplemented + return NotImplemented def itransform(self, seq) -> None: """Transform a sequence of points or vectors in-place. @@ -644,7 +629,7 @@ def itransform(self, seq) -> None: The input sequence is mutated in-place. """ if self is not identity and self != identity: - sa, sb, sc, sd, se, sf = self.a, self.b, self.c, self.d, self.e, self.f + sa, sb, sc, sd, se, sf = self[:6] for i, (x, y) in enumerate(seq): seq[i] = (x * sa + y * sb + sc, x * sd + y * se + sf) @@ -659,19 +644,17 @@ def __invert__(self): if self.is_degenerate: raise TransformNotInvertibleError("Cannot invert degenerate transform") idet = 1.0 / self.determinant - sa, sb, sc, sd, se, sf = self.a, self.b, self.c, self.d, self.e, self.f + sa, sb, sc, sd, se, sf = self[:6] ra = se * idet rb = -sb * idet rd = -sd * idet re = sa * idet + # fmt: off return self.__class__( - ra, - rb, - -sc * ra - sf * rb, - rd, - re, - -sc * rd - sf * re, + ra, rb, -sc * ra - sf * rb, + rd, re, -sc * rd - sf * re, ) + # fmt: on def __getnewargs__(self): """Pickle protocol support. @@ -682,7 +665,7 @@ def __getnewargs__(self): 9 elements rather than the 6 that are required for the constructor. This method ensures that only the 6 are provided. """ - return self.a, self.b, self.c, self.d, self.e, self.f + return self[:6] identity = Affine(1, 0, 0, 0, 1, 0) diff --git a/tests/test_numpy.py b/tests/test_numpy.py index 128ab1d..200045d 100644 --- a/tests/test_numpy.py +++ b/tests/test_numpy.py @@ -12,27 +12,29 @@ def test_array(): - tfm = Affine(*np.linspace(0.1, 0.6, 6)) - tfm_ar = np.array(tfm) + a, b, c, d, e, f = (np.arange(6) + 1) / 10 + tfm = Affine(a, b, c, d, e, f) expected = np.array( [ - [0.1, 0.2, 0.3], - [0.4, 0.5, 0.6], - [0.0, 0.0, 1.0], + [a, b, c], + [d, e, f], + [0, 0, 1], ], ) - assert tfm_ar.shape == (3, 3) - assert tfm_ar.dtype == np.float64 - testing.assert_allclose(tfm_ar, expected) + ar = np.array(tfm) + assert ar.shape == (3, 3) + assert ar.dtype == np.float64 + testing.assert_array_equal(ar, expected) # dtype option - tfm_ar = np.array(tfm, dtype=np.float32) - assert tfm_ar.shape == (3, 3) - assert tfm_ar.dtype == np.float32 - testing.assert_allclose(tfm_ar, expected) + ar = np.array(tfm, dtype=np.float32) + assert ar.shape == (3, 3) + assert ar.dtype == np.float32 + testing.assert_allclose(ar, expected) # copy option - tfm_ar = np.array(tfm, copy=True) # default None does the same + ar = np.array(tfm, copy=True) # default None does the same + testing.assert_allclose(ar, expected) # Behaviour of copy=False is different between NumPy 1.x and 2.x if int(np.version.short_version.split(".", 1)[0]) >= 2: @@ -40,3 +42,27 @@ def test_array(): np.array(tfm, copy=False) else: testing.assert_allclose(np.array(tfm, copy=False), expected) + + +def test_linalg(): + # cross-check properties with numpy's linear algebra module + ar = np.array( + [ + [0, -2, 2], + [3, 0, 5], + [0, 0, 1], + ] + ) + tfm = Affine(*ar.flatten()) + assert tfm.determinant == pytest.approx(6.0) + assert np.linalg.det(ar) == pytest.approx(6.0) + + expected_inv = np.array( + [ + [0, 1 / 3, -5 / 3], + [-1 / 2, 0, 1], + [0, 0, 1], + ] + ) + testing.assert_allclose(~tfm, expected_inv) + testing.assert_allclose(np.linalg.inv(ar), expected_inv) diff --git a/tests/test_transform.py b/tests/test_transform.py index ac2f19f..975592a 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -78,6 +78,22 @@ def test_slice_last_row(): assert t[-3:] == (0, 0, 1) +def test_members(): + t = Affine(1, 2, 3, 4, 5, 6) + assert t.a == 1 + assert t.b == 2 + assert t.c == 3 + assert t.d == 4 + assert t.e == 5 + assert t.f == 6 + assert t.g == 0 + assert t.h == 0 + assert t.i == 1 + # these are aliases + assert t.c is t.xoff + assert t.f is t.yoff + + def test_members_are_floats(): t = Affine(1, 2, 3, 4, 5, 6) for m in t: @@ -463,15 +479,13 @@ def test_shapely(): def test_imul_number(): t = Affine(1, 2, 3, 4, 5, 6) - try: + with pytest.raises(TypeError): t *= 2.0 - except TypeError: - assert True def test_mul_tuple(): t = Affine(1, 2, 3, 4, 5, 6) - t * (2.0, 2.0) + assert t * (2, 2) == (9, 24) def test_rmul_tuple():