├── .gitignore ├── .requirements.txt ├── .travis.yml ├── LICENCE.txt ├── MANIFEST.in ├── README.rst ├── _static ├── figure_1.png ├── figure_2.png ├── figure_3.png └── figure_4.png ├── appveyor.yml ├── docs ├── Makefile └── source │ ├── _static │ ├── figure_1.png │ ├── figure_2.png │ ├── figure_3.png │ └── figure_4.png │ ├── conf.py │ ├── index.rst │ ├── modules.rst │ ├── raster_link.rst │ └── spatial_efd.rst ├── paper ├── codemeta.json ├── paper.md ├── paper.pdf ├── paper.template └── refs.bib ├── setup.cfg ├── setup.py ├── spatial_efd ├── __init__.py └── spatial_efd.py └── test ├── __init__.py ├── fixtures ├── example_data.dbf ├── example_data.prj ├── example_data.shp ├── example_data.shx └── expected.json └── test_efd.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled python modules. 2 | *.pyc 3 | 4 | # Setuptools distribution folder. 5 | /dist/ 6 | 7 | /build/ 8 | 9 | # Python egg metadata, regenerated from source files by setuptools. 10 | /*.egg-info 11 | 12 | # sphinx build files 13 | /docs/build/* 14 | 15 | .cache/ 16 | .coverage 17 | .eggs/ 18 | .pytest_cache/ 19 | -------------------------------------------------------------------------------- /.requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib 2 | numpy 3 | pyshp 4 | flake8 5 | pytest-cov 6 | codecov 7 | future 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.5" 6 | - "3.6" 7 | - "3.7" 8 | - "3.8" 9 | # PyPy versions - Failing on Travis as of 10/8/2020 10 | # - "pypy3" 11 | 12 | services: 13 | - xvfb 14 | script: pytest 15 | branches: 16 | only: 17 | - master 18 | install: 19 | - pip install . 20 | - pip install -r .requirements.txt 21 | after_success: 22 | - pytest --cov=spatial_efd/ 23 | - codecov 24 | - flake8 spatial_efd/spatial_efd.py 25 | - flake8 tests/test_efd.py 26 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2020 Stuart Grieve 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include test/fixtures/example_data.* 3 | include test/fixtures/expected.json 4 | include _static/* 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Spatial Elliptical Fourier Descriptors 2 | ======================================= 3 | 4 | .. image:: https://travis-ci.com/sgrieve/spatial_efd.svg?branch=master 5 | :target: https://travis-ci.com/sgrieve/spatial_efd 6 | 7 | .. image:: https://ci.appveyor.com/api/projects/status/vgq1n1ke4tnia2yn/branch/master?svg=true 8 | :target: https://ci.appveyor.com/project/sgrieve/spatial-efd 9 | 10 | .. image:: https://codecov.io/gh/sgrieve/spatial_efd/branch/master/graph/badge.svg 11 | :target: https://codecov.io/gh/sgrieve/spatial_efd 12 | 13 | .. image:: https://requires.io/github/sgrieve/spatial_efd/requirements.svg?branch=master 14 | :target: https://requires.io/github/sgrieve/spatial_efd/requirements/?branch=master 15 | 16 | .. image:: https://readthedocs.org/projects/spatial-efd/badge/?version=latest 17 | :target: http://spatial-efd.readthedocs.io/en/latest/?badge=latest 18 | 19 | .. image:: https://img.shields.io/badge/License-MIT-green.svg 20 | :target: https://opensource.org/licenses/MIT 21 | 22 | .. image:: http://joss.theoj.org/papers/10.21105/joss.00189/status.svg 23 | :target: http://dx.doi.org/10.21105/joss.00189 24 | 25 | 26 | A pure python implementation of the elliptical Fourier analysis method described by `Kuhl and Giardina (1982) `_. This package is designed to allow the rapid analysis of spatial data stored as ESRI shapefiles, handling all of the geometric conversions. The resulting data can be written back to shapefiles to allow analysis with other spatial data or can be plotted using matplotlib. 27 | 28 | The code is built upon the `pyefd module `_ and it is hoped that this package will allow more geoscientists to apply this technique to analyze spatial data using the elliptical Fourier descriptor technique as there is no longer a data conversion barrier to entry. This package is also more feature rich than previous implementations, providing calculations of Fourier power and spatial averaging of collections of ellipses. 29 | 30 | .. figure:: _static/figure_1.png 31 | :width: 600px 32 | :align: center 33 | :alt: spatial_efd example 34 | :figclass: align-center 35 | 36 | Examples of Fourier ellipses (black) being fitted to a shapefile outline (red), for increasing numbers of harmonics. 37 | 38 | Features 39 | -------- 40 | 41 | - Built-in geometry processing, just pass in a shapefile and get results quickly! 42 | - Fourier coefficient average and standard deviation calculation 43 | - Handles spatial input data through the pyshp library 44 | - Compute an appropriate number of harmonics for a given polygon 45 | - Basic plotting for analysis and debugging through matplotlib 46 | - Write Fourier ellipses as shapefiles 47 | 48 | Installation 49 | ------------ 50 | 51 | Install ``spatial_efd`` by running: 52 | 53 | .. code-block:: bash 54 | 55 | $ pip install spatial_efd 56 | 57 | Dependencies 58 | ------------ 59 | 60 | This package supports Python 2.7 and Python 3 and is tested on Linux and Windows environments, using both the standard python interpreter and `pypy `_. It requires ``matplotlib``, ``numpy``, ``future`` and ``pyshp``. These packages will all install automatically if ``spatial_efd`` is installed using ``pip``. 61 | 62 | Note: `pypy` is currently not being tested via CI due to a matplotlib build error. Please get in touch if this is an issue. 63 | 64 | Dependencies can be tracked by visiting `requires.io `_ 65 | 66 | Note that Python 2 has reached `end of life `_ and although the code currently works under Python 2, this will not be supported, and future updates may completely break Python 2 support without warning. 67 | 68 | Tests 69 | ---------- 70 | 71 | A range of unit tests are included in the `/test/` directory. These can 72 | be run using `pytest`: 73 | 74 | .. code-block:: bash 75 | 76 | $ pytest 77 | 78 | Many of these tests make use of the ``example_data.shp`` file which is a shapefile containing six polygons taken from a real dataset of landslide source areas. 79 | 80 | Usage 81 | ---------- 82 | 83 | Normalized Data 84 | ~~~~~~~~~~~~~~~~~~~~~~ 85 | 86 | The first step in using ``spatial_efd`` is always to load a shapefile: 87 | 88 | .. code-block:: python 89 | 90 | import spatial_efd 91 | shp = spatial_efd.LoadGeometries('test/fixtures/example_data.shp') 92 | 93 | This creates a shapefile object ``shp`` which contains the polygon geometries we want to analyze. As in most cases more than one polygon will be stored in an individual file, a single polygon can be selected for processing using python's list notation: 94 | 95 | .. code-block:: python 96 | 97 | x, y, centroid = spatial_efd.ProcessGeometryNorm(shp[1]) 98 | 99 | This loads the geometry from the 2nd polygon within the shapefile into a list of x and a list of y coordinates. This method also computes the centroid of the polygon, which can be useful for later analysis. To make comparisons between data from different locations simpler, these data are normalized. 100 | 101 | If you already know how many harmonics you wish to compute this can be specified during the calculation of the Fourier coefficients: 102 | 103 | .. code-block:: python 104 | 105 | harmonic = 20 106 | coeffs = spatial_efd.CalculateEFD(x, y, harmonic) 107 | 108 | However, if you need to quantify the number of harmonics needed to exceed a threshold Fourier power, this functionality is available. To do this, an initial set of coefficients need to be computed to the number of harmonics required to equal the Nyquist frequency: 109 | 110 | .. code-block:: python 111 | 112 | nyquist = spatial_efd.Nyquist(x) 113 | tmpcoeffs = spatial_efd.CalculateEFD(x, y, nyquist) 114 | harmonic = spatial_efd.FourierPower(tmpcoeffs, x) 115 | coeffs = spatial_efd.CalculateEFD(x, y, harmonic) 116 | 117 | Once the coefficients have been calculated they can be normalized following the steps outlined by `Kuhl and Giardina (1982) `_: 118 | 119 | .. code-block:: python 120 | 121 | coeffs, rotation = spatial_efd.normalize_efd(coeffs, size_invariant=True) 122 | 123 | ``size_invariant`` should be set to True (the default value) in most cases to normalize the coefficient values, allowing comparison between polygons of differing sizes. Set ``size_invariant`` to False if it is required to plot the Fourier ellipses alongside the input shapefiles, or if the Fourier ellipses are to be written to a shapefile. These techniques which apply to normalized data are outlined later in this document. 124 | 125 | A set of coefficients can be converted back into a series of x and y coordinates by performing an inverse transform, where the harmonic value passed in will be the harmonic reconstructed: 126 | 127 | .. code-block:: python 128 | 129 | xt, yt = spatial_efd.inverse_transform(coeffs, harmonic=harmonic) 130 | 131 | Wrappers around some of the basic ``matplotlib`` functionality is provided to speed up the visualization of results: 132 | 133 | .. code-block:: python 134 | 135 | ax = spatial_efd.InitPlot() 136 | spatial_efd.PlotEllipse(ax, xt, yt, color='k', width=1.) 137 | spatial_efd.SavePlot(ax, harmonic, '/plots/myfigure', 'png') 138 | 139 | This example generates an axis object, plots our transformed coordinates onto it with a line width of 1 and a line color of black. These axes are saved with a title denoting the harmonic used to generate the coordinates and are saved in the format provided in the location provided. 140 | 141 | Note that as this plotting is performed using ``matplotlib`` many other formatting options can be applied to the created axis object, to easily create publication ready plots. 142 | 143 | To plot an overlay of a Fourier ellipse and the original shapefile data, a convenience function has been provided to streamline the coordinate processing required. 144 | Plotting the normalized coefficients, where the data has been processed using the ``ProcessGeometryNorm`` method is undertaken as follows (Note that ``size_invariant`` has been set to ``False``): 145 | 146 | .. code-block:: python 147 | 148 | # size_invariant must be set to false if a normalized Fourier ellipse 149 | # is to be plotted alongside the shapefile data 150 | coeffs, rotation = spatial_efd.normalize_efd(coeffs, size_invariant=False) 151 | ax = spatial_efd.InitPlot() 152 | spatial_efd.plotComparison(ax, coeffs, harmonic, x, y, rotation=rotation) 153 | spatial_efd.SavePlot(ax, harmonic, '/plots/myComparison', 'png') 154 | 155 | Which produces a figure like this: 156 | 157 | .. figure:: _static/figure_3.png 158 | :width: 400 159 | :align: center 160 | :alt: spatial_efd example 161 | :figclass: align-center 162 | 163 | Example of a normalized Fourier ellipse (black) being plotted on top of a shapefile outline (red). 164 | 165 | All of the above examples have focused on processing a single polygon from a multipart shapefile, but in most cases multiple geometries will be required to be processed. One of the common techniques surrounding elliptical Fourier analysis is the averaging of a collection of polygons. This can be achieved as follows: 166 | 167 | .. code-block:: python 168 | 169 | shp = spatial_efd.LoadGeometries('test/fixtures/example_data.shp') 170 | 171 | coeffsList = [] 172 | 173 | for shape in shp: 174 | x, y, centroid = spatial_efd.ProcessGeometryNorm(shape) 175 | 176 | harmonic = 10 177 | coeffs = spatial_efd.CalculateEFD(x, y, harmonic) 178 | 179 | coeffs, rotation = spatial_efd.normalize_efd(coeffs, size_invariant=True) 180 | 181 | coeffsList.append(coeffs) 182 | 183 | avgcoeffs = spatial_efd.AverageCoefficients(coeffsList) 184 | 185 | Once the average coefficients for a collection of polygons has been computed, the standard deviation can also be calculated: 186 | 187 | .. code-block:: python 188 | 189 | SDcoeffs = spatial_efd.AverageSD(coeffsList, avgcoeffs) 190 | 191 | With the average and standard deviation coefficients calculated, the average shape, with error ellipses can be plotted in the same manner as individual ellipses were plotted earlier 192 | 193 | .. code-block:: python 194 | 195 | x_avg, y_avg = spatial_efd.inverse_transform(avgcoeffs, harmonic=harmonic) 196 | x_sd, y_sd = spatial_efd.inverse_transform(SDcoeffs, harmonic=harmonic) 197 | 198 | ax = spatial_efd.InitPlot() 199 | spatial_efd.PlotEllipse(ax, x_avg, y_avg, color='b', width=2.) 200 | 201 | # Plot avg +/- 1 SD error ellipses 202 | spatial_efd.PlotEllipse(ax, x_avg + x_sd, y_avg + y_sd, color='k', width=1.) 203 | spatial_efd.PlotEllipse(ax, x_avg - x_sd, y_avg - y_sd, color='k', width=1.) 204 | 205 | spatial_efd.SavePlot(ax, harmonic, '/plots/average', 'png') 206 | 207 | Which produces a figure like this: 208 | 209 | .. figure:: _static/figure_4.png 210 | :width: 400 211 | :align: center 212 | :alt: spatial_efd example 213 | :figclass: align-center 214 | 215 | Example of an average Fourier ellipse (blue) being plotted with standard deviation error ellipses (black). 216 | 217 | Non-Normalized Data 218 | ~~~~~~~~~~~~~~~~~~~~~~ 219 | 220 | In cases where the original coordinates are needed, a different processing method can be called when loading coordinates from a shapefile, to return the non-normalized data: 221 | 222 | .. code-block:: python 223 | 224 | x, y, centroid = spatial_efd.ProcessGeometry(shp[1]) 225 | 226 | This method should be used where the original coordinates need to be preserved, for example if output to a shapefile is desired. To plot non-normalized data alongside the original shapefile data, the locus of the coefficients must be computed and passed as an argument to the inverse transform method: 227 | 228 | .. code-block:: python 229 | 230 | locus = spatial_efd.calculate_dc_coefficients(x, y) 231 | xt, yt = spatial_efd.inverse_transform(coeffs, harmonic=harmonic, locus=locus) 232 | 233 | To plot non-normalized coefficients, again call the ``plotComparison`` method, with the rotation value set to ``0`` as no normalization has been performed on the input data: 234 | 235 | .. code-block:: python 236 | 237 | ax = spatial_efd.InitPlot() 238 | spatial_efd.plotComparison(ax, coeffs, harmonic, x, y, rotation=0.) 239 | spatial_efd.SavePlot(ax, harmonic, '/plots/myComparison', 'png') 240 | 241 | Which produces a figure like this: 242 | 243 | .. figure:: _static/figure_2.png 244 | :width: 400 245 | :align: center 246 | :alt: spatial_efd example 247 | :figclass: align-center 248 | 249 | Example of a non-normalized Fourier ellipse (black) being plotted on top of a shapefile outline (red). 250 | 251 | In the case of the non-normalized data plotted above, these ellipses can also be written to a shapefile to allow further analysis in a GIS package: 252 | 253 | .. code-block:: python 254 | 255 | shape_id = 1 256 | shpinstance = spatial_efd.generateShapefile('mydata/myShapefile', prj='test/fixtures/example_data.prj') 257 | shpinstance = spatial_efd.writeGeometry(coeffs, x, y, harmonic, shpinstance, shape_id) 258 | 259 | The first method called creates a blank shapefile in the path ``mydata``, ready to be populated with Fourier ellipses. By passing in the existing ``example.prj`` file to the save method, a new projection file will be generated for the saved shapefile, ensuring that it has the correct spatial reference information for when it is loaded into a GIS package. Note that no reprojection is performed as the aim is for the input and output coordinate systems to match. If this parameter is excluded, the output shapefile will have no defined spatial reference system. 260 | 261 | The second method can be wrapped in a loop to write as many ellipses as required to a single file. ``shape_id`` is written into the attribute table of the output shapefile and can be set to any integer as a means of identifying the Fourier ellipses. 262 | 263 | For more detailed guidance on all of the functions and arguments in this package please check out the source code on `github `_ or the `API documentation. `_ 264 | 265 | Contribute 266 | ---------- 267 | 268 | .. image:: https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat 269 | :target: https://codecov.io/github/sgrieve/spatial_efd/issues 270 | 271 | I welcome contributions to the code, head to the issue tracker on GitHub to get involved! 272 | 273 | - `Issue Tracker `_ 274 | - `Source Code `_ 275 | 276 | Support 277 | ------- 278 | 279 | If you find any bugs, have any questions or would like to see a feature in a new version, drop me a line: 280 | 281 | - Twitter: `@GIStuart `_ 282 | - Email: stuart@swdg.io 283 | 284 | License 285 | ------- 286 | 287 | The project is licensed under the MIT license. 288 | 289 | Citation 290 | -------- 291 | 292 | If you use this package for scientific research please cite it as: 293 | 294 | Grieve, S. W. D. (2017), spatial-efd: A spatial-aware implementation of elliptical Fourier analysis, The Journal of Open Source Software, 2 (11), doi:10.21105/joss.00189. 295 | 296 | 297 | You can grab a bibtex file `here `_. 298 | 299 | References 300 | ----------- 301 | 302 | `Kuhl and Giardina (1982) `_. Elliptic Fourier features of a closed contour. Computer graphics and image processing, 18(3), 236-258. 303 | -------------------------------------------------------------------------------- /_static/figure_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/_static/figure_1.png -------------------------------------------------------------------------------- /_static/figure_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/_static/figure_2.png -------------------------------------------------------------------------------- /_static/figure_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/_static/figure_3.png -------------------------------------------------------------------------------- /_static/figure_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/_static/figure_4.png -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | version: 1.0.{build} 2 | image: Visual Studio 2019 3 | init: 4 | - cmd: SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH% 5 | environment: 6 | matrix: 7 | - PYTHON: C:\\Python37-x64 8 | - PYTHON: C:\\Python37 9 | - PYTHON: C:\\Python38-x64 10 | - PYTHON: C:\\Python38 11 | install: 12 | - cmd: '%PYTHON%\\python.exe -m pip install -r .requirements.txt' 13 | test_script: 14 | - cmd: '%PYTHON%\\python.exe -m pytest' 15 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = SpatialEFD 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/source/_static/figure_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/docs/source/_static/figure_1.png -------------------------------------------------------------------------------- /docs/source/_static/figure_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/docs/source/_static/figure_2.png -------------------------------------------------------------------------------- /docs/source/_static/figure_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/docs/source/_static/figure_3.png -------------------------------------------------------------------------------- /docs/source/_static/figure_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/docs/source/_static/figure_4.png -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Spatial EFD documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Feb 15 14:18:59 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | 22 | sys.path.insert(0, os.path.abspath('.')) 23 | sys.path.insert(0, os.path.abspath('../')) 24 | sys.path.append(os.path.join(os.path.dirname(__name__), '..')) 25 | 26 | 27 | # -- General configuration ------------------------------------------------ 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = ['sphinx.ext.autodoc', 37 | 'sphinx.ext.viewcode', 'sphinx.ext.napoleon'] 38 | 39 | napoleon_google_docstring = True 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The suffix(es) of source filenames. 45 | # You can specify multiple suffix as a list of string: 46 | # 47 | # source_suffix = ['.rst', '.md'] 48 | source_suffix = '.rst' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = u'Spatial EFD' 55 | copyright = u'2017 - 2020, Stuart W.D. Grieve' 56 | author = u'Stuart W.D. Grieve' 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | # The short X.Y version. 63 | version = u'1.2' 64 | # The full version, including alpha/beta/rc tags. 65 | release = u'1.2.1' 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # 70 | # This is also used if you do content translation via gettext catalogs. 71 | # Usually you set "language" from the command line for these cases. 72 | language = None 73 | 74 | # List of patterns, relative to source directory, that match files and 75 | # directories to ignore when looking for source files. 76 | # This patterns also effect to html_static_path and html_extra_path 77 | exclude_patterns = [] 78 | 79 | # The name of the Pygments (syntax highlighting) style to use. 80 | pygments_style = 'sphinx' 81 | 82 | # If true, `todo` and `todoList` produce output, else they produce nothing. 83 | todo_include_todos = False 84 | 85 | 86 | # -- Options for HTML output ---------------------------------------------- 87 | 88 | # The theme to use for HTML and HTML Help pages. See the documentation for 89 | # a list of builtin themes. 90 | # 91 | html_theme = 'sphinx_rtd_theme' 92 | 93 | # Theme options are theme-specific and customize the look and feel of a theme 94 | # further. For a list of options available for each theme, see the 95 | # documentation. 96 | # 97 | #html_theme_options = {'sticky_navigation': True} 98 | 99 | # Add any paths that contain custom static files (such as style sheets) here, 100 | # relative to this directory. They are copied after the builtin static files, 101 | # so a file named "default.css" will overwrite the builtin "default.css". 102 | html_static_path = ['_static'] 103 | 104 | 105 | # -- Options for HTMLHelp output ------------------------------------------ 106 | 107 | # Output file base name for HTML help builder. 108 | htmlhelp_basename = 'SpatialEFDdoc' 109 | 110 | 111 | # -- Options for LaTeX output --------------------------------------------- 112 | 113 | latex_elements = { 114 | # The paper size ('letterpaper' or 'a4paper'). 115 | # 116 | # 'papersize': 'letterpaper', 117 | 118 | # The font size ('10pt', '11pt' or '12pt'). 119 | # 120 | # 'pointsize': '10pt', 121 | 122 | # Additional stuff for the LaTeX preamble. 123 | # 124 | # 'preamble': '', 125 | 126 | # Latex figure (float) alignment 127 | # 128 | # 'figure_align': 'htbp', 129 | } 130 | 131 | # Grouping the document tree into LaTeX files. List of tuples 132 | # (source start file, target name, title, 133 | # author, documentclass [howto, manual, or own class]). 134 | latex_documents = [ 135 | (master_doc, 'SpatialEFD.tex', u'Spatial EFD Documentation', 136 | u'Stuart W.D. Grieve', 'manual'), 137 | ] 138 | 139 | 140 | # -- Options for manual page output --------------------------------------- 141 | 142 | # One entry per manual page. List of tuples 143 | # (source start file, name, description, authors, manual section). 144 | man_pages = [ 145 | (master_doc, 'spatialefd', u'Spatial EFD Documentation', 146 | [author], 1) 147 | ] 148 | 149 | 150 | # -- Options for Texinfo output ------------------------------------------- 151 | 152 | # Grouping the document tree into Texinfo files. List of tuples 153 | # (source start file, target name, title, author, 154 | # dir menu entry, description, category) 155 | texinfo_documents = [ 156 | (master_doc, 'SpatialEFD', u'Spatial EFD Documentation', 157 | author, 'SpatialEFD', 'A spatial-aware implementation of elliptical Fourier analysis', 158 | 'Miscellaneous'), 159 | ] 160 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | 2 | .. toctree:: 3 | :maxdepth: 2 4 | :caption: Contents: 5 | :hidden: 6 | 7 | raster_link 8 | modules 9 | 10 | 11 | .. include:: ../../README.rst 12 | 13 | API 14 | ---- 15 | 16 | :ref:`Click here ` for the module level documentation. 17 | 18 | 19 | Indices and tables 20 | ------------------ 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | API 2 | =========== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | spatial_efd 8 | -------------------------------------------------------------------------------- /docs/source/raster_link.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.rst 2 | -------------------------------------------------------------------------------- /docs/source/spatial_efd.rst: -------------------------------------------------------------------------------- 1 | .. _API-ref: 2 | 3 | spatial_efd 4 | ------------------------------ 5 | 6 | .. automodule:: spatial_efd.spatial_efd 7 | :members: 8 | :undoc-members: 9 | :show-inheritance: 10 | -------------------------------------------------------------------------------- /paper/codemeta.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://raw.githubusercontent.com/mbjones/codemeta/master/codemeta.jsonld", 3 | "@type": "Code", 4 | "author": [ 5 | { 6 | "@id": "0000-0003-1893-7363", 7 | "@type": "Person", 8 | "email": "s.grieve@qmul.ac.uk", 9 | "name": "Stuart W D Grieve", 10 | "affiliation": "The University of Edinburgh, School of GeoScience", 11 | "affiliation": "Queen Mary University of London, School of Geography" 12 | } 13 | ], 14 | "identifier": "10.5281/zenodo.322453", 15 | "codeRepository": "github.com/sgrieve/spatial-efd", 16 | "datePublished": "2017-02-23", 17 | "dateModified": "2017-02-24", 18 | "dateCreated": "2017-02-23", 19 | "description": "A spatial-aware implementation of elliptical Fourier analysis", 20 | "keywords": "elliptical fourier analysis, elliptical fourier descriptors, GIS, shapefile, geoscience", 21 | "license": "MIT", 22 | "title": "spatial-efd", 23 | "version": "v1.0.4" 24 | } 25 | -------------------------------------------------------------------------------- /paper/paper.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'spatial-efd: A spatial-aware implementation of elliptical Fourier analysis' 3 | tags: 4 | - Elliptical Fourier descriptors 5 | - Elliptical Fourier analysis 6 | - GIS 7 | - GeoScience 8 | - Shapefile 9 | authors: 10 | - name: Stuart W D Grieve 11 | orcid: 0000-0003-1893-7363 12 | affiliation: 1, 2 13 | affiliations: 14 | - name: The University of Edinburgh, School of GeoScience 15 | index: 1 16 | - name: Queen Mary University of London, School of Geography 17 | index: 2 18 | date: 23 February 2017 19 | bibliography: refs.bib 20 | repository: https://github.com/sgrieve/spatial-efd 21 | archive_doi: https://doi.org/10.5281/zenodo.322453 22 | --- 23 | 24 | # Summary 25 | 26 | A Python implementation of the calculation of elliptical Fourier descriptors as described by @kuhl1982elliptic. This package is designed to allow the rapid analysis of spatial data stored as ESRI shapefiles, handling all of the geometric conversions. The computed Fourier ellipses can then be written back to shapefiles to allow analysis with other spatial data, or can be plotted using matplotlib [@Hunter2007]. The code is built upon the pyefd module [@pyefd] and it is hoped that this package will make analyzing spatial data using Fourier ellipses more straightforward. 27 | 28 | This package implements the original methodology of @kuhl1982elliptic to compute Fourier coefficients from polygon data loaded from shapefiles, and to transform these coefficients back into spatial coordinates with a range of different coordinate normalization schemes. The number of harmonics required to describe a polygon to a user defined threshold Fourier power can be computed, following @costa2009quantitative. The averaging of Fourier coefficients is also implemented, as described by @raj1992, which can be used to provide averaged shapes to machine learning algorithms. Functions are available to handle the challenges of relating spatial coordinates to the normalized Fourier ellipse coordinates, to allow the calculated Fourier ellipses to be output as shapefiles for further analysis in GIS packages. 29 | 30 | The latest stable release of the software can be installed via `pip`, the development version is available from github (https://github.com/sgrieve/spatial-efd/) and the full documentation and API can be found at https://spatial-efd.readthedocs.io. 31 | 32 | ![Examples of Fourier ellipses (black) being fitted to a shapefile outline (red), for increasing numbers of harmonics.](../_static/figure_1.png){#id .class width=400} 33 | 34 | 35 | # Acknowledgements 36 | 37 | The development of this software has been supported by Natural Environment Research Council grants NE/J009970/1 and NE/N01300X/1. 38 | 39 | # References 40 | -------------------------------------------------------------------------------- /paper/paper.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/paper/paper.pdf -------------------------------------------------------------------------------- /paper/paper.template: -------------------------------------------------------------------------------- 1 | \documentclass[$if(fontsize)$$fontsize$,$endif$$if(lang)$$babel-lang$,$endif$$if(papersize)$$papersize$paper,$endif$$for(classoption)$$classoption$$sep$,$endfor$]{article} 2 | $if(fontfamily)$ 3 | \usepackage[$for(fontfamilyoptions)$$fontfamilyoptions$$sep$,$endfor$]{$fontfamily$} 4 | $else$ 5 | \usepackage{lmodern} 6 | $endif$ 7 | \usepackage{authblk} 8 | $if(linestretch)$ 9 | \usepackage{setspace} 10 | \setstretch{$linestretch$} 11 | $endif$ 12 | \usepackage{amssymb,amsmath} 13 | \usepackage{ifxetex,ifluatex} 14 | \usepackage{fixltx2e} % provides \textsubscript 15 | \ifnum 0\ifxetex 1\fi\ifluatex 1\fi=0 % if pdftex 16 | \usepackage[$if(fontenc)$$fontenc$$else$T1$endif$]{fontenc} 17 | \usepackage[utf8]{inputenc} 18 | $if(euro)$ 19 | \usepackage{eurosym} 20 | $endif$ 21 | \else % if luatex or xelatex 22 | \ifxetex 23 | \usepackage{mathspec} 24 | \else 25 | \usepackage{fontspec} 26 | \fi 27 | \defaultfontfeatures{Ligatures=TeX,Scale=MatchLowercase} 28 | $if(euro)$ 29 | \newcommand{\euro}{€} 30 | $endif$ 31 | $if(mainfont)$ 32 | \setmainfont[$for(mainfontoptions)$$mainfontoptions$$sep$,$endfor$]{$mainfont$} 33 | $endif$ 34 | $if(sansfont)$ 35 | \setsansfont[$for(sansfontoptions)$$sansfontoptions$$sep$,$endfor$]{$sansfont$} 36 | $endif$ 37 | $if(monofont)$ 38 | \setmonofont[Mapping=tex-ansi$if(monofontoptions)$,$for(monofontoptions)$$monofontoptions$$sep$,$endfor$$endif$]{$monofont$} 39 | $endif$ 40 | $if(mathfont)$ 41 | \setmathfont(Digits,Latin,Greek)[$for(mathfontoptions)$$mathfontoptions$$sep$,$endfor$]{$mathfont$} 42 | $endif$ 43 | $if(CJKmainfont)$ 44 | \usepackage{xeCJK} 45 | \setCJKmainfont[$for(CJKoptions)$$CJKoptions$$sep$,$endfor$]{$CJKmainfont$} 46 | $endif$ 47 | \fi 48 | % use upquote if available, for straight quotes in verbatim environments 49 | \IfFileExists{upquote.sty}{\usepackage{upquote}}{} 50 | % use microtype if available 51 | \IfFileExists{microtype.sty}{% 52 | \usepackage{microtype} 53 | \UseMicrotypeSet[protrusion]{basicmath} % disable protrusion for tt fonts 54 | }{} 55 | $if(geometry)$ 56 | \usepackage[$for(geometry)$$geometry$$sep$,$endfor$]{geometry} 57 | $endif$ 58 | \usepackage{hyperref} 59 | $if(colorlinks)$ 60 | \PassOptionsToPackage{usenames,dvipsnames}{color} % color is loaded by hyperref 61 | $endif$ 62 | \hypersetup{unicode=true, 63 | $if(title-meta)$ 64 | pdftitle={$title-meta$}, 65 | $endif$ 66 | $if(author-meta)$ 67 | pdfauthor={$author-meta$}, 68 | $endif$ 69 | $if(keywords)$ 70 | pdfkeywords={$for(keywords)$$keywords$$sep$; $endfor$}, 71 | $endif$ 72 | $if(colorlinks)$ 73 | colorlinks=true, 74 | linkcolor=$if(linkcolor)$$linkcolor$$else$Maroon$endif$, 75 | citecolor=$if(citecolor)$$citecolor$$else$Blue$endif$, 76 | urlcolor=$if(urlcolor)$$urlcolor$$else$Blue$endif$, 77 | $else$ 78 | pdfborder={0 0 0}, 79 | $endif$ 80 | breaklinks=true} 81 | \urlstyle{same} % don't use monospace font for urls 82 | $if(lang)$ 83 | \ifnum 0\ifxetex 1\fi\ifluatex 1\fi=0 % if pdftex 84 | \usepackage[shorthands=off,$for(babel-otherlangs)$$babel-otherlangs$,$endfor$main=$babel-lang$]{babel} 85 | $if(babel-newcommands)$ 86 | $babel-newcommands$ 87 | $endif$ 88 | \else 89 | \usepackage{polyglossia} 90 | \setmainlanguage[$polyglossia-lang.options$]{$polyglossia-lang.name$} 91 | $for(polyglossia-otherlangs)$ 92 | \setotherlanguage[$polyglossia-otherlangs.options$]{$polyglossia-otherlangs.name$} 93 | $endfor$ 94 | \fi 95 | $endif$ 96 | $if(natbib)$ 97 | \usepackage{natbib} 98 | \bibliographystyle{$if(biblio-style)$$biblio-style$$else$plainnat$endif$} 99 | $endif$ 100 | $if(biblatex)$ 101 | \usepackage$if(biblio-style)$[style=$biblio-style$]$endif${biblatex} 102 | $if(biblatexoptions)$\ExecuteBibliographyOptions{$for(biblatexoptions)$$biblatexoptions$$sep$,$endfor$}$endif$ 103 | $for(bibliography)$ 104 | \addbibresource{$bibliography$} 105 | $endfor$ 106 | $endif$ 107 | $if(listings)$ 108 | \usepackage{listings} 109 | $endif$ 110 | $if(lhs)$ 111 | \lstnewenvironment{code}{\lstset{language=Haskell,basicstyle=\small\ttfamily}}{} 112 | $endif$ 113 | $if(highlighting-macros)$ 114 | $highlighting-macros$ 115 | $endif$ 116 | $if(verbatim-in-note)$ 117 | \usepackage{fancyvrb} 118 | \VerbatimFootnotes % allows verbatim text in footnotes 119 | $endif$ 120 | $if(tables)$ 121 | \usepackage{longtable,booktabs} 122 | $endif$ 123 | $if(graphics)$ 124 | \usepackage{graphicx,grffile} 125 | \makeatletter 126 | \def\maxwidth{\ifdim\Gin@nat@width>\linewidth\linewidth\else\Gin@nat@width\fi} 127 | \def\maxheight{\ifdim\Gin@nat@height>\textheight\textheight\else\Gin@nat@height\fi} 128 | \makeatother 129 | % Scale images if necessary, so that they will not overflow the page 130 | % margins by default, and it is still possible to overwrite the defaults 131 | % using explicit options in \includegraphics[width, height, ...]{} 132 | \setkeys{Gin}{width=\maxwidth,height=\maxheight,keepaspectratio} 133 | $endif$ 134 | $if(links-as-notes)$ 135 | % Make links footnotes instead of hotlinks: 136 | \renewcommand{\href}[2]{#2\footnote{\url{#1}}} 137 | $endif$ 138 | $if(strikeout)$ 139 | \usepackage[normalem]{ulem} 140 | % avoid problems with \sout in headers with hyperref: 141 | \pdfstringdefDisableCommands{\renewcommand{\sout}{}} 142 | $endif$ 143 | $if(indent)$ 144 | $else$ 145 | \IfFileExists{parskip.sty}{% 146 | \usepackage{parskip} 147 | }{% else 148 | \setlength{\parindent}{0pt} 149 | \setlength{\parskip}{6pt plus 2pt minus 1pt} 150 | } 151 | $endif$ 152 | \setlength{\emergencystretch}{3em} % prevent overfull lines 153 | \providecommand{\tightlist}{% 154 | \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}} 155 | $if(numbersections)$ 156 | \setcounter{secnumdepth}{5} 157 | $else$ 158 | \setcounter{secnumdepth}{0} 159 | $endif$ 160 | $if(subparagraph)$ 161 | $else$ 162 | % Redefines (sub)paragraphs to behave more like sections 163 | \ifx\paragraph\undefined\else 164 | \let\oldparagraph\paragraph 165 | \renewcommand{\paragraph}[1]{\oldparagraph{#1}\mbox{}} 166 | \fi 167 | \ifx\subparagraph\undefined\else 168 | \let\oldsubparagraph\subparagraph 169 | \renewcommand{\subparagraph}[1]{\oldsubparagraph{#1}\mbox{}} 170 | \fi 171 | $endif$ 172 | $if(dir)$ 173 | \ifxetex 174 | % load bidi as late as possible as it modifies e.g. graphicx 175 | $if(latex-dir-rtl)$ 176 | \usepackage[RTLdocument]{bidi} 177 | $else$ 178 | \usepackage{bidi} 179 | $endif$ 180 | \fi 181 | \ifnum 0\ifxetex 1\fi\ifluatex 1\fi=0 % if pdftex 182 | \TeXXeTstate=1 183 | \newcommand{\RL}[1]{\beginR #1\endR} 184 | \newcommand{\LR}[1]{\beginL #1\endL} 185 | \newenvironment{RTL}{\beginR}{\endR} 186 | \newenvironment{LTR}{\beginL}{\endL} 187 | \fi 188 | $endif$ 189 | $for(header-includes)$ 190 | $header-includes$ 191 | $endfor$ 192 | 193 | $if(title)$ 194 | \title{$title$$if(thanks)$\thanks{$thanks$}$endif$} 195 | $endif$ 196 | $if(subtitle)$ 197 | \providecommand{\subtitle}[1]{} 198 | \subtitle{$subtitle$} 199 | $endif$ 200 | 201 | $if(authors)$ 202 | $for(authors)$ 203 | $if(authors.affiliation)$ 204 | \author[$authors.affiliation$]{$authors.name$} 205 | $else$ 206 | \author{$authors.name$} 207 | $endif$ 208 | $endfor$ 209 | $endif$ 210 | 211 | $if(affiliations)$ 212 | $for(affiliations)$ 213 | \affil[$affiliations.index$]{$affiliations.name$} 214 | $endfor$ 215 | $endif$ 216 | 217 | \date{$date$} 218 | 219 | 220 | 221 | \begin{document} 222 | $if(title)$ 223 | \maketitle 224 | $endif$ 225 | $if(abstract)$ 226 | \begin{abstract} 227 | $abstract$ 228 | \end{abstract} 229 | $endif$ 230 | 231 | \textbf{Paper DOI:} \url{http://dx.doi.org/$formatted_doi$}\\ 232 | \textbf{Software Repository:} \url{$repository$}\\ 233 | \textbf{Software Archive:} \url{$archive_doi$}\\ 234 | 235 | $for(include-before)$ 236 | $include-before$ 237 | 238 | $endfor$ 239 | $if(toc)$ 240 | { 241 | $if(colorlinks)$ 242 | \hypersetup{linkcolor=$if(toccolor)$$toccolor$$else$black$endif$} 243 | $endif$ 244 | \setcounter{tocdepth}{$toc-depth$} 245 | \tableofcontents 246 | } 247 | $endif$ 248 | $if(lot)$ 249 | \listoftables 250 | $endif$ 251 | $if(lof)$ 252 | \listoffigures 253 | $endif$ 254 | $body$ 255 | 256 | $if(natbib)$ 257 | $if(bibliography)$ 258 | $if(biblio-title)$ 259 | $if(book-class)$ 260 | \renewcommand\bibname{$biblio-title$} 261 | $else$ 262 | \renewcommand\refname{$biblio-title$} 263 | $endif$ 264 | $endif$ 265 | \bibliography{$for(bibliography)$$bibliography$$sep$,$endfor$} 266 | 267 | $endif$ 268 | $endif$ 269 | $if(biblatex)$ 270 | \printbibliography$if(biblio-title)$[title=$biblio-title$]$endif$ 271 | 272 | $endif$ 273 | $for(include-after)$ 274 | $include-after$ 275 | 276 | $endfor$ 277 | \end{document} 278 | -------------------------------------------------------------------------------- /paper/refs.bib: -------------------------------------------------------------------------------- 1 | @article{kuhl1982elliptic, 2 | title={Elliptic Fourier features of a closed contour}, 3 | author={Kuhl, Frank P and Giardina, Charles R}, 4 | journal={Computer graphics and image processing}, 5 | volume={18}, 6 | number={3}, 7 | pages={236--258}, 8 | year={1982}, 9 | publisher={Elsevier} 10 | } 11 | 12 | @article{raj1992, 13 | title={2-D particle shape averaging and comparison using Fourier descriptors}, 14 | author={Raj, P Markondeya and Cannon, W Roger}, 15 | journal={Powder technology}, 16 | volume={104}, 17 | number={2}, 18 | pages={180--189}, 19 | year={1999}, 20 | publisher={Elsevier} 21 | } 22 | 23 | @article{costa2009quantitative, 24 | title={Quantitative evaluation of Tarocco sweet orange fruit shape using optoelectronic elliptic Fourier based analysis}, 25 | author={Costa, Corrado and Menesatti, Paolo and Paglia, Graziella and Pallottino, Federico and Aguzzi, Jacopo and Rimatori, Valentina and Russo, Giuseppe and Recupero, Santo and Recupero, Giuseppe Reforgiato}, 26 | journal={Postharvest biology and Technology}, 27 | volume={54}, 28 | number={1}, 29 | pages={38--47}, 30 | year={2009}, 31 | publisher={Elsevier} 32 | } 33 | 34 | @online{pyefd, 35 | author = {Henrik Blidh}, 36 | title = {Python implementation of Elliptic Fourier Features of a Closed Contour}, 37 | year = 2013, 38 | url = {https://github.com/hbldh/pyefd}, 39 | urldate = {2017-02-23} 40 | } 41 | 42 | @Article{Hunter2007, 43 | Author = {Hunter, J. D.}, 44 | Title = {Matplotlib: A 2D graphics environment}, 45 | Journal = {Computing In Science \& Engineering}, 46 | Volume = {9}, 47 | Number = {3}, 48 | Pages = {90--95}, 49 | abstract = {Matplotlib is a 2D graphics package used for Python 50 | for application development, interactive scripting, and 51 | publication-quality image generation across user 52 | interfaces and operating systems.}, 53 | publisher = {IEEE COMPUTER SOC}, 54 | doi = {10.1109/MCSE.2007.55}, 55 | year = 2007 56 | } 57 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | 5 | def readme(): 6 | 7 | # Need to replace the local image paths with the external links so the 8 | # readme works on pypi 9 | lut = { 10 | '_static/figure_1.png': 'https://raw.githubusercontent.com/sgrieve/spatial_efd/master/_static/figure_1.png', 11 | '_static/figure_2.png': 'https://raw.githubusercontent.com/sgrieve/spatial_efd/master/_static/figure_2.png', 12 | '_static/figure_3.png': 'https://raw.githubusercontent.com/sgrieve/spatial_efd/master/_static/figure_3.png', 13 | '_static/figure_4.png': 'https://raw.githubusercontent.com/sgrieve/spatial_efd/master/_static/figure_4.png' 14 | } 15 | 16 | with open('README.rst') as f: 17 | text = f.read() 18 | 19 | for k, v in lut.items(): 20 | text = text.replace(k, v) 21 | 22 | return text 23 | 24 | 25 | setup(name='spatial_efd', 26 | version='1.2.1', 27 | description='Spatial elliptical fourier analysis', 28 | url='http://github.com/sgrieve/spatial_efd', 29 | long_description=readme(), 30 | keywords='GIS elliptical fourier analysis shapefile', 31 | classifiers=['Development Status :: 5 - Production/Stable', 32 | 'License :: OSI Approved :: MIT License', 33 | 'Programming Language :: Python :: 2.7', 34 | 'Programming Language :: Python :: 3', 35 | 'Intended Audience :: Science/Research', 36 | 'Topic :: Scientific/Engineering :: GIS', 37 | 'Operating System :: OS Independent'], 38 | author='Stuart WD Grieve', 39 | author_email='stuart@swdg.io', 40 | license='MIT', 41 | packages=['spatial_efd'], 42 | setup_requires=['pytest-runner'], 43 | install_requires=['matplotlib>=2.0.0', 'numpy>=1.12.0', 44 | 'pyshp>=1.2.10', 'future'], 45 | include_package_data=True, 46 | zip_safe=False, 47 | test_suite='pytest-runner', 48 | tests_require=['pytest']) 49 | -------------------------------------------------------------------------------- /spatial_efd/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | if(sys.version[0] == 2): 3 | from spatial_efd import * 4 | else: 5 | from .spatial_efd import * 6 | -------------------------------------------------------------------------------- /spatial_efd/spatial_efd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import division 3 | from builtins import range, zip 4 | import warnings 5 | import numpy as np 6 | import shapefile as sf 7 | import matplotlib.pyplot as plt 8 | import os.path as path 9 | from shutil import copy2 10 | 11 | 12 | def RotateContour(X, Y, rotation, centroid): 13 | ''' 14 | Rotates a contour about a point by a given amount expressed in degrees. 15 | 16 | Operates by calling rotatePoint() on each x,y pair in turn. X and Y must 17 | have the same dimensions. 18 | 19 | Args: 20 | X (list): A list (or numpy array) of x coordinate values. 21 | Y (list): A list (or numpy array) of y coordinate values. 22 | rotation (float): The angle in degrees for the contour to be rotated 23 | by. 24 | centroid (tuple): A tuple containing the x,y coordinates of the 25 | centroid to rotate the contour about. 26 | 27 | Returns: 28 | tuple: A tuple containing a list of x coordinates and a list of y 29 | coordinates. 30 | ''' 31 | 32 | rxs = [] 33 | rys = [] 34 | 35 | for nx, ny in zip(X, Y): 36 | rx, ry = rotatePoint((nx, ny), centroid, rotation) 37 | rxs.append(rx) 38 | rys.append(ry) 39 | 40 | return rxs, rys 41 | 42 | 43 | def NormContour(X, Y, rawCentroid): 44 | ''' 45 | Normalize the coordinates which make up a contour. 46 | 47 | Rescale the coordinates to values between 0 and 1 in both the x and y 48 | directions. The normalizing is performed using x or y width of the minimum 49 | bounding rectangle of the contour, whichever is largest. X and Y must have 50 | the same dimensions. 51 | 52 | Args: 53 | X (list): A list (or numpy array) of x coordinate values. 54 | Y (list): A list (or numpy array) of y coordinate values. 55 | rawCentroid (tuple): A tuple containing the x,y coordinates of the 56 | centroid of the contour. 57 | 58 | Returns: 59 | tuple: A tuple containing a list of normalized x coordinates, a list of 60 | normalized y coordinate and the normalized centroid. 61 | ''' 62 | 63 | # find longest axis of rotated shape 64 | xwidth, ywidth, xmin, ymin = getBBoxDimensions(X, Y) 65 | if (xwidth > ywidth): 66 | normshape = xwidth 67 | elif (ywidth >= xwidth): 68 | normshape = ywidth 69 | 70 | norm_x = [(value - xmin) / normshape for value in X] 71 | norm_y = [(value - ymin) / normshape for value in Y] 72 | 73 | centroid = ((rawCentroid[0] - xmin) / normshape, 74 | (rawCentroid[1] - ymin) / normshape) 75 | 76 | return norm_x, norm_y, centroid 77 | 78 | 79 | def CloseContour(X, Y): 80 | ''' 81 | Close an opened polygon. 82 | 83 | Args: 84 | X (list): A list (or numpy array) of x coordinate values. 85 | Y (list): A list (or numpy array) of y coordinate values. 86 | 87 | Returns: 88 | tuple: A tuple containing the X and Y lists of coordinates where the 89 | first and last elements are equal. 90 | ''' 91 | if ((X[0] != X[-1]) or (Y[0] != Y[-1])): 92 | X = X + [X[0]] 93 | Y = Y + [Y[0]] 94 | 95 | return X, Y 96 | 97 | 98 | def ContourArea(X, Y): 99 | ''' 100 | Compute the area of an irregular polygon. 101 | 102 | Ensures the contour is closed before processing, but does not modify 103 | X or Y outside the scope of this method. Algorithm taken from 104 | http://paulbourke.net/geometry/polygonmesh/. 105 | 106 | Args: 107 | X (list): A list (or numpy array) of x coordinate values. 108 | Y (list): A list (or numpy array) of y coordinate values. 109 | 110 | Returns: 111 | float: The area of the input polygon. 112 | ''' 113 | 114 | # Check the contour provided is closed 115 | X, Y = CloseContour(X, Y) 116 | 117 | Sum = 0 118 | 119 | for i in range(len(X) - 1): 120 | Sum += (X[i] * Y[i + 1]) - (X[i + 1] * Y[i]) 121 | 122 | return abs(0.5 * Sum) 123 | 124 | 125 | def ContourCentroid(X, Y): 126 | ''' 127 | Compute the centroid of an irregular polygon. 128 | 129 | Ensures the contour is closed before processing, but does not modify 130 | X or Y outside the scope of this method. Algorithm taken from 131 | http://paulbourke.net/geometry/polygonmesh/. 132 | 133 | Args: 134 | X (list): A list (or numpy array) of x coordinate values. 135 | Y (list): A list (or numpy array) of y coordinate values. 136 | 137 | Returns: 138 | tuple: A tuple containing the (x,y) coordinate of the center of the 139 | input polygon. 140 | ''' 141 | 142 | # Check the contour provided is closed 143 | X, Y = CloseContour(X, Y) 144 | 145 | Area = ContourArea(X, Y) 146 | 147 | Cx = 0 148 | Cy = 0 149 | 150 | for i in range(len(X) - 1): 151 | const = (X[i] * Y[i + 1]) - (X[i + 1] * Y[i]) 152 | 153 | Cx += (X[i] + X[i + 1]) * const 154 | Cy += (Y[i] + Y[i + 1]) * const 155 | 156 | AreaFactor = (1 / (6 * Area)) 157 | 158 | Cx *= AreaFactor 159 | Cy *= AreaFactor 160 | 161 | return (abs(Cx), abs(Cy)) 162 | 163 | 164 | def CalculateEFD(X, Y, harmonics=10): 165 | ''' 166 | Compute the Elliptical Fourier Descriptors for a polygon. 167 | 168 | Implements Kuhl and Giardina method of computing the coefficients 169 | An, Bn, Cn, Dn for a specified number of harmonics. This code is adapted 170 | from the pyefd module. See the original paper for more detail: 171 | 172 | Kuhl, FP and Giardina, CR (1982). Elliptic Fourier features of a closed 173 | contour. Computer graphics and image processing, 18(3), 236-258. 174 | 175 | Args: 176 | X (list): A list (or numpy array) of x coordinate values. 177 | Y (list): A list (or numpy array) of y coordinate values. 178 | harmonics (int): The number of harmonics to compute for the given 179 | shape, defaults to 10. 180 | 181 | Returns: 182 | numpy.ndarray: A numpy array of shape (harmonics, 4) representing the 183 | four coefficients for each harmonic computed. 184 | ''' 185 | 186 | contour = np.array([(x, y) for x, y in zip(X, Y)]) 187 | 188 | dxy = np.diff(contour, axis=0) 189 | dt = np.sqrt((dxy ** 2).sum(axis=1)) 190 | t = np.concatenate([([0, ]), np.cumsum(dt)]).reshape(-1, 1) 191 | T = t[-1] 192 | 193 | phi = (2. * np.pi * t)/T 194 | 195 | coeffs = np.zeros((harmonics, 4)) 196 | 197 | n = np.arange(1, harmonics + 1) 198 | const = T / (2 * n * n * np.pi * np.pi) 199 | phi_n = phi * n 200 | d_cos_phi_n = np.cos(phi_n[1:, :]) - np.cos(phi_n[:-1, :]) 201 | d_sin_phi_n = np.sin(phi_n[1:, :]) - np.sin(phi_n[:-1, :]) 202 | a_n = const * np.sum((dxy[:, 1] / dt).reshape(-1, 1) * d_cos_phi_n, axis=0) 203 | b_n = const * np.sum((dxy[:, 1] / dt).reshape(-1, 1) * d_sin_phi_n, axis=0) 204 | c_n = const * np.sum((dxy[:, 0] / dt).reshape(-1, 1) * d_cos_phi_n, axis=0) 205 | d_n = const * np.sum((dxy[:, 0] / dt).reshape(-1, 1) * d_sin_phi_n, axis=0) 206 | 207 | coeffs = np.vstack((a_n, b_n, c_n, d_n)).T 208 | return coeffs 209 | 210 | 211 | def inverse_transform(coeffs, locus=(0, 0), n_coords=300, harmonic=10): 212 | ''' 213 | Perform an inverse fourier transform to convert the coefficients back into 214 | spatial coordinates. 215 | 216 | Implements Kuhl and Giardina method of computing the performing the 217 | transform for a specified number of harmonics. This code is adapted 218 | from the pyefd module. See the original paper for more detail: 219 | 220 | Kuhl, FP and Giardina, CR (1982). Elliptic Fourier features of a closed 221 | contour. Computer graphics and image processing, 18(3), 236-258. 222 | 223 | Args: 224 | coeffs (numpy.ndarray): A numpy array of shape (harmonic, 4) 225 | representing the four coefficients for each harmonic computed. 226 | locus (tuple): The x,y coordinates of the centroid of the contour being 227 | generated. Use calculate_dc_coefficients() to generate the correct 228 | locus for a shape. 229 | n_coords (int): The number of coordinate pairs to compute. A larger 230 | value will result in a more complex shape at the expense of 231 | increased computational time. Defaults to 300. 232 | harmonics (int): The number of harmonics to be used to generate 233 | coordinates, defaults to 10. Must be <= coeffs.shape[0]. Supply a 234 | smaller value to produce coordinates for a more generalized shape. 235 | 236 | Returns: 237 | numpy.ndarray: A numpy array of shape (harmonics, 4) representing the 238 | four coefficients for each harmonic computed. 239 | ''' 240 | 241 | t = np.linspace(0, 1, n_coords).reshape(1, -1) 242 | n = np.arange(harmonic).reshape(-1, 1) 243 | 244 | xt = (np.matmul(coeffs[:harmonic, 2].reshape(1, -1), 245 | np.cos(2. * (n + 1) * np.pi * t)) + 246 | np.matmul(coeffs[:harmonic, 3].reshape(1, -1), 247 | np.sin(2. * (n + 1) * np.pi * t)) + 248 | locus[0]) 249 | 250 | yt = (np.matmul(coeffs[:harmonic, 0].reshape(1, -1), 251 | np.cos(2. * (n + 1) * np.pi * t)) + 252 | np.matmul(coeffs[:harmonic, 1].reshape(1, -1), 253 | np.sin(2. * (n + 1) * np.pi * t)) + 254 | locus[1]) 255 | 256 | return xt.ravel(), yt.ravel() 257 | 258 | 259 | def InitPlot(): 260 | ''' 261 | Set up the axes for plotting, ensuring that x and y dimensions are equal. 262 | 263 | Returns: 264 | matplotlib.axes.Axes: Matplotlib axis instance. 265 | ''' 266 | ax = plt.gca() 267 | ax.axis('equal') 268 | 269 | return ax 270 | 271 | 272 | def PlotEllipse(ax, x, y, color='k', width=1.): 273 | ''' 274 | Plots an ellipse represented as a series of x and y coordinates on a given 275 | axis. 276 | 277 | Args: 278 | ax (matplotlib.axes.Axes): Matplotlib axis instance. 279 | x (list): A list (or numpy array) of x coordinate values. 280 | y (list): A list (or numpy array) of y coordinate values. 281 | color (string): A matplotlib color string to color the line used to 282 | plot the ellipse. Defaults to k (black). 283 | width (float): The width of the plotted line. Defaults to 1. 284 | ''' 285 | ax.plot(x, y, color, linewidth=width) 286 | 287 | 288 | def plotComparison(ax, coeffs, harmonic, x, y, rotation=0, color1='k', 289 | width1=2, color2='r', width2=1): 290 | ''' 291 | Convenience function which plots an EFD ellipse and a shapefile polygon in 292 | the same coordate system. 293 | 294 | Warning: 295 | If passing in normalized coefficients, they must be created with the 296 | size_invariant parameter set to False. 297 | 298 | Args: 299 | ax (matplotlib.axes.Axes): Matplotlib axis instance. 300 | x (list): A list (or numpy array) of x coordinate values. 301 | y (list): A list (or numpy array) of y coordinate values. 302 | rotation (float): The angle in degrees for the contour to be rotated 303 | by. Generated by normalize_efd(). Leave as 0 if non-normalized 304 | coefficients are being plotted. 305 | harmonic (int): The number of harmonics to be used to generate 306 | coordinates. Must be <= coeffs.shape[0]. Supply a smaller value to 307 | produce coordinates for a more generalized shape. 308 | color1 (string): A matplotlib color string to color the line used to 309 | plot the Fourier ellipse. Defaults to k (black). 310 | width1 (float): The width of the plotted fourier ellipse. Defaults 311 | to 1. 312 | color2 (string): A matplotlib color string to color the line used to 313 | plot the shapefile. Defaults to r (red). 314 | width2 (float): The width of the plotted shapefile. Defaults to 1. 315 | ''' 316 | locus = calculate_dc_coefficients(x, y) 317 | xt, yt = inverse_transform(coeffs, locus=locus, harmonic=harmonic) 318 | 319 | if rotation: 320 | x, y = RotateContour(x, y, rotation, locus) 321 | 322 | PlotEllipse(ax, xt, yt, color1, width1) 323 | PlotEllipse(ax, x, y, color2, width2) 324 | 325 | 326 | def SavePlot(ax, harmonic, filename, figformat='png'): 327 | ''' 328 | Wrapper around the savefig method. 329 | 330 | Call this method to add a title identifying the harmonic being plotted, and 331 | save the plot to a file. Note that harmonic is simply an int value to be 332 | appended to the plot title, it does not select a harmonic to plot. 333 | 334 | The figformat argumet can take any value which matplotlib understands, 335 | which varies by system. To see a full list suitable for your matplotlib 336 | instance, call plt.gcf().canvas.get_supported_filetypes(). 337 | 338 | Args: 339 | ax (matplotlib.axes.Axes): Matplotlib axis instance. 340 | harmonic (int): The harmonic which is being plotted. 341 | filename (string): A complete path and filename, without an extension, 342 | for the saved plot. 343 | figformat (string): A string denoting the format to save the figure as. 344 | Defaults to png. 345 | 346 | ''' 347 | ax.set_title('Harmonic: {0}'.format(harmonic)) 348 | plt.savefig('{0}_{1}.{2}'.format(filename, harmonic, figformat)) 349 | plt.clf() 350 | 351 | 352 | def AverageCoefficients(coeffList): 353 | ''' 354 | Average the coefficients contained in the list of coefficient arrays, 355 | coeffList. 356 | 357 | This method is outlined in: 358 | 359 | 2-D particle shape averaging and comparison using Fourier descriptors: 360 | Powder Technology Volume 104, Issue 2, 1 September 1999, Pages 180-189 361 | 362 | Args: 363 | coeffList (list): A list of coefficient arrays to be averaged. 364 | 365 | Returns: 366 | numpy.ndarray: A numpy array containing the average An, Bn, Cn, Dn 367 | coefficient values. 368 | ''' 369 | 370 | nHarmonics = coeffList[0].shape[0] 371 | coeffsum = np.zeros((nHarmonics, 4)) 372 | 373 | for coeff in coeffList: 374 | coeffsum += coeff 375 | 376 | coeffsum /= float(len(coeffList)) 377 | 378 | return coeffsum 379 | 380 | 381 | def AverageSD(coeffList, avgcoeffs): 382 | ''' 383 | Use the coefficients contained in the list of coefficient arrays, 384 | coeffList, and the average coefficient values to compute the standard 385 | deviation of series of ellipses. 386 | 387 | This method is outlined in: 388 | 389 | 2-D particle shape averaging and comparison using Fourier descriptors: 390 | Powder Technology Volume 104, Issue 2, 1 September 1999, Pages 180-189 391 | 392 | Args: 393 | coeffList (list): A list of coefficient arrays to be averaged. 394 | avgcoeffs (numpy.ndarray): A numpy array containing the average 395 | coefficient values, generated by calling AverageCoefficients(). 396 | 397 | Returns: 398 | numpy.ndarray: A numpy array containing the standard deviation 399 | An, Bn, Cn, Dn coefficient values. 400 | ''' 401 | nHarmonics = avgcoeffs.shape[0] 402 | coeffsum = np.zeros((nHarmonics, 4)) 403 | 404 | for coeff in coeffList: 405 | coeffsum += (coeff ** 2) 406 | 407 | return (coeffsum / float(len(coeffList) - 1)) - (avgcoeffs ** 2) 408 | 409 | 410 | def Nyquist(X): 411 | ''' 412 | Returns the maximum number of harmonics that can be computed for a given 413 | contour, the nyquist freqency. 414 | 415 | See this paper for details: 416 | C. Costa et al. / Postharvest Biology and Technology 54 (2009) 38-47 417 | 418 | Args: 419 | X (list): A list (or numpy array) of x coordinate values. 420 | 421 | Returns: 422 | int: The nyquist frequency, expressed as a number of harmonics. 423 | ''' 424 | return len(X) // 2 425 | 426 | 427 | def FourierPower(coeffs, X, threshold=0.9999): 428 | ''' 429 | Compute the total Fourier power and find the minium number of harmonics 430 | required to exceed the threshold fraction of the total power. 431 | 432 | This is a good method for identifying the number of harmonics to use to 433 | describe a polygon. For more details see: 434 | 435 | C. Costa et al. / Postharvest Biology and Technology 54 (2009) 38-47 436 | 437 | Warning: 438 | The number of coeffs must be >= the nyquist freqency. 439 | 440 | Args: 441 | coeffs (numpy.ndarray): A numpy array of shape (n, 4) representing the 442 | four coefficients for each harmonic computed. 443 | X (list): A list (or numpy array) of x coordinate values. 444 | threshold (float): The threshold fraction of the total Fourier power, 445 | the default is 0.9999. 446 | 447 | Returns: 448 | int: The number of harmonics required to represent the contour above 449 | the threshold Fourier power. 450 | 451 | ''' 452 | nyquist = Nyquist(X) 453 | 454 | totalPower = 0 455 | currentPower = 0 456 | 457 | for n in range(nyquist): 458 | totalPower += ((coeffs[n, 0] ** 2) + (coeffs[n, 1] ** 2) + 459 | (coeffs[n, 2] ** 2) + (coeffs[n, 3] ** 2)) / 2 460 | 461 | for i in range(nyquist): 462 | currentPower += ((coeffs[i, 0] ** 2) + (coeffs[i, 1] ** 2.) + 463 | (coeffs[i, 2] ** 2) + (coeffs[i, 3] ** 2.)) / 2 464 | 465 | if (currentPower / totalPower) > threshold: 466 | return i + 1 467 | 468 | 469 | def normalize_efd(coeffs, size_invariant=True): 470 | ''' 471 | Normalize the Elliptical Fourier Descriptor coefficients for a polygon. 472 | 473 | Implements Kuhl and Giardina method of normalizing the coefficients 474 | An, Bn, Cn, Dn. Performs 3 separate normalizations. First, it makes the 475 | data location invariant by re-scaling the data to a common origin. 476 | Secondly, the data is rotated with respect to the major axis. Thirdly, 477 | the coefficients are normalized with regard to the absolute value of A_1. 478 | This code is adapted from the pyefd module. See the original paper for 479 | more detail: 480 | 481 | Kuhl, FP and Giardina, CR (1982). Elliptic Fourier features of a closed 482 | contour. Computer graphics and image processing, 18(3), 236-258. 483 | 484 | Args: 485 | coeffs (numpy.ndarray): A numpy array of shape (n, 4) representing the 486 | four coefficients for each harmonic computed. 487 | size_invariant (bool): Set to True (the default) to perform the third 488 | normalization and false to return the data withot this processing 489 | step. Set this to False when plotting a comparison between the 490 | input data and the Fourier ellipse. 491 | 492 | Returns: 493 | tuple: A tuple consisting of a numpy.ndarray of shape (harmonics, 4) 494 | representing the four coefficients for each harmonic computed and 495 | the rotation in degrees applied to the normalized contour. 496 | ''' 497 | # Make the coefficients have a zero phase shift from 498 | # the first major axis. Theta_1 is that shift angle. 499 | theta_1 = (0.5 * np.arctan2(2 * ((coeffs[0, 0] * coeffs[0, 1]) + 500 | (coeffs[0, 2] * coeffs[0, 3])), 501 | ((coeffs[0, 0] ** 2) - 502 | (coeffs[0, 1] ** 2) + 503 | (coeffs[0, 2] ** 2) - 504 | (coeffs[0, 3] ** 2)))) 505 | 506 | # Rotate all coefficients by theta_1. 507 | for n in range(1, coeffs.shape[0] + 1): 508 | coeffs[n - 1, :] = np.dot(np.array([[coeffs[n - 1, 0], 509 | coeffs[n - 1, 1]], [coeffs[n - 1, 2], 510 | coeffs[n - 1, 3]]]), 511 | np.array([[np.cos(n * theta_1), 512 | -np.sin(n * theta_1)], 513 | [np.sin(n * theta_1), 514 | np.cos(n * theta_1)]])).flatten() 515 | 516 | # Make the coefficients rotation invariant by rotating so that 517 | # the semi-major axis is parallel to the x-axis. 518 | psi_1 = np.arctan2(coeffs[0, 2], coeffs[0, 0]) 519 | psi_r = np.array([[np.cos(psi_1), np.sin(psi_1)], 520 | [-np.sin(psi_1), np.cos(psi_1)]]) 521 | 522 | # Rotate all coefficients by -psi_1. 523 | for n in range(1, coeffs.shape[0] + 1): 524 | rot = np.array([[coeffs[n - 1, 0], coeffs[n - 1, 1]], 525 | [coeffs[n - 1, 2], coeffs[n - 1, 3]]]) 526 | coeffs[n - 1, :] = psi_r.dot(rot).flatten() 527 | 528 | if size_invariant: 529 | # Obtain size-invariance by normalizing. 530 | coeffs /= np.abs(coeffs[0, 0]) 531 | 532 | return coeffs, np.degrees(psi_1) 533 | 534 | 535 | def calculate_dc_coefficients(X, Y): 536 | ''' 537 | Compute the dc coefficients, used as the locus when calling 538 | inverse_transform(). 539 | 540 | This code is adapted from the pyefd module. See the original paper for 541 | more detail: 542 | 543 | Kuhl, FP and Giardina, CR (1982). Elliptic Fourier features of a closed 544 | contour. Computer graphics and image processing, 18(3), 236-258. 545 | 546 | Args: 547 | X (list): A list (or numpy array) of x coordinate values. 548 | Y (list): A list (or numpy array) of y coordinate values. 549 | 550 | Returns: 551 | tuple: A tuple containing the c and d coefficients. 552 | 553 | ''' 554 | 555 | contour = np.array([(x, y) for x, y in zip(X, Y)]) 556 | 557 | dxy = np.diff(contour, axis=0) 558 | dt = np.sqrt((dxy ** 2).sum(axis=1)) 559 | t = np.concatenate([([0, ]), np.cumsum(dt)]) 560 | T = t[-1] 561 | 562 | diff = np.diff(t ** 2) 563 | xi = np.cumsum(dxy[:, 0]) - (dxy[:, 0] / dt) * t[1:] 564 | A0 = (1 / T) * np.sum(((dxy[:, 0] / (2 * dt)) * diff) + xi * dt) 565 | delta = np.cumsum(dxy[:, 1]) - (dxy[:, 1] / dt) * t[1:] 566 | C0 = (1 / T) * np.sum(((dxy[:, 1] / (2 * dt)) * diff) + delta * dt) 567 | 568 | # A0 and CO relate to the first point of the contour array as origin. 569 | # Adding those values to the coeffs to make them relate to true origin 570 | return (contour[0, 0] + A0, contour[0, 1] + C0) 571 | 572 | 573 | def LoadGeometries(filename): 574 | ''' 575 | Takes a filename and uses pyshp to load it, returning a list of 576 | shapefile.ShapeRecord instances. 577 | 578 | This list can be iterated over, passing the individual shape instances 579 | to ProcessGeometry() one by one. There is no input handling if a 580 | non-polygon shapefile is passed in, that will result in undefined behavior. 581 | 582 | Args: 583 | filename (string): A filename with optional full path pointing to an 584 | ESRI shapefile to be loaded by the pyshp module. The file extension 585 | is optional. 586 | 587 | Returns: 588 | list: A list of shapefile._ShapeRecord objects representing each 589 | polygon geometry in the shapefile. 590 | ''' 591 | shp = sf.Reader(filename) 592 | return shp.shapeRecords() 593 | 594 | 595 | def ProcessGeometry(shape): 596 | ''' 597 | Method to handle all the geometry processing that may be needed by the rest 598 | of the EFD code. 599 | 600 | Method which takes a single shape instance from a shapefile 601 | eg shp.Reader('shapefile.shp').shapeRecords()[n] 602 | where n is the index of the shape within a multipart geometry. This results 603 | in the contour, coordinate list and centroid data computed for the input 604 | polygon being normalized and returned to the user. 605 | 606 | Args: 607 | shapefile._ShapeRecord: A shapefile object representing the geometry 608 | and attributes of a single polygon from a multipart shapefile. 609 | 610 | Returns: 611 | tuple: A tuple containing a list of normalized x coordinates, a list of 612 | normalized y coordinates, contour (a list of [x,y] coordinate pairs, 613 | normalized about the shape's centroid) and the normalized coordinate 614 | centroid. 615 | ''' 616 | x = [] 617 | y = [] 618 | 619 | for point in shape.shape.points: 620 | x.append(point[0]) 621 | y.append(point[1]) 622 | 623 | centroid = ContourCentroid(x, y) 624 | 625 | return x, y, centroid 626 | 627 | 628 | def ProcessGeometryNorm(shape): 629 | ''' 630 | Method to handle all the geometry processing that may be needed by the rest 631 | of the EFD code. This method normalizes the input data to allow spatially 632 | distributed data to be plotted in the same cartesian space. 633 | 634 | Method which takes a single shape instance from a shapefile 635 | eg shp.Reader('shapefile.shp').shapeRecords()[n] 636 | where n is the index of the shape within a multipart geometry. This results 637 | in the contour, coordinate list and centroid data computed for the input 638 | polygon being normalized and returned to the user. 639 | 640 | Args: 641 | shapefile._ShapeRecord: A shapefile object representing the geometry 642 | and attributes of a single polygon from a multipart shapefile. 643 | 644 | Returns: 645 | tuple: A tuple containing a list of normalized x coordinates, a list of 646 | normalized y coordinates, contour (a list of [x,y] coordinate pairs, 647 | normalized about the shape's centroid) and the normalized coordinate 648 | centroid. 649 | ''' 650 | x = [] 651 | y = [] 652 | 653 | for point in shape.shape.points: 654 | x.append(point[0]) 655 | y.append(point[1]) 656 | 657 | centroid = ContourCentroid(x, y) 658 | X, Y, NormCentroid = NormContour(x, y, centroid) 659 | 660 | return X, Y, NormCentroid 661 | 662 | 663 | def generateShapefile(filename, prj=None): 664 | ''' 665 | Create an empty shapefile to write output into using writeGeometry(). 666 | 667 | Builds a multipart polygon shapefile with a single attribute, ID, which can 668 | be used to reference the written polygons. 669 | 670 | Args: 671 | filename (string): A complete path and filename, with or without the 672 | .shp extenion, to write the shapefile data to. Must be a path 673 | which exists. 674 | prj (string): A complete path and filename, with or without the 675 | .prj extenion, to the projection file from the shapefile that the 676 | data was loaded from initially, Used to copy the spatial projection 677 | information to the new file. 678 | 679 | Warning: 680 | Code does not test if output paths exist, and if files exist they will 681 | be overwritten. 682 | 683 | Returns: 684 | shapefile.Writer: An empty polygon shapefile instance ready to have 685 | data written to it. 686 | 687 | ''' 688 | shpinstance = sf.Writer(filename, sf.POLYGON) 689 | shpinstance.autoBalance = 1 690 | shpinstance.field('Poly_ID', 'N', '10') 691 | 692 | # create prj file 693 | if prj: 694 | # we have been passed a filename, check prj points to a *.prj file 695 | if path.isfile(prj): 696 | if path.splitext(prj)[-1].lower() == '.prj': 697 | # build the new filename 698 | newprj = '{0}.{1}'.format(path.splitext(filename)[:-1][0], 699 | 'prj') 700 | copy2(prj, newprj) 701 | else: 702 | warning = ('The file supplied ({0}) is not a prj file. ' 703 | 'No .prj file will be written').format(prj) 704 | warnings.warn(warning) 705 | else: 706 | warning = ('The .prj file supplied ({0}) does not exist. ' 707 | 'No .prj file will be written'.format(prj)) 708 | warnings.warn(warning) 709 | 710 | return shpinstance 711 | 712 | 713 | def writeGeometry(coeffs, x, y, harmonic, shpinstance, ID): 714 | ''' 715 | Write the results of inverse_transform() to a shapefile. 716 | 717 | Will only produce spatially meaningful data if the input coefficients have 718 | not been normalized. 719 | 720 | Args: 721 | coeffs (numpy.ndarray): A numpy array of shape (n, 4) representing the 722 | four coefficients for each harmonic computed. 723 | x (list): A list (or numpy array) of x coordinate values. 724 | y (list): A list (or numpy array) of y coordinate values. 725 | harmonic (int): The number of harmonics to be used to generate 726 | coordinates. Must be <= coeffs.shape[0]. Supply a smaller value to 727 | produce coordinates for a more generalized shape. 728 | shpinstance (shapefile.Writer): A multipart polygon shapefile to write 729 | the data to. 730 | ID (int): An integer ID value which will be written as an attribute 731 | alongside the geometry. 732 | 733 | Returns: 734 | shpinstance with the new geometry appended. 735 | 736 | ''' 737 | 738 | locus = calculate_dc_coefficients(x, y) 739 | xt, yt = inverse_transform(coeffs, locus=locus, harmonic=harmonic) 740 | 741 | contour = [(x_, y_) for x_, y_ in zip(xt, yt)] 742 | shpinstance.poly([contour]) 743 | shpinstance.record(ID, 'Poly_ID') 744 | 745 | return shpinstance 746 | 747 | 748 | def rotatePoint(point, centerPoint, angle): 749 | ''' 750 | Rotates a point counter-clockwise around centerPoint. 751 | 752 | The angle to rotate by is supplied in degrees. Code based on: 753 | https://gist.github.com/somada141/d81a05f172bb2df26a2c 754 | 755 | Args: 756 | point (tuple): The point to be rotated, represented as an (x,y) tuple. 757 | centerPoint (tuple): The point to be rotated about, represented as 758 | an (x,y) tuple. 759 | angle (float): The angle to rotate point by, in the counter-clockwise 760 | direction. 761 | 762 | Returns: 763 | tuple: A tuple representing the rotated point, (x,y). 764 | ''' 765 | angle = np.radians(angle) 766 | temp_point = point[0] - centerPoint[0], point[1] - centerPoint[1] 767 | temp_point = (temp_point[0] * np.cos(angle) - temp_point[1] * 768 | np.sin(angle), temp_point[0] * np.sin(angle) + 769 | temp_point[1] * np.cos(angle)) 770 | 771 | temp_point = temp_point[0] + centerPoint[0], temp_point[1] + centerPoint[1] 772 | return temp_point[0], temp_point[1] 773 | 774 | 775 | def getBBoxDimensions(x, y): 776 | ''' 777 | Returns the width in the x and y dimensions and the maximum x and y 778 | coordinates for the bounding box of a given list of x and y coordinates. 779 | 780 | Args: 781 | x (list): A list (or numpy array) of x coordinate values. 782 | y (list): A list (or numpy array) of y coordinate values. 783 | Returns: 784 | tuple: A four-tuple representing (width in the x direction, width in 785 | the y direction, the minimum x coordinate and the minimum y 786 | coordinate). 787 | ''' 788 | xmin = min(x) 789 | ymin = min(y) 790 | 791 | xmax = max(x) 792 | ymax = max(y) 793 | 794 | return xmax - xmin, ymax - ymin, xmin, ymin 795 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/test/__init__.py -------------------------------------------------------------------------------- /test/fixtures/example_data.dbf: -------------------------------------------------------------------------------- 1 | _A DNN 2 | 2705 2960 3048 3085 3444 3904 -------------------------------------------------------------------------------- /test/fixtures/example_data.prj: -------------------------------------------------------------------------------- 1 | PROJCS["WGS_1984_UTM_Zone_17N",GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",-81],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",0],UNIT["Meter",1]] -------------------------------------------------------------------------------- /test/fixtures/example_data.shp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/test/fixtures/example_data.shp -------------------------------------------------------------------------------- /test/fixtures/example_data.shx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgrieve/spatial_efd/55c790b37dbeb8b5c549941b6e6d6185185124e3/test/fixtures/example_data.shx -------------------------------------------------------------------------------- /test/fixtures/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "rotate_contour": { 3 | "x": [ 4 | 3.1698729810778, 5 | 11.830127018922, 6 | 6.8301270189222, 7 | -1.8301270189222 8 | ], 9 | "y": [ 10 | -1.8301270189222, 11 | 3.1698729810778, 12 | 11.830127018922, 13 | 6.8301270189222 14 | ] 15 | }, 16 | "process_geometry": { 17 | "c": [ 18 | 280621.2724339, 19 | 3882371.5613158 20 | ], 21 | "x": [ 22 | 280587, 23 | 280598, 24 | 280598, 25 | 280599, 26 | 280599, 27 | 280600, 28 | 280600, 29 | 280601, 30 | 280601, 31 | 280602 32 | ], 33 | "y": [ 34 | 3882424, 35 | 3882424, 36 | 3882423, 37 | 3882423, 38 | 3882422, 39 | 3882422, 40 | 3882421, 41 | 3882421, 42 | 3882420, 43 | 3882420 44 | ] 45 | }, 46 | "process_geometry_norm": { 47 | "c": [ 48 | 0.47291416526166, 49 | 0.2257062997114 50 | ], 51 | "x": [ 52 | 0.29533678756477, 53 | 0.35233160621762, 54 | 0.35233160621762, 55 | 0.35751295336788, 56 | 0.35751295336788, 57 | 0.36269430051813, 58 | 0.36269430051813, 59 | 0.36787564766839, 60 | 0.36787564766839, 61 | 0.37305699481865 62 | ], 63 | "y": [ 64 | 0.49740932642487, 65 | 0.49740932642487, 66 | 0.49222797927461, 67 | 0.49222797927461, 68 | 0.48704663212435, 69 | 0.48704663212435, 70 | 0.48186528497409, 71 | 0.48186528497409, 72 | 0.47668393782383, 73 | 0.47668393782383 74 | ] 75 | }, 76 | "inverse_transform": { 77 | "a": [ 78 | -0.32100770398698, 79 | -0.32018565808437, 80 | -0.3186928009143, 81 | -0.31654829158232, 82 | -0.3137794204069 83 | ], 84 | "b": [ 85 | 0.4174800975361, 86 | 0.41801422601177, 87 | 0.41777549283191, 88 | 0.41675438039352, 89 | 0.41495338852008 90 | ] 91 | }, 92 | "inverse_transform_locus": { 93 | "a": [ 94 | 0.17899229601302, 95 | 0.17981434191563, 96 | 0.1813071990857, 97 | 0.18345170841768, 98 | 0.1862205795931 99 | ], 100 | "b": [ 101 | 1.3174800975361, 102 | 1.3180142260118, 103 | 1.3177754928319, 104 | 1.3167543803935, 105 | 1.3149533885201 106 | ] 107 | }, 108 | "calculate_efd": { 109 | "coeffs": [ 110 | -0.00134937648, 111 | -0.000604478718, 112 | 0.0003257416778, 113 | 0.001951924972 114 | ] 115 | }, 116 | "average_coefficients": { 117 | "avg": [ 118 | 0.00049541617818, 119 | 0.00515338138093, 120 | -0.0005087032263, 121 | 9.7046992097e-5 122 | ] 123 | }, 124 | "average_sd": { 125 | "sd": [ 126 | 0.000381631249123, 127 | 0.00018247277186, 128 | 4.6821200993e-5, 129 | 9.3013816155e-5 130 | ] 131 | }, 132 | "normalize_efd": { 133 | "coeffs": [ 134 | -0.0043003776734823, 135 | 0.0088456130591875, 136 | -0.013450240117972, 137 | -0.0029657314108908 138 | ], 139 | "rotation": 14.5510829786 140 | }, 141 | "calculate_dc_coefficients": { 142 | "dc": [ 143 | 0.34071444143387, 144 | 0.56752000996605 145 | ] 146 | }, 147 | "rotate_point": { 148 | "rx": 0.628438653482, 149 | "ry": 3.20498121665 150 | }, 151 | "norm_contour": { 152 | "c": [ 153 | 0.5, 154 | 0.5 155 | ], 156 | "x": [ 157 | 0, 158 | 1, 159 | 1, 160 | 0 161 | ], 162 | "y": [ 163 | 0, 164 | 0, 165 | 1, 166 | 1 167 | ] 168 | }, 169 | "get_bbox_dimensions": { 170 | "xw": 10, 171 | "yw": 10, 172 | "xmin": 0, 173 | "ymin": 0 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /test/test_efd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import re 4 | import json 5 | import pytest 6 | import warnings 7 | import matplotlib 8 | import shapefile as shp 9 | import numpy.testing as ntest 10 | from spatial_efd import spatial_efd 11 | 12 | 13 | @pytest.fixture 14 | def expected(): 15 | filepath = os.path.realpath(os.path.join(os.getcwd(), 16 | os.path.dirname(__file__))) 17 | with open(os.path.join(filepath, 'fixtures/expected.json')) as f: 18 | return json.loads(f.read()) 19 | 20 | 21 | @pytest.fixture 22 | def open_square(): 23 | return [0, 10, 10, 0], [0, 0, 10, 10] 24 | 25 | 26 | @pytest.fixture 27 | def closed_square(): 28 | return [0, 10, 10, 0, 0], [0, 0, 10, 10, 0] 29 | 30 | 31 | @pytest.fixture 32 | def shp_paths(): 33 | filepath = os.path.realpath(os.path.join(os.getcwd(), 34 | os.path.dirname(__file__))) 35 | shppath = os.path.join(filepath, 'fixtures/example_data.shp') 36 | prjpath = os.path.join(filepath, 'fixtures/example_data.prj') 37 | return shppath, prjpath 38 | 39 | 40 | @pytest.fixture 41 | def example_shp(shp_paths): 42 | return spatial_efd.LoadGeometries(shp_paths[0]) 43 | 44 | 45 | @pytest.fixture 46 | def warn_wrong_prj(): 47 | return 'The file supplied is not a prj file. No .prj file will be written' 48 | 49 | 50 | @pytest.fixture 51 | def warn_missing_prj(): 52 | return ('The .prj file supplied does not exist.' 53 | ' No .prj file will be written') 54 | 55 | 56 | def clean_warning(message): 57 | ''' 58 | Helper function to format warning messages so they can be used for tests 59 | ''' 60 | return re.sub(r'\(.*?\)\s', '', str(message)) 61 | 62 | 63 | class TestEFD(): 64 | def test_area_open(self, open_square): 65 | area = spatial_efd.ContourArea(*open_square) 66 | assert area == 100 67 | 68 | def test_area_closed(self, closed_square): 69 | area = spatial_efd.ContourArea(*closed_square) 70 | assert area == 100 71 | 72 | def test_centroid_open(self, open_square): 73 | centre = spatial_efd.ContourCentroid(*open_square) 74 | assert centre == (5, 5) 75 | 76 | def test_centroid_open(self, closed_square): 77 | centre = spatial_efd.ContourCentroid(*closed_square) 78 | assert centre == (5, 5) 79 | 80 | def test_close_contour_open(self, open_square): 81 | X, Y = spatial_efd.CloseContour(*open_square) 82 | assert X[0] == X[-1] 83 | assert Y[0] == Y[-1] 84 | 85 | def test_close_contour_closed(self, closed_square): 86 | X, Y = spatial_efd.CloseContour(*closed_square) 87 | assert X[0] == X[-1] 88 | assert Y[0] == Y[-1] 89 | 90 | def test_nyquist(self, closed_square): 91 | n = spatial_efd.Nyquist(closed_square[0]) 92 | assert n == 2 93 | 94 | def test_plot_init(self): 95 | a = spatial_efd.InitPlot() 96 | assert isinstance(a, matplotlib.axes.Axes) 97 | 98 | def test_rotate_contour(self, open_square, expected): 99 | x, y = spatial_efd.RotateContour(*open_square, rotation=30., 100 | centroid=(5, 5)) 101 | 102 | ntest.assert_almost_equal(x, expected['rotate_contour']['x']) 103 | ntest.assert_almost_equal(y, expected['rotate_contour']['y']) 104 | 105 | def test_rotate_point(self, expected): 106 | rx, ry = spatial_efd.rotatePoint((3., 2.), (1., 1.), 73.) 107 | assert pytest.approx(rx) == expected['rotate_point']['rx'] 108 | assert pytest.approx(ry) == expected['rotate_point']['ry'] 109 | 110 | def test_norm_contour(self, open_square, expected): 111 | x, y, c = spatial_efd.NormContour(*open_square, rawCentroid=(5., 5.)) 112 | assert pytest.approx(c) == expected['norm_contour']['c'] 113 | assert pytest.approx(x) == expected['norm_contour']['x'] 114 | assert pytest.approx(y) == expected['norm_contour']['y'] 115 | 116 | def test_get_bbox_dimensions(self, open_square, expected): 117 | xw, yw, xmin, ymin = spatial_efd.getBBoxDimensions(*open_square) 118 | assert xw == expected['get_bbox_dimensions']['xw'] 119 | assert yw == expected['get_bbox_dimensions']['yw'] 120 | assert xmin == expected['get_bbox_dimensions']['xmin'] 121 | assert ymin == expected['get_bbox_dimensions']['ymin'] 122 | 123 | def test_load_geometry(self, example_shp): 124 | assert isinstance(example_shp[0], shp.ShapeRecord) 125 | 126 | def test_process_geometry(self, example_shp, expected): 127 | x, y, c = spatial_efd.ProcessGeometry(example_shp[1]) 128 | ntest.assert_almost_equal(c, expected['process_geometry']['c']) 129 | ntest.assert_almost_equal(x[:10], expected['process_geometry']['x']) 130 | ntest.assert_almost_equal(y[:10], expected['process_geometry']['y']) 131 | 132 | def test_process_geometry_norm(self, example_shp, expected): 133 | x, y, c = spatial_efd.ProcessGeometryNorm(example_shp[1]) 134 | ntest.assert_almost_equal(c, expected['process_geometry_norm']['c']) 135 | ntest.assert_almost_equal(x[:10], 136 | expected['process_geometry_norm']['x']) 137 | ntest.assert_almost_equal(y[:10], 138 | expected['process_geometry_norm']['y']) 139 | 140 | def test_calculate_efd(self, example_shp, expected): 141 | x, y, _ = spatial_efd.ProcessGeometryNorm(example_shp[2]) 142 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 143 | ntest.assert_almost_equal(coeffs[6], 144 | expected['calculate_efd']['coeffs']) 145 | 146 | def test_inverse_transform(self, example_shp, expected): 147 | x, y, _ = spatial_efd.ProcessGeometryNorm(example_shp[2]) 148 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 149 | a, b = spatial_efd.inverse_transform(coeffs) 150 | 151 | ntest.assert_almost_equal(a[:5], expected['inverse_transform']['a']) 152 | ntest.assert_almost_equal(b[:5], expected['inverse_transform']['b']) 153 | 154 | def test_inverse_transform_locus(self, example_shp, expected): 155 | x, y, _ = spatial_efd.ProcessGeometryNorm(example_shp[2]) 156 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 157 | a, b = spatial_efd.inverse_transform(coeffs, locus=(0.5, 0.9)) 158 | 159 | ntest.assert_almost_equal(a[:5], 160 | expected['inverse_transform_locus']['a']) 161 | ntest.assert_almost_equal(b[:5], 162 | expected['inverse_transform_locus']['b']) 163 | 164 | def test_average_coefficients(self, example_shp, expected): 165 | coeffsList = [] 166 | 167 | for i in range(3): 168 | x, y, _ = spatial_efd.ProcessGeometryNorm(example_shp[i]) 169 | coeffsList.append(spatial_efd.CalculateEFD(x, y, 10)) 170 | 171 | avg = spatial_efd.AverageCoefficients(coeffsList) 172 | ntest.assert_almost_equal(avg[6], 173 | expected['average_coefficients']['avg']) 174 | 175 | def test_average_sd(self, example_shp, expected): 176 | coeffsList = [] 177 | 178 | for i in range(3): 179 | x, y, _ = spatial_efd.ProcessGeometryNorm(example_shp[i]) 180 | coeffsList.append(spatial_efd.CalculateEFD(x, y, 10)) 181 | 182 | avg = spatial_efd.AverageCoefficients(coeffsList) 183 | sd = spatial_efd.AverageSD(coeffsList, avg) 184 | ntest.assert_almost_equal(sd[3], expected['average_sd']['sd']) 185 | 186 | def test_fourier_power(self, example_shp): 187 | x, y, _ = spatial_efd.ProcessGeometryNorm(example_shp[2]) 188 | coeffs = spatial_efd.CalculateEFD(x, y, 500) 189 | n = spatial_efd.FourierPower(coeffs, x) 190 | assert n == 19 191 | 192 | def test_normalize_efd(self, example_shp, expected): 193 | x, y, _ = spatial_efd.ProcessGeometryNorm(example_shp[0]) 194 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 195 | coeffs, rotation = spatial_efd.normalize_efd(coeffs) 196 | 197 | ntest.assert_almost_equal(coeffs[9], 198 | expected['normalize_efd']['coeffs']) 199 | assert pytest.approx(rotation) == expected['normalize_efd']['rotation'] 200 | 201 | def test_calculate_dc_coefficients(self, example_shp, expected): 202 | x, y, _ = spatial_efd.ProcessGeometryNorm(example_shp[2]) 203 | dc = spatial_efd.calculate_dc_coefficients(x, y) 204 | assert pytest.approx(dc) == expected['calculate_dc_coefficients']['dc'] 205 | 206 | def test_plotting_savefig(self, example_shp, tmpdir): 207 | matplotlib.pyplot.clf() 208 | x, y, _ = spatial_efd.ProcessGeometryNorm(example_shp[2]) 209 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 210 | a, b = spatial_efd.inverse_transform(coeffs) 211 | ax = spatial_efd.InitPlot() 212 | spatial_efd.PlotEllipse(ax, a, b, color='k', width=1.) 213 | spatial_efd.SavePlot(ax, 5, tmpdir, 'png') 214 | assert os.path.isfile('{0}_5.png'.format(tmpdir)) 215 | 216 | def test_plot_comparison(self, example_shp, tmpdir): 217 | matplotlib.pyplot.clf() 218 | x, y, _ = spatial_efd.ProcessGeometry(example_shp[0]) 219 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 220 | ax = spatial_efd.InitPlot() 221 | spatial_efd.plotComparison(ax, coeffs, 10, x, y) 222 | spatial_efd.SavePlot(ax, 10, tmpdir, 'png') 223 | assert os.path.isfile('{0}_10.png'.format(tmpdir)) 224 | 225 | def test_plot_comparison_norm(self, example_shp, tmpdir): 226 | x, y, _ = spatial_efd.ProcessGeometry(example_shp[1]) 227 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 228 | coeffs, rotation = spatial_efd.normalize_efd(coeffs, 229 | size_invariant=False) 230 | ax = spatial_efd.InitPlot() 231 | spatial_efd.plotComparison(ax, coeffs, 7, x, y, rotation=rotation) 232 | spatial_efd.SavePlot(ax, 7, tmpdir, 'png') 233 | assert os.path.isfile('{0}_7.png'.format(tmpdir)) 234 | 235 | def test_plot_comparison_norm_size_invariant(self, example_shp, tmpdir): 236 | matplotlib.pyplot.clf() 237 | x, y, _ = spatial_efd.ProcessGeometry(example_shp[1]) 238 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 239 | coeffs, rotation = spatial_efd.normalize_efd(coeffs, 240 | size_invariant=True) 241 | ax = spatial_efd.InitPlot() 242 | spatial_efd.plotComparison(ax, coeffs, 7, x, y, rotation=rotation) 243 | spatial_efd.SavePlot(ax, 8, tmpdir, 'png') 244 | assert os.path.isfile('{0}_8.png'.format(tmpdir)) 245 | 246 | def test_generate_shapefile(self, tmpdir): 247 | shape = spatial_efd.generateShapefile(tmpdir.strpath) 248 | assert isinstance(shape, shp.Writer) 249 | 250 | def test_write_geometry(self, example_shp, tmpdir): 251 | x, y, _ = spatial_efd.ProcessGeometry(example_shp[1]) 252 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 253 | shape = spatial_efd.generateShapefile(tmpdir.strpath) 254 | shape = spatial_efd.writeGeometry(coeffs, x, y, 4, shape, 1) 255 | assert os.path.isfile('{}.shp'.format(tmpdir)) 256 | 257 | def test_write_geometry_prj(self, example_shp, tmpdir, shp_paths): 258 | x, y, _ = spatial_efd.ProcessGeometry(example_shp[1]) 259 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 260 | shape = spatial_efd.generateShapefile(tmpdir.strpath, prj=shp_paths[1]) 261 | shape = spatial_efd.writeGeometry(coeffs, x, y, 4, shape, 1) 262 | assert os.path.isfile('{}.shp'.format(tmpdir)) 263 | assert os.path.isfile('{}.prj'.format(tmpdir)) 264 | 265 | def test_write_missing_prj(self, example_shp, tmpdir, shp_paths, 266 | warn_missing_prj): 267 | x, y, _ = spatial_efd.ProcessGeometry(example_shp[1]) 268 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 269 | 270 | with warnings.catch_warnings(record=True) as w: 271 | spatial_efd.generateShapefile(tmpdir.strpath, prj='missing.prj') 272 | 273 | assert os.path.isfile('{0}.shp'.format(tmpdir)) 274 | assert not os.path.isfile('{0}.prj'.format(tmpdir)) 275 | assert len(w) == 1 276 | assert issubclass(w[0].category, UserWarning) 277 | assert clean_warning(w[0].message) == warn_missing_prj 278 | 279 | def test_write_prj_wrong(self, example_shp, tmpdir, shp_paths, 280 | warn_wrong_prj): 281 | x, y, _ = spatial_efd.ProcessGeometry(example_shp[1]) 282 | coeffs = spatial_efd.CalculateEFD(x, y, 10) 283 | 284 | with warnings.catch_warnings(record=True) as w: 285 | spatial_efd.generateShapefile(tmpdir.strpath, prj=shp_paths[0]) 286 | 287 | assert os.path.isfile('{0}.shp'.format(tmpdir)) 288 | assert not os.path.isfile('{0}.prj'.format(tmpdir)) 289 | assert len(w) == 1 290 | assert issubclass(w[0].category, UserWarning) 291 | assert clean_warning(w[0].message) == warn_wrong_prj 292 | --------------------------------------------------------------------------------