[Git][debian-gis-team/python-affine][upstream] New upstream version 3.0.0
Antonio Valentino (@antonio.valentino)
gitlab at salsa.debian.org
Sat Aug 8 17:01:06 BST 2026
Antonio Valentino pushed to branch upstream at Debian GIS Project / python-affine
Commits:
8fa9e1be by Antonio Valentino at 2026-08-08T15:44:56+00:00
New upstream version 3.0.0
- - - - -
26 changed files:
- + .github/dependabot.yml
- .github/workflows/ci.yml
- .gitignore
- + .pre-commit-config.yaml
- + .readthedocs.yaml
- AUTHORS.txt
- CHANGES.txt
- README.rst
- − affine/tests/test_transform.py
- + docs/Makefile
- + docs/make.bat
- + docs/requirements.txt
- + docs/src/_static/.gitkeep
- + docs/src/conf.py
- + docs/src/index.rst
- pyproject.toml
- − setup.cfg
- affine/__init__.py → src/affine/__init__.py
- + src/affine/py.typed
- affine/tests/__init__.py → tests/__init__.py
- + tests/test_numpy.py
- affine/tests/test_pickle.py → tests/test_pickle.py
- affine/tests/test_rotation.py → tests/test_rotation.py
- affine/tests/test_serialize.py → tests/test_serialize.py
- + tests/test_transform.py
- tox.ini
Changes:
=====================================
.github/dependabot.yml
=====================================
@@ -0,0 +1,11 @@
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "quarterly"
+ groups:
+ github-actions:
+ patterns:
+ - "*"
=====================================
.github/workflows/ci.yml
=====================================
@@ -9,19 +9,33 @@ on:
- '*'
pull_request:
env:
- LATEST_PY_VERSION: '3.11'
+ LATEST_PY_VERSION: '3.14'
jobs:
- tests:
+ pre-commit:
runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout at v6
+ - uses: actions/setup-python at v6
+ with:
+ python-version: 3.*
+ - name: pre-commit checks
+ shell: bash
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install pre-commit
+ pre-commit run --show-diff-on-failure --all-files
+
+ tests:
+ runs-on: ubuntu-24.04
strategy:
matrix:
- python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
+ python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
steps:
- - uses: actions/checkout at v3
+ - uses: actions/checkout at v6
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python at v4
+ uses: actions/setup-python at v6
with:
python-version: ${{ matrix.python-version }}
@@ -41,42 +55,3 @@ jobs:
files: ./coverage.xml
flags: unittests
fail_ci_if_error: false
-
- lint:
- needs: tests
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout at v3
- - name: Set up Python
- uses: actions/setup-python at v4
- with:
- python-version: ${{ env.LATEST_PY_VERSION }}
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- python -m pip install flake8 pydocstyle
-
- - name: Run lint
- run: |
- flake8 --ignore=E501,W503
- python -m pydocstyle affine
-
- typing:
- needs: tests
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout at v3
- - name: Set up Python
- uses: actions/setup-python at v4
- with:
- python-version: ${{ env.LATEST_PY_VERSION }}
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- python -m pip install mypy pytest
-
- - name: Run typing test
- run: |
- mypy affine
=====================================
.gitignore
=====================================
@@ -59,3 +59,4 @@ __pycache__
*.swp
.coverage.*
+.DS_Store
=====================================
.pre-commit-config.yaml
=====================================
@@ -0,0 +1,22 @@
+ci:
+ autoupdate_schedule: quarterly
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ - id: check-yaml
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.14.10
+ hooks:
+ # Run the linter.
+ - id: ruff-check
+ args: [ --fix ]
+ # Run the formatter.
+ - id: ruff-format
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: v1.19.1
+ hooks:
+ - id: mypy
+ additional_dependencies: [attrs]
=====================================
.readthedocs.yaml
=====================================
@@ -0,0 +1,15 @@
+version: 2
+
+build:
+ os: ubuntu-24.04
+ tools:
+ python: "3.14"
+
+sphinx:
+ configuration: docs/src/conf.py
+
+python:
+ install:
+ - requirements: docs/requirements.txt
+ - method: pip
+ path: .
=====================================
AUTHORS.txt
=====================================
@@ -1,10 +1,18 @@
Authors
=======
-- Sean Gillies <sean at mapbox.com>
-- Steven Ring <smr at southsky.com.au>
-- Mike Taves <mwtoews at gmail.com>
-- Kevin Wurster <wursterk at gmail.com>
-- Todd Small <todd_small at icloud.com>
-- Juan Luis Cano Rodríguez <juanlu at satellogic.com>
+- Casey Duncan
+- Sean Gillies
+- Denis Rykov
+- Juan Luis Cano Rodríguez
+- Kevin Wurster
- Kirill Kouzoubov
+- Loïc Dutrieux
+- Michael Wess
+- Mike Taves
+- Nick Maxwell
+- Rotzbua
+- Ryan Grout
+- Steven Ring
+- Stuart Axon
+- Vincent Sarago
=====================================
CHANGES.txt
=====================================
@@ -1,6 +1,52 @@
CHANGES
=======
+3.0.0 (2026-08-08)
+------------------
+
+- `Affine.__array__()` has been deleted to remove a dependency on Numpy and
+ associated typing annotations (#140). To convert an Affine instance to
+ a Numpy array, do this: `numpy.array(Affine()).reshape(3, 3)`.
+
+3.0rc3 (2026-01-30)
+-------------------
+
+The single file module affine.py has been moved to affine/__init__.py so that
+a py.typed file can be distributed in the same directory (#136).
+
+3.0rc2 (2026-01-27)
+-------------------
+
+3.0rc1 tagged the wrong commit. The distributions on PyPI have the correct
+version, but the GitHub release does not. This is fixed by 3.0rc2, which makes
+no other changes.
+
+3.0rc1 (2026-01-26)
+-------------------
+
+This is the first 3.0 release candidate. There are no significant changes
+since 3.0b1.
+
+3.0b1 (2025-01-29)
+------------------
+
+- Matrix multiplication with tuples of coordinate matrices, like the output of
+ numpy.meshgrid(), has been restored (#126).
+
+3.0a1 (2025-01-27)
+------------------
+
+- Type hint annotations for functions and methods are complete (#121).
+- Affine raises ValueError if initialized with values for g, h, and i that are
+ not 0.0, 0.0, and 1.0, respectively (#117).
+- Python version support was changed to 3.9+ (#110).
+- Switch from namedtuple to attrs for implementation of the Affine class and
+ use functools.cached_property(), which absolutely requires Python 3.8+
+ (#111).
+- Source was moved to a single-module affine.py in the src directory (#112).
+- Add numpy __array__ interface (#108).
+- Add support for ``@`` matrix multiplier methods (#122).
+
2.4.0 (2023-01-19)
------------------
@@ -63,7 +109,7 @@ Bug fixes:
2.0b2 (2016-05-16)
------------------
-- Bug fix: restore ``Affine __rmul__`` even though it permits dubious
+- Bug fix: restore ``Affine __rmul__`` even though it permits dubious
vector * matrix expressions (#27).
2.0b1 (2016-05-16)
=====================================
README.rst
=====================================
@@ -9,8 +9,12 @@ Matrices describing 2D affine transformation of the plane.
.. image:: https://codecov.io/gh/rasterio/affine/branch/main/graph/badge.svg
:target: https://codecov.io/gh/rasterio/affine
+.. image:: https://readthedocs.org/projects/affine/badge/?version=latest
+ :target: https://affine.readthedocs.io/en/latest/?badge=latest
+ :alt: Documentation Status
+
The Affine package is derived from Casey Duncan's Planar package. Please see
-the copyright statement in `affine/__init__.py <affine/__init__.py>`__.
+the copyright statement in `src/affine.py <src/affine.py>`__.
Usage
-----
@@ -18,7 +22,7 @@ Usage
The 3x3 augmented affine transformation matrix for transformations in two
dimensions is illustrated below.
-::
+.. code-block:: none
| x' | | a b c | | x |
| y' | = | d e f | | y |
@@ -47,8 +51,9 @@ Matrices can be created by passing the values ``a, b, c, d, e, f`` to the
Affine(0.7071067811865476, -0.7071067811865475, 0.0,
0.7071067811865475, 0.7071067811865476, 0.0)
-These matrices can be applied to ``(x, y)`` tuples to obtain transformed
-coordinates ``(x', y')``.
+These matrices can be applied to ``(x, y)`` tuples using the
+``*`` operator (or the ``@`` matrix multiplier operator for
+future releases) to obtain transformed coordinates ``(x', y')``.
.. code-block:: pycon
@@ -87,11 +92,10 @@ origin can be easily computed.
>>> fwd * (col, row)
(-237481.5, 195036.4)
-The reverse transformation is obtained using the ``~`` operator.
+The reverse transformation is obtained using the ``~`` inverse operator.
.. code-block:: pycon
>>> rev = ~fwd
>>> rev * fwd * (col, row)
(0.0, 99.99999999999999)
-
=====================================
affine/tests/test_transform.py deleted
=====================================
@@ -1,543 +0,0 @@
-#############################################################################
-# Planar is Copyright (c) 2010 by Casey Duncan
-# All rights reserved.
-#
-# Redistribution and use in source and binary forms, with or without
-# modification, are permitted provided that the following conditions are met:
-#
-# * Redistributions of source code must retain the above copyright notice,
-# this list of conditions and the following disclaimer.
-# * Redistributions in binary form must reproduce the above copyright notice,
-# this list of conditions and the following disclaimer in the documentation
-# and/or other materials provided with the distribution.
-# * Neither the name(s) of the copyright holders nor the names of its
-# contributors may be used to endorse or promote products derived from this
-# software without specific prior written permission.
-#
-# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AS IS AND ANY EXPRESS OR
-# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
-# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
-# EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT,
-# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
-# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
-# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
-# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
-# EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-#############################################################################
-
-"""Transform unit tests"""
-
-import math
-import unittest
-from textwrap import dedent
-
-import pytest
-
-import affine
-from affine import Affine, EPSILON
-
-
-def seq_almost_equal(t1, t2, error=0.00001):
- assert len(t1) == len(t2), f"{t1!r} != {t2!r}"
- for m1, m2 in zip(t1, t2):
- assert abs(m1 - m2) <= error, f"{t1!r} != {t2!r}"
-
-
-class PyAffineTestCase(unittest.TestCase):
- def test_zero_args(self):
- with pytest.raises(TypeError):
- Affine()
-
- def test_wrong_arg_type(self):
- with pytest.raises(TypeError):
- Affine(None)
-
- def test_args_too_few(self):
- with pytest.raises(TypeError):
- Affine(1, 2)
-
- def test_args_too_many(self):
- with pytest.raises(TypeError):
- Affine(*range(10))
-
- def test_args_members_wrong_type(self):
- with pytest.raises(TypeError):
- Affine(0, 2, 3, None, None, "")
-
- def test_len(self):
- t = Affine(1, 2, 3, 4, 5, 6)
- assert len(t) == 9
-
- def test_slice_last_row(self):
- t = Affine(1, 2, 3, 4, 5, 6)
- assert t[-3:] == (0, 0, 1)
-
- def test_members_are_floats(self):
- t = Affine(1, 2, 3, 4, 5, 6)
- for m in t:
- assert isinstance(m, float), repr(m)
-
- def test_getitem(self):
- t = Affine(1, 2, 3, 4, 5, 6)
- assert t[0] == 1
- assert t[1] == 2
- assert t[2] == 3
- assert t[3] == 4
- assert t[4] == 5
- assert t[5] == 6
- assert t[6] == 0
- assert t[7] == 0
- assert t[8] == 1
- assert t[-1] == 1
-
- def test_getitem_wrong_type(self):
- t = Affine(1, 2, 3, 4, 5, 6)
- with pytest.raises(TypeError):
- t["foobar"]
-
- def test_str(self):
- t = Affine(1.111, 2.222, 3.333, -4.444, -5.555, 6.666)
- assert str(t) == dedent(
- """\
- | 1.11, 2.22, 3.33|
- |-4.44,-5.55, 6.67|
- | 0.00, 0.00, 1.00|"""
- )
-
- def test_repr(self):
- t = Affine(1.111, 2.222, 3.456, 4.444, 5.5, 6.25)
- assert repr(t) == dedent(
- """\
- Affine(1.111, 2.222, 3.456,
- 4.444, 5.5, 6.25)"""
- )
-
- def test_identity_constructor(self):
- ident = Affine.identity()
- assert isinstance(ident, Affine)
- assert tuple(ident) == (1, 0, 0, 0, 1, 0, 0, 0, 1)
- assert ident.is_identity
-
- def test_permutation_constructor(self):
- perm = Affine.permutation()
- assert isinstance(perm, Affine)
- assert tuple(perm) == (0, 1, 0, 1, 0, 0, 0, 0, 1)
- assert (perm * perm).is_identity
-
- def test_translation_constructor(self):
- trans = Affine.translation(2, -5)
- assert isinstance(trans, Affine)
- assert tuple(trans) == (1, 0, 2, 0, 1, -5, 0, 0, 1)
-
- def test_scale_constructor(self):
- scale = Affine.scale(5)
- assert isinstance(scale, Affine)
- assert tuple(scale) == (5, 0, 0, 0, 5, 0, 0, 0, 1)
- scale = Affine.scale(-1, 2)
- assert tuple(scale) == (-1, 0, 0, 0, 2, 0, 0, 0, 1)
- assert tuple(Affine.scale(1)) == tuple(Affine.identity())
-
- def test_shear_constructor(self):
- shear = Affine.shear(30)
- assert isinstance(shear, Affine)
- mx = math.tan(math.radians(30))
- seq_almost_equal(tuple(shear), (1, mx, 0, 0, 1, 0, 0, 0, 1))
- shear = Affine.shear(-15, 60)
- mx = math.tan(math.radians(-15))
- my = math.tan(math.radians(60))
- seq_almost_equal(tuple(shear), (1, mx, 0, my, 1, 0, 0, 0, 1))
- shear = Affine.shear(y_angle=45)
- seq_almost_equal(tuple(shear), (1, 0, 0, 1, 1, 0, 0, 0, 1))
-
- def test_rotation_constructor(self):
- rot = Affine.rotation(60)
- assert isinstance(rot, Affine)
- r = math.radians(60)
- s, c = math.sin(r), math.cos(r)
- assert tuple(rot) == (c, -s, 0, s, c, 0, 0, 0, 1)
- rot = Affine.rotation(337)
- r = math.radians(337)
- s, c = math.sin(r), math.cos(r)
- seq_almost_equal(tuple(rot), (c, -s, 0, s, c, 0, 0, 0, 1))
- assert tuple(Affine.rotation(0)) == tuple(Affine.identity())
-
- def test_rotation_constructor_quadrants(self):
- assert tuple(Affine.rotation(0)) == (1, 0, 0, 0, 1, 0, 0, 0, 1)
- assert tuple(Affine.rotation(90)) == (0, -1, 0, 1, 0, 0, 0, 0, 1)
- assert tuple(Affine.rotation(180)) == (-1, 0, 0, 0, -1, 0, 0, 0, 1)
- assert tuple(Affine.rotation(-180)) == (-1, 0, 0, 0, -1, 0, 0, 0, 1)
- assert tuple(Affine.rotation(270)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
- assert tuple(Affine.rotation(-90)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
- assert tuple(Affine.rotation(360)) == (1, 0, 0, 0, 1, 0, 0, 0, 1)
- assert tuple(Affine.rotation(450)) == (0, -1, 0, 1, 0, 0, 0, 0, 1)
- assert tuple(Affine.rotation(-450)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
-
- def test_rotation_constructor_with_pivot(self):
- assert tuple(Affine.rotation(60)) == tuple(Affine.rotation(60, pivot=(0, 0)))
- rot = Affine.rotation(27, pivot=(2, -4))
- r = math.radians(27)
- s, c = math.sin(r), math.cos(r)
- assert tuple(rot) == (
- c,
- -s,
- 2 - 2 * c - 4 * s,
- s,
- c,
- -4 - 2 * s + 4 * c,
- 0,
- 0,
- 1,
- )
- assert tuple(Affine.rotation(0, (-3, 2))) == tuple(Affine.identity())
-
- def test_rotation_contructor_wrong_arg_types(self):
- with pytest.raises(TypeError):
- Affine.rotation(1, 1)
-
- def test_determinant(self):
- assert Affine.identity().determinant == 1
- assert Affine.scale(2).determinant == 4
- assert Affine.scale(0).determinant == 0
- assert Affine.scale(5, 1).determinant == 5
- assert Affine.scale(-1, 1).determinant == -1
- assert Affine.scale(-1, 0).determinant == 0
- assert Affine.rotation(77).determinant == pytest.approx(1)
- assert Affine.translation(32, -47).determinant == pytest.approx(1)
-
- def test_is_rectilinear(self):
- assert Affine.identity().is_rectilinear
- assert Affine.scale(2.5, 6.1).is_rectilinear
- assert Affine.translation(4, -1).is_rectilinear
- assert Affine.rotation(90).is_rectilinear
- assert not Affine.shear(4, -1).is_rectilinear
- assert not Affine.rotation(-26).is_rectilinear
-
- def test_is_conformal(self):
- assert Affine.identity().is_conformal
- assert Affine.scale(2.5, 6.1).is_conformal
- assert Affine.translation(4, -1).is_conformal
- assert Affine.rotation(90).is_conformal
- assert Affine.rotation(-26).is_conformal
- assert not Affine.shear(4, -1).is_conformal
-
- def test_is_orthonormal(self):
- assert Affine.identity().is_orthonormal
- assert Affine.translation(4, -1).is_orthonormal
- assert Affine.rotation(90).is_orthonormal
- assert Affine.rotation(-26).is_orthonormal
- assert not Affine.scale(2.5, 6.1).is_orthonormal
- assert not Affine.scale(0.5, 2).is_orthonormal
- assert not Affine.shear(4, -1).is_orthonormal
-
- def test_is_degenerate(self):
- assert not Affine.identity().is_degenerate
- assert not Affine.translation(2, -1).is_degenerate
- assert not Affine.shear(0, -22.5).is_degenerate
- assert not Affine.rotation(88.7).is_degenerate
- assert not Affine.scale(0.5).is_degenerate
- assert Affine.scale(0).is_degenerate
- assert Affine.scale(-10, 0).is_degenerate
- assert Affine.scale(0, 300).is_degenerate
- assert Affine.scale(0).is_degenerate
- assert Affine.scale(0).is_degenerate
-
- def test_column_vectors(self):
- a, b, c = Affine(2, 3, 4, 5, 6, 7).column_vectors
- assert isinstance(a, tuple)
- assert isinstance(b, tuple)
- assert isinstance(c, tuple)
- assert a == (2, 5)
- assert b == (3, 6)
- assert c == (4, 7)
-
- def test_almost_equals(self):
- EPSILON = 1e-5
- E = EPSILON * 0.5
- t = Affine(1.0, E, 0, -E, 1.0 + E, E)
- assert t.almost_equals(Affine.identity())
- assert Affine.identity().almost_equals(t)
- assert t.almost_equals(t)
- t = Affine(1.0, 0, 0, -EPSILON, 1.0, 0)
- assert not t.almost_equals(Affine.identity())
- assert not Affine.identity().almost_equals(t)
- assert t.almost_equals(t)
-
- def test_almost_equals_2(self):
- EPSILON = 1e-10
- E = EPSILON * 0.5
- t = Affine(1.0, E, 0, -E, 1.0 + E, E)
- assert t.almost_equals(Affine.identity(), precision=EPSILON)
- assert Affine.identity().almost_equals(t, precision=EPSILON)
- assert t.almost_equals(t, precision=EPSILON)
- t = Affine(1.0, 0, 0, -EPSILON, 1.0, 0)
- assert not t.almost_equals(Affine.identity(), precision=EPSILON)
- assert not Affine.identity().almost_equals(t, precision=EPSILON)
- assert t.almost_equals(t, precision=EPSILON)
-
- def test_equality(self):
- t1 = Affine(1, 2, 3, 4, 5, 6)
- t2 = Affine(6, 5, 4, 3, 2, 1)
- t3 = Affine(1, 2, 3, 4, 5, 6)
- assert t1 == t3
- assert not t1 == t2
- assert t2 == t2
- assert not t1 != t3
- assert not t2 != t2
- assert t1 != t2
- assert not t1 == 1
- assert t1 != 1
-
- def test_gt(self):
- with pytest.raises(TypeError):
- Affine(1, 2, 3, 4, 5, 6) > Affine(6, 5, 4, 3, 2, 1)
-
- def test_lt(self):
- with pytest.raises(TypeError):
- Affine(1, 2, 3, 4, 5, 6) < Affine(6, 5, 4, 3, 2, 1)
-
- def test_add(self):
- with pytest.raises(TypeError):
- Affine(1, 2, 3, 4, 5, 6) + Affine(6, 5, 4, 3, 2, 1)
-
- def test_sub(self):
- with pytest.raises(TypeError):
- Affine(1, 2, 3, 4, 5, 6) - Affine(6, 5, 4, 3, 2, 1)
-
- def test_mul_by_identity(self):
- t = Affine(1, 2, 3, 4, 5, 6)
- assert tuple(t * Affine.identity()) == tuple(t)
-
- def test_mul_transform(self):
- t = Affine.rotation(5) * Affine.rotation(29)
- assert isinstance(t, Affine)
- seq_almost_equal(t, Affine.rotation(34))
- t = Affine.scale(3, 5) * Affine.scale(2)
- seq_almost_equal(t, Affine.scale(6, 10))
-
- def test_itransform(self):
- pts = [(4, 1), (-1, 0), (3, 2)]
- r = Affine.scale(-2).itransform(pts)
- assert r is None, r
- assert pts == [(-8, -2), (2, 0), (-6, -4)]
-
- A = Affine.rotation(33)
- pts = [(4, 1), (-1, 0), (3, 2)]
- pts_expect = [A * pt for pt in pts]
- r = A.itransform(pts)
- assert r is None
- assert pts == pts_expect
-
- def test_mul_wrong_type(self):
- with pytest.raises(TypeError):
- Affine(1, 2, 3, 4, 5, 6) * None
-
- def test_mul_sequence_wrong_member_types(self):
- class NotPtSeq:
- @classmethod
- def from_points(cls, points):
- list(points)
-
- def __iter__(self):
- yield 0
-
- with pytest.raises(TypeError):
- Affine(1, 2, 3, 4, 5, 6) * NotPtSeq()
-
- def test_imul_transform(self):
- t = Affine.translation(3, 5)
- t *= Affine.translation(-2, 3.5)
- assert isinstance(t, Affine)
- seq_almost_equal(t, Affine.translation(1, 8.5))
-
- def test_inverse(self):
- seq_almost_equal(~Affine.identity(), Affine.identity())
- seq_almost_equal(~Affine.translation(2, -3), Affine.translation(-2, 3))
- seq_almost_equal(~Affine.rotation(-33.3), Affine.rotation(33.3))
- t = Affine(1, 2, 3, 4, 5, 6)
- seq_almost_equal(~t * t, Affine.identity())
-
- def test_cant_invert_degenerate(self):
- t = Affine.scale(0)
- with pytest.raises(affine.TransformNotInvertibleError):
- ~t
-
- def test_bad_type_world(self):
- """wrong type, i.e don't use readlines()"""
- with pytest.raises(TypeError):
- affine.loadsw(["1.0", "0.0", "0.0", "1.0", "0.0", "0.0"])
-
- def test_bad_value_world(self):
- """Wrong number of parameters."""
- with pytest.raises(ValueError):
- affine.loadsw("1.0\n0.0\n0.0\n1.0\n0.0\n0.0\n0.0")
-
- def test_simple_world(self):
- s = "1.0\n0.0\n0.0\n-1.0\n100.5\n199.5\n"
- a = affine.loadsw(s)
- assert a == Affine(1.0, 0.0, 100.0, 0.0, -1.0, 200.0)
- assert affine.dumpsw(a) == s
-
- def test_real_world(self):
- s = dedent(
- """\
- 39.9317755024
- 30.0907511581
- 30.0907511576
- -39.9317755019
- 2658137.2266720217
- 5990821.7039887439"""
- ) # no EOL
- a1 = affine.loadsw(s)
- assert a1.almost_equals(
- Affine(
- 39.931775502364644,
- 30.090751157602412,
- 2658102.2154086917,
- 30.090751157602412,
- -39.931775502364644,
- 5990826.624500916,
- )
- )
- a1out = affine.dumpsw(a1)
- assert isinstance(a1out, str)
- a2 = affine.loadsw(a1out)
- assert a1.almost_equals(a2)
-
-
-# We're using pytest for tests added after 1.0 and don't need unittest
-# test case classes.
-
-
-def test_gdal():
- t = Affine.from_gdal(-237481.5, 425.0, 0.0, 237536.4, 0.0, -425.0)
- assert t.c == t.xoff == -237481.5
- assert t.a == 425.0
- assert t.b == 0.0
- assert t.f == t.yoff == 237536.4
- assert t.d == 0.0
- assert t.e == -425.0
- assert tuple(t) == (425.0, 0.0, -237481.5, 0.0, -425.0, 237536.4, 0, 0, 1)
- assert t.to_gdal() == (-237481.5, 425.0, 0.0, 237536.4, 0.0, -425.0)
-
-
-def test_shapely():
- t = Affine(425.0, 0.0, -237481.5, 0.0, -425.0, 237536.4)
- assert t.to_shapely() == (425.0, 0.0, 0.0, -425, -237481.5, 237536.4)
-
-
-def test_imul_number():
- t = Affine(1, 2, 3, 4, 5, 6)
- try:
- t *= 2.0
- except TypeError:
- assert True
-
-
-def test_mul_tuple():
- t = Affine(1, 2, 3, 4, 5, 6)
- t * (2.0, 2.0)
-
-
-def test_rmul_tuple():
- with pytest.warns(DeprecationWarning):
- t = Affine(1, 2, 3, 4, 5, 6)
- (2.0, 2.0) * t
-
-
-def test_transform_precision():
- t = Affine.rotation(45.0)
- assert t.precision == EPSILON
- t.precision = 1e-10
- assert t.precision == 1e-10
- assert Affine.rotation(0.0).precision == EPSILON
-
-
-def test_associative():
- point = (12, 5)
- trans = Affine.translation(-10.0, -5.0)
- rot90 = Affine.rotation(90.0)
- result1 = rot90 * (trans * point)
- result2 = (rot90 * trans) * point
- seq_almost_equal(result1, (0.0, 2.0))
- seq_almost_equal(result1, result2)
-
-
-def test_roundtrip():
- point = (12, 5)
- trans = Affine.translation(3, 4)
- rot37 = Affine.rotation(37.0)
- point_prime = (trans * rot37) * point
- roundtrip_point = ~(trans * rot37) * point_prime
- seq_almost_equal(point, roundtrip_point)
-
-
-def test_eccentricity():
- assert Affine.identity().eccentricity == 0.0
- assert Affine.scale(2).eccentricity == 0.0
- # assert_equal(Affine.scale(0).eccentricity, ?)
- assert Affine.scale(2, 1).eccentricity == pytest.approx(math.sqrt(3) / 2)
- assert Affine.scale(2, 3).eccentricity == pytest.approx(math.sqrt(5) / 3)
- assert Affine.scale(1, 0).eccentricity == 1.0
- assert Affine.rotation(77).eccentricity == pytest.approx(0.0)
- assert Affine.translation(32, -47).eccentricity == pytest.approx(0.0)
- assert Affine.scale(-1, 1).eccentricity == pytest.approx(0.0)
-
-
-def test_eccentricity_complex():
- assert (Affine.scale(2, 3) * Affine.rotation(77)).eccentricity == pytest.approx(
- math.sqrt(5) / 3
- )
- assert (Affine.rotation(77) * Affine.scale(2, 3)).eccentricity == pytest.approx(
- math.sqrt(5) / 3
- )
- assert (
- Affine.translation(32, -47) * Affine.rotation(77) * Affine.scale(2, 3)
- ).eccentricity == pytest.approx(math.sqrt(5) / 3)
-
-
-def test_rotation_angle():
- assert Affine.identity().rotation_angle == 0.0
- assert Affine.scale(2).rotation_angle == 0.0
- assert Affine.scale(2, 1).rotation_angle == 0.0
- assert Affine.translation(32, -47).rotation_angle == pytest.approx(0.0)
- assert Affine.rotation(30).rotation_angle == pytest.approx(30)
- assert Affine.rotation(-150).rotation_angle == pytest.approx(-150)
-
-
-def test_rotation_improper():
- with pytest.raises(affine.UndefinedRotationError):
- Affine.scale(-1, 1).rotation_angle
-
-
-# See gh-71 for bug report motivating this test.
-def test_mul_fallback_unpack():
- """Support fallback in case that other is a single object."""
-
- class TextPoint:
- """Not iterable, will trigger ValueError in Affine.__mul__."""
-
- def __rmul__(self, other):
- return other * (1, 2)
-
- assert Affine.identity() * TextPoint() == (1, 2)
-
-
-# See gh-71 for bug report motivating this test.
-def test_mul_fallback_type_error():
- """Support fallback in case that other is an unexpected type."""
-
- class TextPoint:
- """Iterable, but values trigger TypeError in Affine.__mul__."""
-
- def __iter__(self):
- return ("1", "2")
-
- def __rmul__(self, other):
- return other * (1, 2)
-
- assert Affine.identity() * TextPoint() == (1, 2)
-
-
-if __name__ == "__main__":
- unittest.main()
=====================================
docs/Makefile
=====================================
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = src
+BUILDDIR = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
=====================================
docs/make.bat
=====================================
@@ -0,0 +1,35 @@
+ at ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=src
+set BUILDDIR=build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.https://www.sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
=====================================
docs/requirements.txt
=====================================
@@ -0,0 +1,2 @@
+attrs
+sphinx==9.1.0
=====================================
docs/src/_static/.gitkeep
=====================================
=====================================
docs/src/conf.py
=====================================
@@ -0,0 +1,40 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+import os
+import sys
+
+sys.path.append(os.path.abspath("../../src"))
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+project = "rasterio/affine"
+project_copyright = "%Y, Sean Gillies"
+author = "Sean Gillies"
+release = "development"
+
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
+
+extensions = [
+ "sphinx.ext.autodoc",
+ "sphinx.ext.coverage",
+ "sphinx.ext.ifconfig",
+ "sphinx.ext.napoleon", # NumPy doc style.
+ "sphinx.ext.todo",
+ "sphinx.ext.viewcode",
+]
+
+templates_path = ["_templates"]
+exclude_patterns = ["build", "Thumbs.db", ".DS_Store"]
+
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
+
+html_theme = "nature"
+html_static_path = ["_static"]
+
+# If this is not None, a 'Last updated on:' timestamp is inserted at every page bottom.
+html_last_updated_fmt = ""
=====================================
docs/src/index.rst
=====================================
@@ -0,0 +1,28 @@
+Welcome to rasterio/affine's documentation!
+===========================================
+
+This is the documentation of the latest development version at https://github.com/rasterio/affine .
+
+.. include:: ../../README.rst
+
+Module documentation
+====================
+
+.. automodule:: affine
+ :members:
+
+.. include:: ../../CHANGES.txt
+.. include:: ../../AUTHORS.txt
+
+License
+=======
+
+.. include:: ../../LICENSE.txt
+ :literal:
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
=====================================
pyproject.toml
=====================================
@@ -18,19 +18,22 @@ classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
"Topic :: Scientific/Engineering :: GIS",
+ "Typing :: Typed",
]
license = {text = "BSD-3-Clause"}
-requires-python = ">=3.7"
+requires-python = ">=3.9"
+dependencies = [
+ "attrs",
+]
[project.optional-dependencies]
test = [
- "pytest >=4.6",
+ "numpy>=1.20",
+ "pytest>=6.0",
"pytest-cov",
]
dev = [
- "pydocstyle",
- "flake8",
- "coveralls",
+ "coveralls>=3.0",
]
[project.urls]
@@ -41,4 +44,39 @@ include = [
"AUTHORS.txt",
"CHANGES.txt",
"LICENSE.txt",
+ "docs/",
+ "tests/",
+]
+exclude = ["docs/build/**"]
+
+[tool.pytest.ini_options]
+pythonpath = "src"
+testpaths = ["tests"]
+
+[tool.ruff.lint]
+select = [
+ "B", # flake8-bugbear
+ "D", # pydocstyle
+ "E", "W", # pycodestyle
+ "F", # Pyflakes
+ "I", # isort
+ "NPY", # NumPy-specific rules
+ "PT", # flake8-pytest-style
+ "RET", # flake8-return
+ "RUF", # Ruff-specific rules
+ "SIM", # flake8-simplify
+ "UP", # pyupgrade
]
+ignore = [
+ "D105", # Missing docstring in magic method
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**.py" = ["B", "D"]
+"docs/**.py" = ["D"]
+
+[tool.ruff.lint.isort]
+force-sort-within-sections = true
+
+[tool.ruff.lint.pydocstyle]
+convention = "numpy"
=====================================
setup.cfg deleted
=====================================
@@ -1,6 +0,0 @@
-[tool:pytest]
-testpaths: affine/tests
-
-[pydocstyle]
-select: D1
-add-ignore: D105
=====================================
affine/__init__.py → src/affine/__init__.py
=====================================
@@ -1,4 +1,4 @@
-"""Affine transformation matrices
+"""Affine transformation matrices.
The Affine package is derived from Casey Duncan's Planar package. See the
copyright statement below.
@@ -32,16 +32,22 @@ copyright statement below.
# EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#############################################################################
-from collections import namedtuple
+from __future__ import annotations
+
+from collections.abc import MutableSequence, Sequence
+from functools import cached_property
import math
+from typing import overload
import warnings
+from attrs import astuple, define, field
__all__ = ["Affine"]
__author__ = "Sean Gillies"
-__version__ = "2.4.0"
+__version__ = "3.0.0"
EPSILON: float = 1e-5
+EPSILON2: float = 1e-10
class AffineError(Exception):
@@ -49,33 +55,14 @@ class AffineError(Exception):
class TransformNotInvertibleError(AffineError):
- """The transform could not be inverted"""
+ """The transform could not be inverted."""
class UndefinedRotationError(AffineError):
- """The rotation angle could not be computed for this transform"""
-
-
-def cached_property(func):
- """Special property decorator that caches the computed
- property value in the object's instance dict the first
- time it is accessed.
- """
- name = func.__name__
- doc = func.__doc__
-
- def getter(self, name=name):
- try:
- return self.__dict__[name]
- except KeyError:
- self.__dict__[name] = value = func(self)
- return value
-
- getter.func_name = name
- return property(getter, doc=doc)
+ """The rotation angle could not be computed for this transform."""
-def cos_sin_deg(deg: float):
+def cos_sin_deg(deg: float) -> tuple[float, float]:
"""Return the cosine and sin for the given angle in degrees.
With special-case handling of multiples of 90 for perfect right
@@ -84,272 +71,297 @@ 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)
-class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))):
+ at define(frozen=True)
+class Affine:
"""Two dimensional affine transform for 2D linear mapping.
+ Parallel lines are preserved by these transforms. Affine transforms
+ can perform any combination of translations, scales/flips, shears,
+ and rotations. Class methods are provided to conveniently compose
+ transforms from these operations.
+
Parameters
----------
a, b, c, d, e, f : float
- Coefficients of an augmented affine transformation matrix
-
- | x' | | a b c | | x |
- | y' | = | d e f | | y |
- | 1 | | 0 0 1 | | 1 |
+ Coefficients of the 3 x 3 augmented affine transformation matrix.
- `a`, `b`, and `c` are the elements of the first row of the
- matrix. `d`, `e`, and `f` are the elements of the second row.
+ g, h, i : float, optional
+ Coefficients of the 3 x 3 augmented affine transformation matrix.
Attributes
----------
a, b, c, d, e, f, g, h, i : float
- The coefficients of the 3x3 augmented affine transformation
- matrix
+ Coefficients of the 3 x 3 augmented affine transformation matrix.
- | x' | | a b c | | x |
- | y' | = | d e f | | y |
- | 1 | | g h i | | 1 |
+ .. code-block:: none
+
+ | x' | | a b c | | x |
+ | y' | = | d e f | | y |
+ | 1 | | g h i | | 1 |
`g`, `h`, and `i` are always 0, 0, and 1.
- The Affine package is derived from Casey Duncan's Planar package.
- See the copyright statement below. Parallel lines are preserved by
- these transforms. Affine transforms can perform any combination of
- translations, scales/flips, shears, and rotations. Class methods
- are provided to conveniently compose transforms from these
- operations.
-
- Internally the transform is stored as a 3x3 transformation matrix.
- The transform may be constructed directly by specifying the first
- two rows of matrix values as 6 floats. Since the matrix is an affine
- transform, the last row is always ``(0, 0, 1)``.
-
- N.B.: multiplication of a transform and an (x, y) vector *always*
- returns the column vector that is the matrix multiplication product
- of the transform and (x, y) as a column vector, no matter which is
- on the left or right side. This is obviously not the case for
- matrices and vectors in general, but provides a convenience for
- users of this class.
+ Notes
+ -----
+ Multiplication of a transform and an (x, y) vector *always* returns
+ the column vector that is the matrix multiplication product of the
+ transform and (x, y) as a column vector, no matter which is on the
+ left or right side. This is obviously not the case for matrices and
+ vectors in general, but provides a convenience for users of this
+ class.
+ The Affine package is derived from Casey Duncan's Planar package.
+ See the copyright statement.
"""
- precision = EPSILON
-
- def __new__(
- cls,
- a: float,
- b: float,
- c: float,
- d: float,
- e: float,
- f: float,
- g: float = 0.0,
- h: float = 0.0,
- i: float = 1.0,
- ):
- """Create a new object
+ a: float = field(converter=float)
+ b: float = field(converter=float)
+ c: float = field(converter=float)
+ d: float = field(converter=float)
+ e: float = field(converter=float)
+ f: float = field(converter=float)
- Parameters
- ----------
- a, b, c, d, e, f : float
- Elements of an augmented affine transformation matrix.
- """
- return tuple.__new__(
- cls,
- (
- a * 1.0,
- b * 1.0,
- c * 1.0,
- d * 1.0,
- e * 1.0,
- f * 1.0,
- g * 1.0,
- h * 1.0,
- i * 1.0,
- ),
- )
+ # The class has 3 attributes that don't have to be specified: g, h,
+ # and i. If they are, the given value has to be the same as the
+ # default value. This allows a new instances to be created from the
+ # tuple form of another, like Affine(*Affine.identity()).
+
+ g: float = field(default=0.0, converter=float)
+
+ @g.validator
+ def _check_g(self, attribute, value):
+ if value != 0.0:
+ raise ValueError("g must be equal to 0.0")
+
+ h: float = field(default=0.0, converter=float)
+
+ @h.validator
+ def _check_h(self, attribute, value):
+ if value != 0.0:
+ raise ValueError("h must be equal to 0.0")
+
+ i: float = field(default=1.0, converter=float)
+
+ @i.validator
+ def _check_i(self, attribute, value):
+ if value != 1.0:
+ raise ValueError("i must be equal to 1.0")
@classmethod
- def from_gdal(cls, c: float, a: float, b: float, f: float, d: float, e: float):
+ def from_gdal(
+ cls, c: float, a: float, b: float, f: float, d: float, e: float
+ ) -> Affine:
"""Use same coefficient order as GDAL's GetGeoTransform().
- :param c, a, b, f, d, e: 6 floats ordered by GDAL.
- :rtype: Affine
+ Parameters
+ ----------
+ c, a, b, f, d, e : float
+ Parameters ordered by GDAL's GeoTransform.
+
+ Returns
+ -------
+ Affine
"""
- return cls.__new__(cls, a, b, c, d, e, f)
+ return cls(a, b, c, d, e, f)
@classmethod
- def identity(cls):
+ def identity(cls) -> Affine:
"""Return the identity transform.
- :rtype: Affine
+ Returns
+ -------
+ Affine
"""
return identity
@classmethod
- def translation(cls, xoff: float, yoff: float):
+ def translation(cls, xoff: float, yoff: float) -> Affine:
"""Create a translation transform from an offset vector.
- :param xoff: Translation x offset.
- :type xoff: float
- :param yoff: Translation y offset.
- :type yoff: float
- :rtype: Affine
+ Parameters
+ ----------
+ xoff, yoff : float
+ Translation offsets in x and y directions.
+
+ Returns
+ -------
+ Affine
"""
- return tuple.__new__(cls, (1.0, 0.0, xoff, 0.0, 1.0, yoff, 0.0, 0.0, 1.0))
+ return cls(1.0, 0.0, xoff, 0.0, 1.0, yoff)
@classmethod
- def scale(cls, *scaling):
+ def scale(cls, *scaling: float) -> Affine:
"""Create a scaling transform from a scalar or vector.
- :param scaling: The scaling factor. A scalar value will
- scale in both dimensions equally. A vector scaling
- value scales the dimensions independently.
- :type scaling: float or sequence
- :rtype: Affine
+ Parameters
+ ----------
+ *scaling : float or sequence of two floats
+ One or two scaling factors. A scalar value will scale in
+ both dimensions equally. A vector scaling value scales the
+ dimensions independently.
+
+ Returns
+ -------
+ Affine
"""
if len(scaling) == 1:
- sx = sy = float(scaling[0])
+ sx = scaling[0]
+ sy = sx
else:
sx, sy = scaling
- return tuple.__new__(cls, (sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0))
+ return cls(sx, 0.0, 0.0, 0.0, sy, 0.0)
@classmethod
- def shear(cls, x_angle: float = 0, y_angle: float = 0):
+ def shear(cls, x_angle: float = 0.0, y_angle: float = 0.0) -> Affine:
"""Create a shear transform along one or both axes.
- :param x_angle: Shear angle in degrees parallel to the x-axis.
- :type x_angle: float
- :param y_angle: Shear angle in degrees parallel to the y-axis.
- :type y_angle: float
- :rtype: Affine
+ Parameters
+ ----------
+ x_angle, y_angle : float
+ Shear angles in degrees parallel to the x- and y-axis.
+
+ Returns
+ -------
+ Affine
"""
mx = math.tan(math.radians(x_angle))
my = math.tan(math.radians(y_angle))
- return tuple.__new__(cls, (1.0, mx, 0.0, my, 1.0, 0.0, 0.0, 0.0, 1.0))
+ return cls(1.0, mx, 0.0, my, 1.0, 0.0)
@classmethod
- def rotation(cls, angle: float, pivot=None):
+ def rotation(cls, angle: float, pivot: Sequence[float] | None = None) -> Affine:
"""Create a rotation transform at the specified angle.
- A pivot point other than the coordinate system origin may be
- optionally specified.
-
- :param angle: Rotation angle in degrees, counter-clockwise
- about the pivot point.
- :type angle: float
- :param pivot: Point to rotate about, if omitted the rotation is
- about the origin.
- :type pivot: sequence
- :rtype: Affine
+ Parameters
+ ----------
+ angle : float
+ Rotation angle in degrees, counter-clockwise about the pivot
+ point.
+ pivot : sequence of float (px, py), optional
+ Pivot point coordinates to rotate around. If None (default),
+ the pivot point is the coordinate system origin (0.0, 0.0).
+
+ Returns
+ -------
+ Affine
"""
ca, sa = cos_sin_deg(angle)
if pivot is None:
- return tuple.__new__(cls, (ca, -sa, 0.0, sa, ca, 0.0, 0.0, 0.0, 1.0))
- else:
- px, py = pivot
- return tuple.__new__(
- cls,
- (
- ca,
- -sa,
- px - px * ca + py * sa,
- sa,
- ca,
- py - px * sa - py * ca,
- 0.0,
- 0.0,
- 1.0,
- ),
- )
+ return cls(ca, -sa, 0.0, sa, ca, 0.0)
+ 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):
- """Create the permutation transform
+ def permutation(cls, *scaling: float) -> Affine:
+ """Create the permutation transform.
For 2x2 matrices, there is only one permutation matrix that is
not the identity.
- :rtype: Affine
- """
+ Parameters
+ ----------
+ *scaling : any
+ Ignored.
- return tuple.__new__(cls, (0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0))
+ Returns
+ -------
+ Affine
+ """
+ return cls(0.0, 1.0, 0.0, 1.0, 0.0, 0.0)
def __str__(self) -> str:
"""Concise string representation."""
return (
- "|% .2f,% .2f,% .2f|\n" "|% .2f,% .2f,% .2f|\n" "|% .2f,% .2f,% .2f|"
- ) % self
+ f"|{self.a: .2f},{self.b: .2f},{self.c: .2f}|\n"
+ f"|{self.d: .2f},{self.e: .2f},{self.f: .2f}|\n"
+ f"|{self.g: .2f},{self.h: .2f},{self.i: .2f}|"
+ )
def __repr__(self) -> str:
"""Precise string representation."""
- return ("Affine(%r, %r, %r,\n" " %r, %r, %r)") % self[:6]
+ return (
+ f"Affine({self.a!r}, {self.b!r}, {self.c!r},\n"
+ f" {self.d!r}, {self.e!r}, {self.f!r})"
+ )
- def to_gdal(self):
- """Return same coefficient order as GDAL's SetGeoTransform().
+ def to_gdal(self) -> tuple[float, float, float, float, float, float]:
+ """Return same coefficient order expected by GDAL's SetGeoTransform().
- :rtype: tuple
+ Returns
+ -------
+ tuple
+ Ordered: c, a, b, f, d, e.
"""
return (self.c, self.a, self.b, self.f, self.d, self.e)
- def to_shapely(self):
- """Return an affine transformation matrix compatible with shapely
-
- Shapely's affinity module expects an affine transformation matrix
- in (a,b,d,e,xoff,yoff) order.
+ def to_shapely(self) -> tuple[float, float, float, float, float, float]:
+ """Return affine transformation parameters for shapely's affinity module.
- :rtype: tuple
+ Returns
+ -------
+ tuple
+ Ordered: a, b, d, e, c, f.
"""
- return (self.a, self.b, self.d, self.e, self.xoff, self.yoff)
+ return (self.a, self.b, self.d, self.e, self.c, self.f)
@property
def xoff(self) -> float:
- """Alias for 'c'"""
+ """Alias for 'c'."""
return self.c
@property
def yoff(self) -> float:
- """Alias for 'f'"""
+ """Alias for 'f'."""
return self.f
@cached_property
def determinant(self) -> float:
- """The determinant of the transform matrix.
+ """Evaluate the determinant of the transform matrix.
This value is equal to the area scaling factor when the
transform is applied to a shape.
+
+ Returns
+ -------
+ float
"""
- a, b, c, d, e, f, g, h, i = self
- return a * e - b * d
+ return self.a * self.e - self.b * self.d
@property
- def _scaling(self):
+ def _scaling(self) -> tuple[float, float]:
"""The absolute scaling factors of the transformation.
- This tuple represents the absolute value of the scaling factors of the
- transformation, sorted from bigger to smaller.
+ This tuple represents the absolute value of the scaling factors
+ of the transformation, sorted from bigger to smaller.
"""
- a, b, _, d, e, _, _, _, _ = self
+ a, b, d, e = self.a, self.b, self.d, self.e
# The singular values are the square root of the eigenvalues
# of the matrix times its transpose, M M*
# Computing trace and determinant of M M*
- trace = a ** 2 + b ** 2 + d ** 2 + e ** 2
- det = (a * e - b * d) ** 2
+ trace = a**2 + b**2 + d**2 + e**2
+ det2 = (a * e - b * d) ** 2
- delta = trace ** 2 / 4 - det
- if delta < 1e-12:
- delta = 0
+ delta = trace**2 / 4.0 - det2
+ if delta < EPSILON2:
+ delta = 0.0
- l1 = math.sqrt(trace / 2 + math.sqrt(delta))
- l2 = math.sqrt(trace / 2 - math.sqrt(delta))
+ sqrt_delta = math.sqrt(delta)
+ l1 = math.sqrt(trace / 2.0 + sqrt_delta)
+ l2 = math.sqrt(trace / 2.0 - sqrt_delta)
return l1, l2
@property
@@ -359,36 +371,37 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
This value represents the eccentricity of an ellipse under
this affine transformation.
- Raises NotImplementedError for improper transformations.
+ Raises
+ ------
+ NotImplementedError
+ For improper transformations.
"""
l1, l2 = self._scaling
- return math.sqrt(l1 ** 2 - l2 ** 2) / l1
+ return math.sqrt(l1**2 - l2**2) / l1
@property
def rotation_angle(self) -> float:
"""The rotation angle in degrees of the affine transformation.
- This is the rotation angle in degrees of the affine transformation,
- assuming it is in the form M = R S, where R is a rotation and S is a
- scaling.
+ This is the rotation angle in degrees of the affine
+ transformation, assuming it is in the form M = R S, where R is
+ a rotation and S is a scaling.
- Raises UndefinedRotationError for improper and degenerate
- transformations.
+ Raises
+ ------
+ UndefinedRotationError
+ For improper and degenerate transformations.
"""
- a, b, _, c, d, _, _, _, _ = self
if self.is_proper or self.is_degenerate:
l1, _ = self._scaling
- y, x = c / l1, a / l1
- return math.atan2(y, x) * 180 / math.pi
- else:
- raise UndefinedRotationError
+ y, x = self.d / l1, self.a / l1
+ return math.degrees(math.atan2(y, x))
+ raise UndefinedRotationError
@property
def is_identity(self) -> bool:
- """True if this transform equals the identity matrix,
- within rounding limits.
- """
- return self is identity or self.almost_equals(identity, self.precision)
+ """True if this transform equals the identity matrix, within rounding limits."""
+ return self is identity or self.almost_equals(identity, EPSILON)
@property
def is_rectilinear(self) -> bool:
@@ -397,9 +410,8 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
i.e., whether a shape would remain axis-aligned, within rounding
limits, after applying the transform.
"""
- a, b, c, d, e, f, g, h, i = self
- return (abs(a) < self.precision and abs(e) < self.precision) or (
- abs(d) < self.precision and abs(b) < self.precision
+ return (abs(self.a) < EPSILON and abs(self.e) < EPSILON) or (
+ abs(self.d) < EPSILON and abs(self.b) < EPSILON
)
@property
@@ -410,8 +422,7 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
transform, within rounding limits. This implies that the
transform has no effective shear.
"""
- a, b, c, d, e, f, g, h, i = self
- return abs(a * b + d * e) < self.precision
+ return abs(self.a * self.b + self.d * self.e) < EPSILON
@property
def is_orthonormal(self) -> bool:
@@ -423,48 +434,83 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
and unit-length. Applying an orthonormal transform to a shape
always results in a congruent shape.
"""
- a, b, c, d, e, f, g, h, i = self
+ a, b, d, e = self.a, self.b, self.d, self.e
return (
self.is_conformal
- and abs(1.0 - (a * a + d * d)) < self.precision
- and abs(1.0 - (b * b + e * e)) < self.precision
+ and abs(1.0 - (a * a + d * d)) < EPSILON
+ and abs(1.0 - (b * b + e * e)) < EPSILON
)
@cached_property
def is_degenerate(self) -> bool:
- """True if this transform is degenerate.
+ """Return True if this transform is degenerate.
+
+ A degenerate transform will collapse a shape to an effective area
+ of zero, and cannot be inverted.
- Which means that it will collapse a shape to an effective area
- of zero. Degenerate transforms cannot be inverted.
+ Returns
+ -------
+ bool
"""
return self.determinant == 0.0
@cached_property
def is_proper(self) -> bool:
- """True if this transform is proper.
+ """Return True if this transform is proper.
- Which means that it does not include reflection.
+ A proper transform (with a positive determinant) does not include
+ reflection.
+
+ Returns
+ -------
+ bool
"""
return self.determinant > 0.0
@property
- def column_vectors(self):
- """The values of the transform as three 2D column vectors"""
- a, b, c, d, e, f, _, _, _ = self
- return (a, d), (b, e), (c, f)
+ def column_vectors(
+ self,
+ ) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]:
+ """The values of the transform as three 2D column vectors.
+
+ Returns
+ -------
+ tuple of three tuple pairs
+ Ordered (a, d), (b, e), (c, f).
+ """
+ return (self.a, self.d), (self.b, self.e), (self.c, self.f)
- def almost_equals(self, other, precision: float = EPSILON) -> bool:
+ def almost_equals(self, other: Affine, precision: float | None = None) -> bool:
"""Compare transforms for approximate equality.
- :param other: Transform being compared.
- :type other: Affine
- :return: True if absolute difference between each element
- of each respective transform matrix < ``self.precision``.
+ Parameters
+ ----------
+ other : Affine
+ Transform being compared.
+ precision : float, default EPSILON
+ Precision to use to evaluate equality.
+
+ Returns
+ -------
+ bool
+ True if absolute difference between each element
+ of each respective transform matrix < ``precision``.
"""
- for i in (0, 1, 2, 3, 4, 5):
- if abs(self[i] - other[i]) >= precision:
- return False
- return True
+ precision = precision or EPSILON
+ return all(abs(sv - ov) < precision for sv, ov in zip(self, other))
+
+ @cached_property
+ def _astuple(self) -> tuple[float]:
+ return astuple(self)
+
+ def __getitem__(self, index):
+ return self._astuple[index]
+
+ def __iter__(self):
+ return iter(self._astuple)
+
+ def __len__(self):
+ return 9
def __gt__(self, other) -> bool:
return NotImplemented
@@ -480,104 +526,164 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
__iadd__ = __add__
- def __mul__(self, other):
- """Multiplication
+ @overload
+ def __matmul__(self, other: Affine) -> Affine: ...
+ @overload
+ def __matmul__(self, other: tuple[float, float]) -> tuple[float, float]: ...
+ @overload
+ def __matmul__(
+ self, other: tuple[float, float, float]
+ ) -> tuple[float, float, float]: ...
+ # For other float sequences, we don't know the returned tuple length here
+ @overload
+ def __matmul__(self, other: Sequence[float]) -> tuple[float, ...]: ...
+ def __matmul__(self, other):
+ """Matrix multiplication.
Apply the transform using matrix multiplication, creating
a resulting object of the same type. A transform may be applied
to another transform, a vector, vector array, or shape.
- :param other: The object to transform.
- :type other: Affine, :class:`~planar.Vec2`,
- :class:`~planar.Vec2Array`, :class:`~planar.Shape`
- :rtype: Same as ``other``
+ Parameters
+ ----------
+ other : Affine or iterable of (vx, vy, [vw])
+
+ Returns
+ -------
+ Affine or a tuple of two or three items
"""
- sa, sb, sc, sd, se, sf, _, _, _ = self
+ sa, sb, sc, sd, se, sf = self[:6]
if isinstance(other, Affine):
- oa, ob, oc, od, oe, of, _, _, _ = other
- return tuple.__new__(
- self.__class__,
- (
- sa * oa + sb * od,
- sa * ob + sb * oe,
- sa * oc + sb * of + sc,
- sd * oa + se * od,
- sd * ob + se * oe,
- sd * oc + se * of + sf,
- 0.0,
- 0.0,
- 1.0,
- ),
+ oa, ob, oc, od, oe, of = other[:6]
+ return self.__class__(
+ sa * oa + sb * od,
+ sa * ob + sb * oe,
+ sa * oc + sb * of + sc,
+ sd * oa + se * od,
+ sd * ob + se * oe,
+ sd * oc + se * of + sf,
)
- else:
+ # vector of 2 or 3 items
+ try:
+ num_items = len(other)
+ except (TypeError, ValueError):
+ return NotImplemented
+ if num_items == 2:
+ vx, vy = other
+ elif num_items == 3:
+ vx, vy, vw = other
+ vw_eq_one = vw == 1.0
try:
- vx, vy = other
- return (vx * sa + vy * sb + sc, vx * sd + vy * se + sf)
- except (ValueError, TypeError):
- return NotImplemented
+ is_eq_one = bool(vw_eq_one)
+ msg = "third value must be 1.0"
+ except ValueError:
+ is_eq_one = (vw_eq_one).all()
+ msg = "third values must all be 1.0"
+ if not is_eq_one:
+ raise ValueError(msg)
+ else:
+ raise TypeError("expected vector of 2 or 3 items")
+ px = vx * sa + vy * sb + sc
+ py = vx * sd + vy * se + sf
+ if num_items == 2:
+ return (px, py)
+ return (px, py, vw)
+
+ def __rmatmul__(self, other):
+ return NotImplemented
- def __rmul__(self, other):
- """Right hand multiplication
+ def __imatmul__(self, other): # type: ignore
+ if not isinstance(other, Affine):
+ raise TypeError("Operation not supported")
+ return NotImplemented
- .. deprecated:: 2.3.0
- Right multiplication will be prohibited in version 3.0. This method
- will raise AffineError.
+ @overload
+ def __mul__(self, other: Affine) -> Affine: ...
+ @overload
+ def __mul__(self, other: tuple[float, float]) -> tuple[float, float]: ...
+ def __mul__(self, other):
+ """Multiplication.
- Notes
- -----
- We should not be called if other is an affine instance This is
- just a guarantee, since we would potentially return the wrong
- answer in that case.
- """
- warnings.warn(
- "Right multiplication will be prohibited in version 3.0",
- DeprecationWarning,
- stacklevel=2,
- )
- assert not isinstance(other, Affine)
- return self.__mul__(other)
+ Apply the transform using matrix multiplication, creating
+ a resulting object of the same type. A transform may be applied
+ to another transform, a vector, vector array, or shape.
- def __imul__(self, other):
- if isinstance(other, Affine) or isinstance(other, tuple):
- return self.__mul__(other)
- else:
+ Parameters
+ ----------
+ other : Affine or iterable of (vx, vy)
+
+ Returns
+ -------
+ Affine or a tuple of two items
+ """
+ # TODO: consider enabling this for 3.1
+ # warnings.warn(
+ # "Use `@` matmul instead of `*` mul operator for matrix multiplication",
+ # PendingDeprecationWarning,
+ # stacklevel=2,
+ # )
+ if isinstance(other, Affine):
+ return self.__matmul__(other)
+ try:
+ _, _ = other
+ return self.__matmul__(other)
+ except (ValueError, TypeError):
return NotImplemented
- def itransform(self, seq) -> None:
- """Transform a sequence of points or vectors in place.
+ def __rmul__(self, other):
+ return NotImplemented
+
+ def __imul__(self, other): # type: ignore
+ if isinstance(other, tuple):
+ warnings.warn(
+ "in-place multiplication with tuple is deprecated",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return NotImplemented
+
+ def itransform(self, seq: MutableSequence[Sequence[float]]) -> None:
+ """Transform a sequence of points or vectors in-place.
+
+ Parameters
+ ----------
+ seq : mutable sequence
- :param seq: Mutable sequence of :class:`~planar.Vec2` to be
- transformed.
- :returns: None, the input sequence is mutated in place.
+ Returns
+ -------
+ None
+ The input sequence is mutated in-place.
"""
if self is not identity and self != identity:
- sa, sb, sc, sd, se, sf, _, _, _ = self
+ 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)
def __invert__(self):
"""Return the inverse transform.
- :raises: :except:`TransformNotInvertible` if the transform
- is degenerate.
+ Raises
+ ------
+ TransformNotInvertible
+ If the transform is degenerate.
"""
if self.is_degenerate:
raise TransformNotInvertibleError("Cannot invert degenerate transform")
idet = 1.0 / self.determinant
- sa, sb, sc, sd, se, sf, _, _, _ = self
+ sa, sb, sc, sd, se, sf = self[:6]
ra = se * idet
rb = -sb * idet
rd = -sd * idet
re = sa * idet
- return tuple.__new__(
- self.__class__,
- (ra, rb, -sc * ra - sf * rb, rd, re, -sc * rd - sf * re, 0.0, 0.0, 1.0),
+ # fmt: off
+ return self.__class__(
+ ra, rb, -sc * ra - sf * rb,
+ rd, re, -sc * rd - sf * re,
)
-
- __hash__ = tuple.__hash__ # hash is not inherited in Py 3
+ # fmt: on
def __getnewargs__(self):
- """Pickle protocol support
+ """Pickle protocol support.
Notes
-----
@@ -585,7 +691,7 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
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)
@@ -594,32 +700,71 @@ identity = Affine(1, 0, 0, 0, 1, 0)
# Miscellaneous utilities
-def loadsw(s: str):
- """Returns Affine from the contents of a world file string.
+def loadsw(s: str) -> Affine:
+ """Return Affine from the contents of a world file string.
This method also translates the coefficients from center- to
corner-based coordinates.
- :param s: str with 6 floats ordered in a world file.
- :rtype: Affine
+ Parameters
+ ----------
+ s : str
+ String with 6 floats ordered in a world file.
+
+ Returns
+ -------
+ Affine
"""
if not hasattr(s, "split"):
raise TypeError("Cannot split input string")
coeffs = s.split()
if len(coeffs) != 6:
- raise ValueError("Expected 6 coefficients, found %d" % len(coeffs))
+ raise ValueError(f"Expected 6 coefficients, found {len(coeffs)}")
a, d, b, e, c, f = (float(x) for x in coeffs)
- center = tuple.__new__(Affine, [a, b, c, d, e, f, 0.0, 0.0, 1.0])
- return center * Affine.translation(-0.5, -0.5)
+ center = Affine(a, b, c, d, e, f)
+ return center @ Affine.translation(-0.5, -0.5)
-def dumpsw(obj) -> str:
+def dumpsw(obj: Affine) -> str:
"""Return string for a world file.
This method also translates the coefficients from corner- to
center-based coordinates.
- :rtype: str
+ Returns
+ -------
+ str
"""
- center = obj * Affine.translation(0.5, 0.5)
+ center = obj @ Affine.translation(0.5, 0.5)
return "\n".join(repr(getattr(center, x)) for x in list("adbecf")) + "\n"
+
+
+def set_epsilon(epsilon: float) -> None:
+ """Set the global absolute error value and rounding limit.
+
+ This value is accessible via the affine.EPSILON global variable.
+
+ Parameters
+ ----------
+ epsilon : float
+ The global absolute error value and rounding limit for
+ approximate floating point comparison operations.
+
+ Returns
+ -------
+ None
+
+ Notes
+ -----
+ The default value of ``0.00001`` is suitable for values that are in
+ the "countable range". You may need a larger epsilon when using
+ large absolute values, and a smaller value for very small values
+ close to zero. Otherwise approximate comparison operations will not
+ behave as expected.
+ """
+ global EPSILON, EPSILON2
+ EPSILON = float(epsilon)
+ EPSILON2 = EPSILON**2
+
+
+set_epsilon(1e-5)
=====================================
src/affine/py.typed
=====================================
=====================================
affine/tests/__init__.py → tests/__init__.py
=====================================
=====================================
tests/test_numpy.py
=====================================
@@ -0,0 +1,132 @@
+"""Test interoperability with NumPy."""
+
+import pytest
+
+from affine import Affine
+
+try:
+ import numpy as np
+ from numpy import testing
+except ImportError:
+ pytest.skip("requires numpy", allow_module_level=True)
+
+
+def test_array():
+ """Affine instance has 9 elements, becomes 1-D Numpy array."""
+ assert np.array(Affine.identity()).shape == (9,)
+
+
+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(np.array(~tfm).reshape(3, 3), expected_inv)
+ testing.assert_allclose(np.linalg.inv(ar), expected_inv)
+
+
+def test_matmul_3x3_array():
+ A = Affine(2, 0, 3, 0, 3, 2)
+ Ar = np.array(A).reshape(3, 3)
+
+ # matrix @ matrix = matrix
+ res = A @ Affine.identity()
+ assert isinstance(res, Affine)
+ testing.assert_equal(np.array(res).reshape(3, 3), Ar)
+ res = Ar @ np.eye(3)
+ assert isinstance(res, np.ndarray)
+ testing.assert_equal(res, Ar)
+
+
+def test_matmul_vector():
+ A = Affine(2, 0, 3, 0, 3, 2)
+ Ar = np.array(A).reshape(3, 3)
+
+ # matrix @ vector = vector
+ v = (2, 3, 1)
+ vr = np.array(v)
+ expected_p = (7, 11, 1)
+ res = A @ v
+ assert isinstance(res, tuple)
+ testing.assert_equal(res, expected_p)
+ res = A @ vr
+ assert isinstance(res, tuple)
+ testing.assert_equal(res, expected_p)
+ res = Ar @ vr
+ assert isinstance(res, np.ndarray)
+ testing.assert_equal(res, expected_p)
+
+
+def test_matmul_items():
+ A = Affine(2, 0, 3, 0, 3, 2) @ Affine.rotation(45)
+ shape = (4, 5)
+ vx, vy = np.meshgrid(np.arange(shape[1]), np.arange(shape[0]))
+ expected_px = np.array(
+ [
+ [3.0, 4.4142137, 5.8284273, 7.2426405, 8.656855],
+ [1.5857865, 3.0, 4.4142137, 5.8284273, 7.2426405],
+ [0.17157288, 1.5857865, 3.0, 4.4142137, 5.8284273],
+ [-1.2426407, 0.17157288, 1.5857865, 3.0, 4.4142137],
+ ]
+ )
+ expected_py = np.array(
+ [
+ [2.0, 4.1213202, 6.2426405, 8.363961, 10.485281],
+ [4.1213202, 6.2426405, 8.363961, 10.485281, 12.606602],
+ [6.2426405, 8.363961, 10.485281, 12.606602, 14.727922],
+ [8.363961, 10.485281, 12.606602, 14.727922, 16.849243],
+ ]
+ )
+ px, py = A @ (vx, vy)
+ testing.assert_allclose(px, expected_px)
+ testing.assert_allclose(py, expected_py)
+
+ px, py, pw = A @ (vx, vy, 1)
+ testing.assert_allclose(px, expected_px)
+ testing.assert_allclose(py, expected_py)
+ assert pw == 1
+
+ px, py, pw = A @ (vx, vy, np.ones(shape))
+ testing.assert_allclose(px, expected_px)
+ testing.assert_allclose(py, expected_py)
+ testing.assert_array_equal(pw, np.ones(shape))
+
+ # see GH-125 with mul `*` op
+ px, py = A * (vx, vy)
+ testing.assert_allclose(px, expected_px)
+ testing.assert_allclose(py, expected_py)
+
+
+def test_matmul_item_errors():
+ shape = (4, 5)
+ vx, vy = np.meshgrid(np.arange(shape[1]), np.arange(shape[0]))
+ with pytest.raises(ValueError, match=r"third value must be 1.0"):
+ Affine.identity() @ (vx, vy, 2)
+ with pytest.raises(ValueError, match=r"third values must all be 1.0"):
+ Affine.identity() @ (vx, vy, np.zeros(shape))
+ with pytest.raises(TypeError, match="2 or 3 items"):
+ Affine.identity() @ (vx, vy, np.ones(shape), np.ones(shape))
+
+
+def test_mul_item_errors():
+ shape = (4, 5)
+ vx, vy = np.meshgrid(np.arange(shape[1]), np.arange(shape[0]))
+ with pytest.raises(TypeError):
+ Affine.identity() * (vx, vy, 1)
+ with pytest.raises(TypeError):
+ Affine.identity() * (vx, vy, np.zeros(shape))
=====================================
affine/tests/test_pickle.py → tests/test_pickle.py
=====================================
@@ -2,9 +2,8 @@
Validate that instances of `affine.Affine()` can be pickled and unpickled.
"""
-
-import pickle
from multiprocessing import Pool
+import pickle
import affine
=====================================
affine/tests/test_rotation.py → tests/test_rotation.py
=====================================
@@ -1,5 +1,7 @@
import math
+import pytest
+
from affine import Affine
@@ -14,7 +16,7 @@ def test_rotation_angle():
|
0---------*
- Affine.rotation(45.0) * (1.0, 0.0) == (0.707..., 0.707...)
+ Affine.rotation(45.0) @ (1.0, 0.0) == (0.707..., 0.707...)
|
| *
@@ -22,9 +24,10 @@ def test_rotation_angle():
|
0----------
"""
- x, y = Affine.rotation(45.0) * (1.0, 0.0)
- assert round(x, 14) == round(math.sqrt(2.0) / 2.0, 14)
- assert round(y, 14) == round(math.sqrt(2.0) / 2.0, 14)
+ x, y = Affine.rotation(45.0) @ (1.0, 0.0)
+ sqrt2div2 = math.sqrt(2.0) / 2.0
+ assert x == pytest.approx(sqrt2div2)
+ assert y == pytest.approx(sqrt2div2)
def test_rotation_matrix():
@@ -34,12 +37,16 @@ def test_rotation_matrix():
| sin(a) cos(a) |
"""
- rot = Affine.rotation(90.0)
- assert round(rot.a, 15) == round(math.cos(math.pi / 2.0), 15)
- assert round(rot.b, 15) == round(-math.sin(math.pi / 2.0), 15)
+ deg = 90.0
+ rot = Affine.rotation(deg)
+ rad = math.radians(deg)
+ cosrad = math.cos(rad)
+ sinrad = math.sin(rad)
+ assert rot.a == pytest.approx(cosrad)
+ assert rot.b == pytest.approx(-sinrad)
assert rot.c == 0.0
- assert round(rot.d, 15) == round(math.sin(math.pi / 2.0), 15)
- assert round(rot.e, 15) == round(math.cos(math.pi / 2.0), 15)
+ assert rot.d == pytest.approx(sinrad)
+ assert rot.e == pytest.approx(cosrad)
assert rot.f == 0.0
@@ -48,8 +55,8 @@ def test_rotation_matrix_pivot():
rot = Affine.rotation(90.0, pivot=(1.0, 1.0))
exp = (
Affine.translation(1.0, 1.0)
- * Affine.rotation(90.0)
- * Affine.translation(-1.0, -1.0)
+ @ Affine.rotation(90.0)
+ @ Affine.translation(-1.0, -1.0)
)
for r, e in zip(rot, exp):
- assert round(r, 15) == round(e, 15)
+ assert r == pytest.approx(e)
=====================================
affine/tests/test_serialize.py → tests/test_serialize.py
=====================================
=====================================
tests/test_transform.py
=====================================
@@ -0,0 +1,658 @@
+#############################################################################
+# Planar is Copyright (c) 2010 by Casey Duncan
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# * Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+# * Neither the name(s) of the copyright holders nor the names of its
+# contributors may be used to endorse or promote products derived from this
+# software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AS IS AND ANY EXPRESS OR
+# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
+# EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
+# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+# EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#############################################################################
+
+"""Transform unit tests"""
+
+import math
+from textwrap import dedent
+
+import pytest
+
+import affine
+from affine import Affine
+
+
+def seq_almost_equal(t1, t2, error=0.00001):
+ assert len(t1) == len(t2), f"{t1!r} != {t2!r}"
+ for m1, m2 in zip(t1, t2):
+ assert abs(m1 - m2) <= error, f"{t1!r} != {t2!r}"
+
+
+def test_zero_args():
+ with pytest.raises(TypeError):
+ Affine()
+
+
+def test_wrong_arg_type():
+ with pytest.raises(TypeError):
+ Affine(None)
+
+
+def test_args_too_few():
+ with pytest.raises(TypeError):
+ Affine(1, 2)
+
+
+def test_args_too_many():
+ with pytest.raises(TypeError):
+ Affine(*range(10))
+
+
+def test_args_members_wrong_type():
+ with pytest.raises(TypeError):
+ Affine(0, 2, 3, None, None, "")
+
+
+def test_len():
+ t = Affine(1, 2, 3, 4, 5, 6)
+ assert len(t) == 9
+
+
+def test_slice_last_row():
+ t = Affine(1, 2, 3, 4, 5, 6)
+ 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:
+ assert isinstance(m, float), repr(m)
+
+
+def test_getitem():
+ t = Affine(1, 2, 3, 4, 5, 6)
+ assert t[0] == 1
+ assert t[1] == 2
+ assert t[2] == 3
+ assert t[3] == 4
+ assert t[4] == 5
+ assert t[5] == 6
+ assert t[6] == 0
+ assert t[7] == 0
+ assert t[8] == 1
+ assert t[-1] == 1
+
+
+def test_getitem_wrong_type():
+ t = Affine(1, 2, 3, 4, 5, 6)
+ with pytest.raises(TypeError):
+ t["foobar"]
+
+
+def test_str():
+ t = Affine(1.111, 2.222, 3.333, -4.444, -5.555, 6.666)
+ assert str(t) == dedent(
+ """\
+ | 1.11, 2.22, 3.33|
+ |-4.44,-5.55, 6.67|
+ | 0.00, 0.00, 1.00|"""
+ )
+
+
+def test_repr():
+ t = Affine(1.111, 2.222, 3.456, 4.444, 5.5, 6.25)
+ assert repr(t) == dedent(
+ """\
+ Affine(1.111, 2.222, 3.456,
+ 4.444, 5.5, 6.25)"""
+ )
+
+
+def test_identity_constructor():
+ ident = Affine.identity()
+ assert isinstance(ident, Affine)
+ assert tuple(ident) == (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)
+ assert ident.is_identity
+
+
+def test_permutation_constructor():
+ perm = Affine.permutation()
+ assert isinstance(perm, Affine)
+ assert tuple(perm) == (0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0)
+ assert (perm @ perm).is_identity
+
+
+def test_translation_constructor():
+ trans = Affine.translation(2, -5)
+ assert isinstance(trans, Affine)
+ assert tuple(trans) == (1.0, 0.0, 2.0, 0.0, 1.0, -5.0, 0.0, 0.0, 1.0)
+
+
+def test_scale_constructor():
+ scale = Affine.scale(5)
+ assert isinstance(scale, Affine)
+ assert tuple(scale) == (5, 0, 0, 0, 5, 0, 0, 0, 1)
+ scale = Affine.scale(-1, 2)
+ assert tuple(scale) == (-1, 0, 0, 0, 2, 0, 0, 0, 1)
+ assert tuple(Affine.scale(1)) == tuple(Affine.identity())
+
+
+def test_shear_constructor():
+ shear = Affine.shear(30)
+ assert isinstance(shear, Affine)
+ mx = math.tan(math.radians(30))
+ seq_almost_equal(tuple(shear), (1, mx, 0, 0, 1, 0, 0, 0, 1))
+ shear = Affine.shear(-15, 60)
+ mx = math.tan(math.radians(-15))
+ my = math.tan(math.radians(60))
+ seq_almost_equal(tuple(shear), (1, mx, 0, my, 1, 0, 0, 0, 1))
+ shear = Affine.shear(y_angle=45)
+ seq_almost_equal(tuple(shear), (1, 0, 0, 1, 1, 0, 0, 0, 1))
+
+
+def test_rotation_constructor():
+ rot = Affine.rotation(60)
+ assert isinstance(rot, Affine)
+ r = math.radians(60)
+ s, c = math.sin(r), math.cos(r)
+ assert tuple(rot) == (c, -s, 0, s, c, 0, 0, 0, 1)
+ rot = Affine.rotation(337)
+ r = math.radians(337)
+ s, c = math.sin(r), math.cos(r)
+ seq_almost_equal(tuple(rot), (c, -s, 0, s, c, 0, 0, 0, 1))
+ assert tuple(Affine.rotation(0)) == tuple(Affine.identity())
+
+
+def test_rotation_constructor_quadrants():
+ assert tuple(Affine.rotation(0)) == (1, 0, 0, 0, 1, 0, 0, 0, 1)
+ assert tuple(Affine.rotation(90)) == (0, -1, 0, 1, 0, 0, 0, 0, 1)
+ assert tuple(Affine.rotation(180)) == (-1, 0, 0, 0, -1, 0, 0, 0, 1)
+ assert tuple(Affine.rotation(-180)) == (-1, 0, 0, 0, -1, 0, 0, 0, 1)
+ assert tuple(Affine.rotation(270)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
+ assert tuple(Affine.rotation(-90)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
+ assert tuple(Affine.rotation(360)) == (1, 0, 0, 0, 1, 0, 0, 0, 1)
+ assert tuple(Affine.rotation(450)) == (0, -1, 0, 1, 0, 0, 0, 0, 1)
+ assert tuple(Affine.rotation(-450)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
+
+
+def test_rotation_constructor_with_pivot():
+ assert tuple(Affine.rotation(60)) == tuple(Affine.rotation(60, pivot=(0, 0)))
+ rot = Affine.rotation(27, pivot=(2, -4))
+ r = math.radians(27)
+ s, c = math.sin(r), math.cos(r)
+ # fmt: off
+ assert tuple(rot) == (
+ c, -s, 2 - 2 * c - 4 * s,
+ s, c, -4 - 2 * s + 4 * c,
+ 0, 0, 1,
+ )
+ # fmt: on
+ assert tuple(Affine.rotation(0, (-3, 2))) == tuple(Affine.identity())
+
+
+def test_rotation_contructor_wrong_arg_types():
+ with pytest.raises(TypeError):
+ Affine.rotation(1, 1)
+
+
+def test_determinant():
+ assert Affine.identity().determinant == 1
+ assert Affine.scale(2).determinant == 4
+ assert Affine.scale(0).determinant == 0
+ assert Affine.scale(5, 1).determinant == 5
+ assert Affine.scale(-1, 1).determinant == -1
+ assert Affine.scale(-1, 0).determinant == 0
+ assert Affine.rotation(77).determinant == pytest.approx(1)
+ assert Affine.translation(32, -47).determinant == pytest.approx(1)
+
+
+def test_is_rectilinear():
+ assert Affine.identity().is_rectilinear
+ assert Affine.scale(2.5, 6.1).is_rectilinear
+ assert Affine.translation(4, -1).is_rectilinear
+ assert Affine.rotation(90).is_rectilinear
+ assert not Affine.shear(4, -1).is_rectilinear
+ assert not Affine.rotation(-26).is_rectilinear
+
+
+def test_is_conformal():
+ assert Affine.identity().is_conformal
+ assert Affine.scale(2.5, 6.1).is_conformal
+ assert Affine.translation(4, -1).is_conformal
+ assert Affine.rotation(90).is_conformal
+ assert Affine.rotation(-26).is_conformal
+ assert not Affine.shear(4, -1).is_conformal
+
+
+def test_is_orthonormal():
+ assert Affine.identity().is_orthonormal
+ assert Affine.translation(4, -1).is_orthonormal
+ assert Affine.rotation(90).is_orthonormal
+ assert Affine.rotation(-26).is_orthonormal
+ assert not Affine.scale(2.5, 6.1).is_orthonormal
+ assert not Affine.scale(0.5, 2).is_orthonormal
+ assert not Affine.shear(4, -1).is_orthonormal
+
+
+def test_is_degenerate():
+ assert not Affine.identity().is_degenerate
+ assert not Affine.translation(2, -1).is_degenerate
+ assert not Affine.shear(0, -22.5).is_degenerate
+ assert not Affine.rotation(88.7).is_degenerate
+ assert not Affine.scale(0.5).is_degenerate
+ assert Affine.scale(0).is_degenerate
+ assert Affine.scale(-10, 0).is_degenerate
+ assert Affine.scale(0, 300).is_degenerate
+ assert Affine.scale(0).is_degenerate
+ assert Affine.scale(0).is_degenerate
+
+
+def test_column_vectors():
+ a, b, c = Affine(2, 3, 4, 5, 6, 7).column_vectors
+ assert isinstance(a, tuple)
+ assert isinstance(b, tuple)
+ assert isinstance(c, tuple)
+ assert a == (2, 5)
+ assert b == (3, 6)
+ assert c == (4, 7)
+
+
+def test_almost_equals():
+ EPSILON = 1e-5
+ E = EPSILON * 0.5
+ t = Affine(1.0, E, 0, -E, 1.0 + E, E)
+ assert t.almost_equals(Affine.identity())
+ assert Affine.identity().almost_equals(t)
+ assert t.almost_equals(t)
+ t = Affine(1.0, 0, 0, -EPSILON, 1.0, 0)
+ assert not t.almost_equals(Affine.identity())
+ assert not Affine.identity().almost_equals(t)
+ assert t.almost_equals(t)
+
+
+def test_almost_equals_2():
+ EPSILON = 1e-10
+ E = EPSILON * 0.5
+ t = Affine(1.0, E, 0, -E, 1.0 + E, E)
+ assert t.almost_equals(Affine.identity(), precision=EPSILON)
+ assert Affine.identity().almost_equals(t, precision=EPSILON)
+ assert t.almost_equals(t, precision=EPSILON)
+ t = Affine(1.0, 0, 0, -EPSILON, 1.0, 0)
+ assert not t.almost_equals(Affine.identity(), precision=EPSILON)
+ assert not Affine.identity().almost_equals(t, precision=EPSILON)
+ assert t.almost_equals(t, precision=EPSILON)
+
+
+def test_equality():
+ t1 = Affine(1, 2, 3, 4, 5, 6)
+ t2 = Affine(6, 5, 4, 3, 2, 1)
+ t3 = Affine(1, 2, 3, 4, 5, 6)
+ assert t1 == t3
+ assert t1 != t2
+ assert t2 == t2
+ assert t1 == t3
+ assert t2 == t2
+ assert t1 != t2
+ assert t1 != 1
+ assert t1 != 1
+
+
+def test_gt():
+ with pytest.raises(TypeError):
+ Affine(1, 2, 3, 4, 5, 6) > Affine(6, 5, 4, 3, 2, 1)
+
+
+def test_lt():
+ with pytest.raises(TypeError):
+ Affine(1, 2, 3, 4, 5, 6) < Affine(6, 5, 4, 3, 2, 1)
+
+
+def test_add():
+ with pytest.raises(TypeError):
+ Affine(1, 2, 3, 4, 5, 6) + Affine(6, 5, 4, 3, 2, 1)
+
+
+def test_sub():
+ with pytest.raises(TypeError):
+ Affine(1, 2, 3, 4, 5, 6) - Affine(6, 5, 4, 3, 2, 1)
+
+
+def test_matmul_by_identity():
+ t = Affine(1, 2, 3, 4, 5, 6)
+ assert tuple(t @ Affine.identity()) == tuple(t)
+
+
+def test_matmul_transform():
+ t = Affine.rotation(5) @ Affine.rotation(29)
+ assert isinstance(t, Affine)
+ seq_almost_equal(t, Affine.rotation(34))
+ t = Affine.scale(3, 5) @ Affine.scale(2)
+ seq_almost_equal(t, Affine.scale(6, 10))
+
+
+def test_itransform():
+ pts = [(4, 1), (-1, 0), (3, 2)]
+ r = Affine.scale(-2).itransform(pts)
+ assert r is None, r
+ assert pts == [(-8, -2), (2, 0), (-6, -4)]
+
+ A = Affine.rotation(33)
+ pts = [(4, 1), (-1, 0), (3, 2)]
+ pts_expect = [A @ pt for pt in pts]
+ r = A.itransform(pts)
+ assert r is None
+ assert pts == pts_expect
+
+
+def test_mul_wrong_type():
+ with pytest.raises(TypeError):
+ Affine(1, 2, 3, 4, 5, 6) * None
+
+
+def test_matmul_wrong_type():
+ with pytest.raises(TypeError):
+ Affine(1, 2, 3, 4, 5, 6) @ None
+
+
+def test_matmul_sequence_wrong_member_types():
+ class NotPtSeq:
+ @classmethod
+ def from_points(cls, points):
+ list(points)
+
+ def __iter__():
+ yield 0
+
+ with pytest.raises(TypeError):
+ Affine(1, 2, 3, 4, 5, 6) * NotPtSeq()
+
+ with pytest.raises(TypeError):
+ Affine(1, 2, 3, 4, 5, 6) @ NotPtSeq()
+
+
+def test_imul_errors():
+ t = Affine.identity()
+ with pytest.raises(TypeError):
+ t *= 2.0
+
+
+def test_imatmul_errors():
+ t = Affine.identity()
+ with pytest.raises(TypeError):
+ t @= 2.0
+ with pytest.raises(TypeError):
+ t @= (1, 2)
+ with pytest.raises(TypeError):
+ t @= (1, 2, 1)
+
+
+def test_imul_tuple():
+ t = Affine.translation(3, 5)
+ with pytest.deprecated_call():
+ t *= (4, 5)
+ assert isinstance(t, tuple) # changes type!
+ assert t == (7.0, 10.0)
+
+
+def test_imul_transform():
+ t = Affine.translation(3, 5)
+ t *= Affine.translation(-2, 3.5)
+ assert isinstance(t, Affine)
+ seq_almost_equal(t, Affine.translation(1, 8.5))
+
+
+def test_imatmul_transform():
+ t = Affine.translation(3, 5)
+ t @= Affine.translation(-2, 3.5)
+ assert isinstance(t, Affine)
+ seq_almost_equal(t, Affine.translation(1, 8.5))
+
+
+def test_inverse():
+ seq_almost_equal(~Affine.identity(), Affine.identity())
+ seq_almost_equal(~Affine.translation(2, -3), Affine.translation(-2, 3))
+ seq_almost_equal(~Affine.rotation(-33.3), Affine.rotation(33.3))
+ t = Affine(1, 2, 3, 4, 5, 6)
+ seq_almost_equal(~t @ t, Affine.identity())
+
+
+def test_cant_invert_degenerate():
+ t = Affine.scale(0)
+ with pytest.raises(affine.TransformNotInvertibleError):
+ ~t
+
+
+def test_bad_type_world():
+ """wrong type, i.e don't use readlines()"""
+ with pytest.raises(TypeError):
+ affine.loadsw(["1.0", "0.0", "0.0", "1.0", "0.0", "0.0"])
+
+
+def test_bad_value_world():
+ """Wrong number of parameters."""
+ with pytest.raises(ValueError, match="Expected 6 coefficients"):
+ affine.loadsw("1.0\n0.0\n0.0\n1.0\n0.0\n0.0\n0.0")
+
+
+def test_simple_world():
+ s = "1.0\n0.0\n0.0\n-1.0\n100.5\n199.5\n"
+ a = affine.loadsw(s)
+ assert a == Affine(1.0, 0.0, 100.0, 0.0, -1.0, 200.0)
+ assert affine.dumpsw(a) == s
+
+
+def test_real_world():
+ s = dedent(
+ """\
+ 39.9317755024
+ 30.0907511581
+ 30.0907511576
+ -39.9317755019
+ 2658137.2266720217
+ 5990821.7039887439"""
+ ) # no EOL
+ a1 = affine.loadsw(s)
+ assert a1.almost_equals(
+ Affine(
+ 39.931775502364644,
+ 30.090751157602412,
+ 2658102.2154086917,
+ 30.090751157602412,
+ -39.931775502364644,
+ 5990826.624500916,
+ )
+ )
+ a1out = affine.dumpsw(a1)
+ assert isinstance(a1out, str)
+ a2 = affine.loadsw(a1out)
+ assert a1.almost_equals(a2)
+
+
+def test_gdal():
+ t = Affine.from_gdal(-237481.5, 425.0, 0.0, 237536.4, 0.0, -425.0)
+ assert t.c == t.xoff == -237481.5
+ assert t.a == 425.0
+ assert t.b == 0.0
+ assert t.f == t.yoff == 237536.4
+ assert t.d == 0.0
+ assert t.e == -425.0
+ assert tuple(t) == (425.0, 0.0, -237481.5, 0.0, -425.0, 237536.4, 0, 0, 1)
+ assert t.to_gdal() == (-237481.5, 425.0, 0.0, 237536.4, 0.0, -425.0)
+
+
+def test_shapely():
+ t = Affine(425.0, 0.0, -237481.5, 0.0, -425.0, 237536.4)
+ assert t.to_shapely() == (425.0, 0.0, 0.0, -425, -237481.5, 237536.4)
+
+
+def test_rmul_errors():
+ t = Affine.identity()
+ with pytest.raises(TypeError):
+ (1.0, 1.0) * t
+
+
+def test_rmatmul_errors():
+ t = Affine.identity()
+ with pytest.raises(TypeError):
+ (1.0, 1.0) @ t
+ with pytest.raises(TypeError):
+ (1.0, 1.0, 1.0) @ t
+
+
+def test_mul_tuple():
+ t = Affine(1, 2, 3, 4, 5, 6)
+ assert t * (2, 2) == (9, 24)
+ with pytest.raises(TypeError):
+ t * (2, 2, 1)
+
+
+def test_associative():
+ point = (12, 5)
+ trans = Affine.translation(-10.0, -5.0)
+ rot90 = Affine.rotation(90.0)
+ result1 = rot90 @ (trans @ point)
+ result2 = (rot90 @ trans) @ point
+ seq_almost_equal(result1, (0.0, 2.0))
+ seq_almost_equal(result1, result2)
+
+
+def test_roundtrip():
+ point = (12, 5)
+ trans = Affine.translation(3, 4)
+ rot37 = Affine.rotation(37.0)
+ point_prime = (trans @ rot37) @ point
+ roundtrip_point = ~(trans @ rot37) @ point_prime
+ seq_almost_equal(point, roundtrip_point)
+
+
+def test_eccentricity():
+ assert Affine.identity().eccentricity == 0.0
+ assert Affine.scale(2).eccentricity == 0.0
+ # assert_equal(Affine.scale(0).eccentricity, ?)
+ assert Affine.scale(2, 1).eccentricity == pytest.approx(math.sqrt(3) / 2)
+ assert Affine.scale(2, 3).eccentricity == pytest.approx(math.sqrt(5) / 3)
+ assert Affine.scale(1, 0).eccentricity == 1.0
+ assert Affine.rotation(77).eccentricity == pytest.approx(0.0)
+ assert Affine.translation(32, -47).eccentricity == pytest.approx(0.0)
+ assert Affine.scale(-1, 1).eccentricity == pytest.approx(0.0)
+
+
+def test_eccentricity_complex():
+ assert (Affine.scale(2, 3) @ Affine.rotation(77)).eccentricity == pytest.approx(
+ math.sqrt(5) / 3
+ )
+ assert (Affine.rotation(77) @ Affine.scale(2, 3)).eccentricity == pytest.approx(
+ math.sqrt(5) / 3
+ )
+ assert (
+ Affine.translation(32, -47) @ Affine.rotation(77) @ Affine.scale(2, 3)
+ ).eccentricity == pytest.approx(math.sqrt(5) / 3)
+
+
+def test_rotation_angle():
+ assert Affine.identity().rotation_angle == 0.0
+ assert Affine.scale(2).rotation_angle == 0.0
+ assert Affine.scale(2, 1).rotation_angle == 0.0
+ assert Affine.translation(32, -47).rotation_angle == pytest.approx(0.0)
+ assert Affine.rotation(30).rotation_angle == pytest.approx(30)
+ assert Affine.rotation(-150).rotation_angle == pytest.approx(-150)
+
+
+def test_rotation_improper():
+ with pytest.raises(affine.UndefinedRotationError):
+ Affine.scale(-1, 1).rotation_angle
+
+
+# See gh-71 for bug report motivating this test.
+def test_mul_fallback_unpack():
+ """Support fallback in case that other is a single object."""
+
+ class TextPoint:
+ """Not iterable, will trigger ValueError in Affine.__mul__."""
+
+ def __rmul__(self, other):
+ return other * (1, 2)
+
+ def __rmatmul__(self, other):
+ return other @ (1, 2)
+
+ assert Affine.identity() * TextPoint() == (1, 2)
+
+ assert Affine.identity() @ TextPoint() == (1, 2)
+
+
+# See gh-71 for bug report motivating this test.
+def test_mul_fallback_type_error():
+ """Support fallback in case that other is an unexpected type."""
+
+ class TextPoint:
+ """Iterable, but values trigger TypeError in Affine.__mul__."""
+
+ def __iter__(self):
+ return ("1", "2")
+
+ def __rmul__(self, other):
+ return other * (1, 2)
+
+ def __rmatmul__(self, other):
+ return other @ (1, 2)
+
+ assert Affine.identity() * TextPoint() == (1, 2)
+ assert Affine.identity() @ TextPoint() == (1, 2)
+
+
+def test_init_invalid_g():
+ with pytest.raises(ValueError, match="g must"):
+ Affine(0, 0, 0, 0, 0, 0, 1)
+
+
+def test_init_invalid_h():
+ with pytest.raises(ValueError, match="h must"):
+ Affine(0, 0, 0, 0, 0, 0, 0, 1)
+
+
+def test_init_invalid_i():
+ with pytest.raises(ValueError, match="i must"):
+ Affine(0, 0, 0, 0, 0, 0, 0, 0, 0)
+
+
+def test_matmul_invalid_vector():
+ with pytest.raises(ValueError, match=r"must be 1.0"):
+ Affine.identity() @ (2.0, 3.0, 0.0)
+
+
+ at pytest.mark.parametrize("vec", [(2.0,), (2.0, 2.0, 1.0, 1.0)])
+def test_matmul_invalid_vector_2(vec):
+ with pytest.raises(TypeError, match="2 or 3 items"):
+ Affine.identity() @ vec
=====================================
tox.ini
=====================================
@@ -1,11 +1,11 @@
[tox]
envlist =
- py37,py38,py39,py310,py311
+ py39,py310,py311,py312,py313,py314
[testenv]
usedevelop = true
deps =
+ numpy
pytest-cov
- responses
commands =
- python -m pytest affine/tests --cov affine --cov-report term-missing
+ python -m pytest --cov affine --cov-report term-missing
View it on GitLab: https://salsa.debian.org/debian-gis-team/python-affine/-/commit/8fa9e1be21f3c9a0b0256d02c59b00f19f85cb54
--
View it on GitLab: https://salsa.debian.org/debian-gis-team/python-affine/-/commit/8fa9e1be21f3c9a0b0256d02c59b00f19f85cb54
You're receiving this email because of your account on salsa.debian.org. Manage all notifications: https://salsa.debian.org/-/profile/notifications | Help: https://salsa.debian.org/help
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20260808/45568909/attachment-0001.htm>
More information about the Pkg-grass-devel
mailing list