pax_global_header00006660000000000000000000000064152356277430014530gustar00rootroot0000000000000052 comment=0ebe33091e838ef8b98558af6528b2f3cc455960 affine-3.0.0/000077500000000000000000000000001523562774300127605ustar00rootroot00000000000000affine-3.0.0/.github/000077500000000000000000000000001523562774300143205ustar00rootroot00000000000000affine-3.0.0/.github/codecov.yml000066400000000000000000000002041523562774300164610ustar00rootroot00000000000000comment: off coverage: status: project: default: target: auto threshold: 5 affine-3.0.0/.github/dependabot.yml000066400000000000000000000004461523562774300171540ustar00rootroot00000000000000# 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: - "*" affine-3.0.0/.github/workflows/000077500000000000000000000000001523562774300163555ustar00rootroot00000000000000affine-3.0.0/.github/workflows/ci.yml000066400000000000000000000026071523562774300175000ustar00rootroot00000000000000name: CI # On every pull request, but only on push to master on: push: branches: - main tags: - '*' pull_request: env: LATEST_PY_VERSION: '3.14' jobs: pre-commit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-python@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.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install affine run: | python -m pip install --upgrade pip python -m pip install .["test"] - name: Run tests run: | python -m pytest --cov=affine --cov-report=term-missing --cov-report=xml - name: Upload Coverage Results if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} uses: codecov/codecov-action@v3 with: files: ./coverage.xml flags: unittests fail_ci_if_error: false affine-3.0.0/.gitignore000066400000000000000000000011231523562774300147450ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ venv/ bin/ build/ develop-eggs/ dist/ eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .cache coverage.xml # Translations *.mo # Mr Developer .mr.developer.cfg .project .pydevproject # PyCharm .idea/ # Rope .ropeproject # Django stuff: *.log *.pot # Sphinx documentation docs/_build/ __pycache__ *.swp .coverage.* .DS_Store affine-3.0.0/.pre-commit-config.yaml000066400000000000000000000010501523562774300172350ustar00rootroot00000000000000ci: 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] affine-3.0.0/.readthedocs.yaml000066400000000000000000000003121523562774300162030ustar00rootroot00000000000000version: 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: . affine-3.0.0/AUTHORS.txt000066400000000000000000000003741523562774300146520ustar00rootroot00000000000000Authors ======= - 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 affine-3.0.0/CHANGES.txt000066400000000000000000000106711523562774300145760ustar00rootroot00000000000000CHANGES ======= 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) ------------------ - Package is marked as Python 3 only, two instances of "%" string formatting are replaced by f-strings (#96). 2.4b1 (2023-01-18) ------------------ - Elimination of Python 2/3 compatibility code in __gt__ (#94). - Addition of optional keyword arguments for __new__, solving an issue with Dask (#92). - Addition of some type hints for float arguments and return types (#87). - Python version support is now 3.7-3.11 (#82). - Faster __new__ and from_gdal methods (#78). 2.3.1 (2022-03-24) ------------------ Bug fixes: - Return NotImplemented for both ValueError and TypeError in __mul__ to support fallback to __rmul__ in more cases (gh-71). 2.3.0 (2019-09-04) ------------------ Deprecations: - Right multiplication like vector * matrix is deprecated and will raise AffineError in version 3.0.0. Bug fixes: - Change signature of Affine constructor to help users of PyCharm (#45). - The Affine class docstring has been improved. 2.2.2 (2018-12-20) ------------------ - Affine.itransform computed the wrong results for arrays with rotation or shear (#40). This is fixed (#41). 2.2.1 (2018-06-04) ------------------ - Docstring improvements (#37). 2.2.0 (2018-03-20) ------------------ - Addition of permutation matrix (#35). 2.1.0 (2017-07-12) ------------------ - Addition of new ``eccentricity`` and ``rotation_angle`` properties (#28). 2.0.0.post1 (2016-05-20) ------------------------ - This is the final 2.0.0 release. The post-release version segment is used because we accidentally uploaded a 2.0.0 to PyPI before the beta releases below. 2.0b2 (2016-05-16) ------------------ - Bug fix: restore ``Affine __rmul__`` even though it permits dubious vector * matrix expressions (#27). 2.0b1 (2016-05-16) ------------------ - Breaking change: precision used in properties like ``is_conformal`` is no longer a global module attribute, but an Affine class or instance attribute (#19, #20). - Breaking change: ``is_degenerate`` property is now exact and not subject to a level of precision (#23). - Breaking change: we have reversed our sense of rotation, a positive angle now rotates a point counter-clockwise about the pivot point (#25). - Bug fix: a bug in matrix-vector multiplication had been reversing the direction of rotation and is now fixed (#25). 1.3.0 (2016-04-08) ------------------ - is_degenerate predicate is precise, not approximate (#22). 1.2.0 (2015-06-01) ------------------ - Enable pickling of Affine objects (#14). - Sort out the mixed up shearing parameters (#12). 1.1.0 (2014-11-13) ------------------ - Add loadsw/dumpsw world file utilities (#6). - Travis-CI and Coveralls config and web hooks added (#10). 1.0.1 (2014-10-20) ------------------ - set_epsilon() now actually sets module EPSILON (#4). - add AUTHORS.txt. 1.0 (2014-05-27) ---------------- - Code ported from Casey Duncan's Planar package. - from_gdal() class method added. affine-3.0.0/CODE_OF_CONDUCT.txt000066400000000000000000000037001523562774300157760ustar00rootroot00000000000000Contributor Code of Conduct --------------------------- As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery * Personal attacks * Trolling or insulting/derogatory comments * Public or private harassment * Publishing other's private information, such as physical or electronic addresses, without explicit permission * Other unethical or unprofessional conduct. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. This Code of Conduct is adapted from the `Contributor Covenant`_, version 1.2.0, available at http://contributor-covenant.org/version/1/2/0/ .. _Contributor Covenant: http://contributor-covenant.org affine-3.0.0/LICENSE.txt000066400000000000000000000027551523562774300146140ustar00rootroot00000000000000Copyright (c) 2014, Sean C. Gillies 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 of Sean C. Gillies 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 AND CONTRIBUTORS "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 OWNER OR CONTRIBUTORS 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. affine-3.0.0/README.rst000066400000000000000000000064051523562774300144540ustar00rootroot00000000000000Affine ====== Matrices describing 2D affine transformation of the plane. .. image:: https://github.com/rasterio/affine/actions/workflows/ci.yml/badge.svg?branch=main :target: https://github.com/rasterio/affine/actions/workflows/ci.yml .. 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 `src/affine.py `__. 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 | | 1 | | 0 0 1 | | 1 | Matrices can be created by passing the values ``a, b, c, d, e, f`` to the ``affine.Affine`` constructor or by using its ``identity()``, ``translation()``, ``scale()``, ``shear()``, and ``rotation()`` class methods. .. code-block:: pycon >>> from affine import Affine >>> Affine.identity() Affine(1.0, 0.0, 0.0, 0.0, 1.0, 0.0) >>> Affine.translation(1.0, 5.0) Affine(1.0, 0.0, 1.0, 0.0, 1.0, 5.0) >>> Affine.scale(2.0) Affine(2.0, 0.0, 0.0, 0.0, 2.0, 0.0) >>> Affine.shear(45.0, 45.0) # decimal degrees Affine(1.0, 0.9999999999999999, 0.0, 0.9999999999999999, 1.0, 0.0) >>> Affine.rotation(45.0) # decimal degrees Affine(0.7071067811865476, -0.7071067811865475, 0.0, 0.7071067811865475, 0.7071067811865476, 0.0) 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 >>> Affine.translation(1.0, 5.0) * (1.0, 1.0) (2.0, 6.0) >>> Affine.rotation(45.0) * (1.0, 1.0) (1.1102230246251565e-16, 1.414213562373095) They may also be multiplied together to combine transformations. .. code-block:: pycon >>> Affine.translation(1.0, 5.0) * Affine.rotation(45.0) Affine(0.7071067811865476, -0.7071067811865475, 1.0, 0.7071067811865475, 0.7071067811865476, 5.0) Usage with GIS data packages ---------------------------- Georeferenced raster datasets use affine transformations to map from image coordinates to world coordinates. The ``affine.Affine.from_gdal()`` class method helps convert `GDAL GeoTransform `__, sequences of 6 numbers in which the first and fourth are the x and y offsets and the second and sixth are the x and y pixel sizes. Using a GDAL dataset transformation matrix, the world coordinates ``(x, y)`` corresponding to the top left corner of the pixel 100 rows down from the origin can be easily computed. .. code-block:: pycon >>> geotransform = (-237481.5, 425.0, 0.0, 237536.4, 0.0, -425.0) >>> fwd = Affine.from_gdal(*geotransform) >>> col, row = 0, 100 >>> fwd * (col, row) (-237481.5, 195036.4) The reverse transformation is obtained using the ``~`` inverse operator. .. code-block:: pycon >>> rev = ~fwd >>> rev * fwd * (col, row) (0.0, 99.99999999999999) affine-3.0.0/docs/000077500000000000000000000000001523562774300137105ustar00rootroot00000000000000affine-3.0.0/docs/Makefile000066400000000000000000000011731523562774300153520ustar00rootroot00000000000000# 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) affine-3.0.0/docs/make.bat000066400000000000000000000013761523562774300153240ustar00rootroot00000000000000@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 affine-3.0.0/docs/requirements.txt000066400000000000000000000000241523562774300171700ustar00rootroot00000000000000attrs sphinx==9.1.0 affine-3.0.0/docs/src/000077500000000000000000000000001523562774300144775ustar00rootroot00000000000000affine-3.0.0/docs/src/_static/000077500000000000000000000000001523562774300161255ustar00rootroot00000000000000affine-3.0.0/docs/src/_static/.gitkeep000066400000000000000000000000001523562774300175440ustar00rootroot00000000000000affine-3.0.0/docs/src/conf.py000066400000000000000000000024711523562774300160020ustar00rootroot00000000000000# 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 = "" affine-3.0.0/docs/src/index.rst000066400000000000000000000010051523562774300163340ustar00rootroot00000000000000Welcome 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` affine-3.0.0/pyproject.toml000066400000000000000000000033541523562774300157010ustar00rootroot00000000000000[build-system] requires = ["flit_core >=3.2,<4"] build-backend = "flit_core.buildapi" [project] name = "affine" description = "Matrices describing affine transformation of the plane" dynamic = ["version"] authors = [ {name = "Sean Gillies", email = "sean.gillies@gmail.com"}, ] readme = "README.rst" keywords = ["affine", "transformation", "matrix"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Topic :: Multimedia :: Graphics :: Graphics Conversion", "Topic :: Scientific/Engineering :: GIS", "Typing :: Typed", ] license = {text = "BSD-3-Clause"} requires-python = ">=3.9" dependencies = [ "attrs", ] [project.optional-dependencies] test = [ "numpy>=1.20", "pytest>=6.0", "pytest-cov", ] dev = [ "coveralls>=3.0", ] [project.urls] Source = "https://github.com/rasterio/affine" [tool.flit.sdist] 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" affine-3.0.0/src/000077500000000000000000000000001523562774300135475ustar00rootroot00000000000000affine-3.0.0/src/affine/000077500000000000000000000000001523562774300147775ustar00rootroot00000000000000affine-3.0.0/src/affine/__init__.py000066400000000000000000000555161523562774300171240ustar00rootroot00000000000000"""Affine transformation matrices. The Affine package is derived from Casey Duncan's Planar package. See the copyright statement below. """ ############################################################################# # 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. ############################################################################# 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__ = "3.0.0" EPSILON: float = 1e-5 EPSILON2: float = 1e-10 class AffineError(Exception): pass class TransformNotInvertibleError(AffineError): """The transform could not be inverted.""" class UndefinedRotationError(AffineError): """The rotation angle could not be computed for this transform.""" 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 angles. """ deg = deg % 360.0 if deg == 90.0: return 0.0, 1.0 if deg == 180.0: return -1.0, 0 if deg == 270.0: return 0, -1.0 rad = math.radians(deg) return math.cos(rad), math.sin(rad) @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 the 3 x 3 augmented affine transformation matrix. 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 Coefficients of the 3 x 3 augmented affine transformation matrix. .. 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. 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. """ 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) # 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 ) -> Affine: """Use same coefficient order as GDAL's GetGeoTransform(). Parameters ---------- c, a, b, f, d, e : float Parameters ordered by GDAL's GeoTransform. Returns ------- Affine """ return cls(a, b, c, d, e, f) @classmethod def identity(cls) -> Affine: """Return the identity transform. Returns ------- Affine """ return identity @classmethod def translation(cls, xoff: float, yoff: float) -> Affine: """Create a translation transform from an offset vector. Parameters ---------- xoff, yoff : float Translation offsets in x and y directions. Returns ------- Affine """ return cls(1.0, 0.0, xoff, 0.0, 1.0, yoff) @classmethod def scale(cls, *scaling: float) -> Affine: """Create a scaling transform from a scalar or vector. 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 = scaling[0] sy = sx else: sx, sy = scaling return cls(sx, 0.0, 0.0, 0.0, sy, 0.0) @classmethod def shear(cls, x_angle: float = 0.0, y_angle: float = 0.0) -> Affine: """Create a shear transform along one or both axes. 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 cls(1.0, mx, 0.0, my, 1.0, 0.0) @classmethod def rotation(cls, angle: float, pivot: Sequence[float] | None = None) -> Affine: """Create a rotation transform at the specified angle. 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 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: float) -> Affine: """Create the permutation transform. For 2x2 matrices, there is only one permutation matrix that is not the identity. Parameters ---------- *scaling : any Ignored. Returns ------- Affine """ return cls(0.0, 1.0, 0.0, 1.0, 0.0, 0.0) def __str__(self) -> str: """Concise string representation.""" return ( 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 ( 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) -> tuple[float, float, float, float, float, float]: """Return same coefficient order expected by GDAL's SetGeoTransform(). 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) -> tuple[float, float, float, float, float, float]: """Return affine transformation parameters for shapely's affinity module. Returns ------- tuple Ordered: a, b, d, e, c, f. """ return (self.a, self.b, self.d, self.e, self.c, self.f) @property def xoff(self) -> float: """Alias for 'c'.""" return self.c @property def yoff(self) -> float: """Alias for 'f'.""" return self.f @cached_property def determinant(self) -> float: """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 """ return self.a * self.e - self.b * self.d @property 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. """ 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 det2 = (a * e - b * d) ** 2 delta = trace**2 / 4.0 - det2 if delta < EPSILON2: delta = 0.0 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 def eccentricity(self) -> float: """The eccentricity of the affine transformation. This value represents the eccentricity of an ellipse under this affine transformation. Raises ------ NotImplementedError For improper transformations. """ l1, l2 = self._scaling 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. Raises ------ UndefinedRotationError For improper and degenerate transformations. """ if self.is_proper or self.is_degenerate: l1, _ = self._scaling 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, EPSILON) @property def is_rectilinear(self) -> bool: """True if the transform is rectilinear. i.e., whether a shape would remain axis-aligned, within rounding limits, after applying the transform. """ return (abs(self.a) < EPSILON and abs(self.e) < EPSILON) or ( abs(self.d) < EPSILON and abs(self.b) < EPSILON ) @property def is_conformal(self) -> bool: """True if the transform is conformal. i.e., if angles between points are preserved after applying the transform, within rounding limits. This implies that the transform has no effective shear. """ return abs(self.a * self.b + self.d * self.e) < EPSILON @property def is_orthonormal(self) -> bool: """True if the transform is orthonormal. Which means that the transform represents a rigid motion, which has no effective scaling or shear. Mathematically, this means that the axis vectors of the transform matrix are perpendicular and unit-length. Applying an orthonormal transform to a shape always results in a congruent shape. """ a, b, d, e = self.a, self.b, self.d, self.e return ( self.is_conformal 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: """Return True if this transform is degenerate. A degenerate transform will collapse a shape to an effective area of zero, and cannot be inverted. Returns ------- bool """ return self.determinant == 0.0 @cached_property def is_proper(self) -> bool: """Return True if this transform is proper. A proper transform (with a positive determinant) does not include reflection. Returns ------- bool """ return self.determinant > 0.0 @property 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: Affine, precision: float | None = None) -> bool: """Compare transforms for approximate equality. 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``. """ 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 __ge__ = __lt__ = __le__ = __gt__ # Override from base class. We do not support entrywise # addition, subtraction or scalar multiplication because # the result is not an affine transform def __add__(self, other): raise TypeError("Operation not supported") __iadd__ = __add__ @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. 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[:6] if isinstance(other, Affine): 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, ) # 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: 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 __imatmul__(self, other): # type: ignore if not isinstance(other, Affine): raise TypeError("Operation not supported") return NotImplemented @overload def __mul__(self, other: Affine) -> Affine: ... @overload def __mul__(self, other: tuple[float, float]) -> tuple[float, float]: ... def __mul__(self, other): """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. 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 __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 Returns ------- None The input sequence is mutated in-place. """ if self is not identity and self != identity: 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 ------ 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[: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, ) # fmt: on def __getnewargs__(self): """Pickle protocol support. Notes ----- Normal unpickling creates a situation where __new__ receives all 9 elements rather than the 6 that are required for the constructor. This method ensures that only the 6 are provided. """ return self[:6] identity = Affine(1, 0, 0, 0, 1, 0) """The identity transform""" # Miscellaneous utilities 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. 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(f"Expected 6 coefficients, found {len(coeffs)}") a, d, b, e, c, f = (float(x) for x in coeffs) center = Affine(a, b, c, d, e, f) return center @ Affine.translation(-0.5, -0.5) def dumpsw(obj: Affine) -> str: """Return string for a world file. This method also translates the coefficients from corner- to center-based coordinates. Returns ------- str """ 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) affine-3.0.0/src/affine/py.typed000066400000000000000000000000001523562774300164640ustar00rootroot00000000000000affine-3.0.0/tests/000077500000000000000000000000001523562774300141225ustar00rootroot00000000000000affine-3.0.0/tests/__init__.py000066400000000000000000000000331523562774300162270ustar00rootroot00000000000000"""Affine tests package""" affine-3.0.0/tests/test_numpy.py000066400000000000000000000075621523562774300167150ustar00rootroot00000000000000"""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-3.0.0/tests/test_pickle.py000066400000000000000000000013471523562774300170070ustar00rootroot00000000000000""" Validate that instances of `affine.Affine()` can be pickled and unpickled. """ from multiprocessing import Pool import pickle import affine def test_pickle(): a = affine.Affine(1, 2, 3, 4, 5, 6) assert pickle.loads(pickle.dumps(a)) == a def _mp_proc(x): # A helper function - needed for test_with_multiprocessing() # Can't be defined inside the test because multiprocessing needs # everything to be in __main__ assert isinstance(x, affine.Affine) return x def test_with_multiprocessing(): a1 = affine.Affine(1, 2, 3, 4, 5, 6) a2 = affine.Affine(6, 5, 4, 3, 2, 1) results = Pool(2).map(_mp_proc, [a1, a2]) for expected, actual in zip([a1, a2], results): assert expected == actual affine-3.0.0/tests/test_rotation.py000066400000000000000000000025021523562774300173710ustar00rootroot00000000000000import math import pytest from affine import Affine def test_rotation_angle(): """A positive angle rotates a vector counter clockwise (1.0, 0.0): | | | | 0---------* Affine.rotation(45.0) @ (1.0, 0.0) == (0.707..., 0.707...) | | * | | 0---------- """ 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(): """A rotation matrix has expected elements | cos(a) -sin(a) | | sin(a) cos(a) | """ 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 rot.d == pytest.approx(sinrad) assert rot.e == pytest.approx(cosrad) assert rot.f == 0.0 def test_rotation_matrix_pivot(): """A rotation matrix with pivot has expected elements""" 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) ) for r, e in zip(rot, exp): assert r == pytest.approx(e) affine-3.0.0/tests/test_serialize.py000066400000000000000000000003071523562774300175220ustar00rootroot00000000000000"""Test unpacking and repacking affine matrices.""" from affine import Affine def test_issue79(): """An affine matrix can be created from an unpacked matrix.""" Affine(*Affine.identity()) affine-3.0.0/tests/test_transform.py000066400000000000000000000456561523562774300175660ustar00rootroot00000000000000############################################################################# # 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) @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 affine-3.0.0/tox.ini000066400000000000000000000002741523562774300142760ustar00rootroot00000000000000[tox] envlist = py39,py310,py311,py312,py313,py314 [testenv] usedevelop = true deps = numpy pytest-cov commands = python -m pytest --cov affine --cov-report term-missing