├── tests ├── __init__.py ├── test_vw.py └── data │ └── sample.json ├── setup.py ├── pyproject.toml ├── Makefile ├── .gitignore ├── .github └── workflows │ ├── test.yml │ └── publish.yml ├── .travis.yml ├── src └── visvalingamwyatt │ ├── __init__.py │ ├── __main__.py │ └── visvalingamwyatt.py ├── LICENSE.txt ├── setup.cfg └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This file is part of visvalingamwyatt. 4 | # https://github.com/fitnr/visvalingamwyatt 5 | 6 | # Licensed under the MIT license: 7 | # http://www.opensource.org/licenses/MIT-license 8 | # Copyright (c) 2015, fitnr 9 | 10 | from . import test_vw 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This file is part of visvalingamwyatt. 4 | # https://github.com/fitnr/visvalingamwyatt 5 | 6 | # Licensed under the MIT license: 7 | # http://www.opensource.org/licenses/MIT-license 8 | # Copyright (c) 2015, fitnr 9 | 10 | from setuptools import setup 11 | 12 | setup() 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # This file is part of visvalingamwyatt. 2 | # https://github.com/fitnr/visvalingamwyatt 3 | [build-system] 4 | requires = ["setuptools>=60.5.0", "wheel"] 5 | build-backend = 'setuptools.build_meta' 6 | 7 | [tool.black] 8 | max-line-length = 100 9 | skip-string-normalization = true 10 | target-version = [ 11 | "py39", 12 | "py310", 13 | "py311", 14 | "py312", 15 | ] 16 | 17 | [tool.pylint."MESSAGES CONTROL"] 18 | disable = ["C0103"] 19 | max-line-length = 100 20 | 21 | [tool.isort] 22 | profile = "black" 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # This file is part of visvalingamwyatt. 2 | # https://github.com/fitnr/visvalingamwyatt 3 | 4 | # Licensed under the MIT license: 5 | # http://www.opensource.org/licenses/MIT-license 6 | # Copyright (c) 2015, 2021, fitnr 7 | 8 | .PHONY: install test publish build clean cov 9 | 10 | install: 11 | python setup.py install 12 | 13 | cov: 14 | -coverage run --branch --source visvalingamwyatt -m unittest 15 | coverage report 16 | 17 | test: 18 | python -m unittest 19 | 20 | publish: build 21 | twine upload dist/* 22 | 23 | build: | clean 24 | python -m build 25 | 26 | clean:; rm -rf dist build 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is part of visvalingamwyatt. 2 | # https://github.com/fitnr/visvalingamwyatt 3 | 4 | # Licensed under the MIT license: 5 | # http://www.opensource.org/licenses/MIT-license 6 | # Copyright (c) 2015, fitnr 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | bin/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | eggs/ 20 | .eggs 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | eggs 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | README.rst 44 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This file is part of visvalingamwyatt. 2 | # https://github.com/fitnr/visvalingamwyatt 3 | name: Test package 4 | 5 | on: 6 | push: 7 | pull_request: 8 | branches: [master] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python: ["3.10", "3.11", "3.12"] 16 | 17 | steps: 18 | - uses: actions/checkout@v4.2.2 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python }} 24 | cache: 'pip' 25 | cache-dependency-path: setup.cfg 26 | 27 | - name: Install package 28 | run: | 29 | python -m pip install -U pip 30 | pip install -e '.[tests]' 31 | 32 | - run: make cov 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # This file is part of visvalingamwyatt. 2 | # https://github.com/fitnr/visvalingamwyatt 3 | 4 | # Licensed under the MIT license: 5 | # http://www.opensource.org/licenses/MIT-license 6 | # Copyright (c) 2015, fitnr 7 | 8 | language: python 9 | 10 | python: 11 | - 3.7 12 | - 3.8 13 | - 3.9 14 | 15 | matrix: 16 | fast_finish: true 17 | allow_failures: 18 | - python: 2.7 19 | 20 | install: 21 | - pip install docutils coverage 22 | - make install 23 | 24 | script: 25 | - python setup.py test 26 | - vwsimplify --help 27 | - vwsimplify --threshold 0.001 tests/data/sample.json > /dev/null 28 | - vwsimplify --number 10 tests/data/sample.json > /dev/null 29 | - vwsimplify --ratio 0.90 tests/data/sample.json > /dev/null 30 | 31 | after_script: 32 | - make cov 33 | -------------------------------------------------------------------------------- /src/visvalingamwyatt/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This file is part of visvalingamwyatt. 3 | # https://github.com/fitnr/visvalingamwyatt 4 | # Licensed under the MIT license: 5 | # http://www.opensource.org/licenses/MIT-license 6 | # Copyright (c) 2015, fitnr 7 | ''' 8 | Visvalingam-Whyatt method of poly-line vertex reduction 9 | 10 | Visvalingam, M and Whyatt J D (1993) 11 | "Line Generalisation by Repeated Elimination of Points", Cartographic J., 30 (1), 46 - 51 12 | 13 | Described here: 14 | http://web.archive.org/web/20100428020453/http://www2.dcs.hull.ac.uk/CISRG/publications/DPs/DP10/DP10.html 15 | 16 | source: https://github.com/Permafacture/Py-Visvalingam-Whyatt/ 17 | ''' 18 | from . import visvalingamwyatt 19 | from .visvalingamwyatt import Simplifier, simplify, simplify_feature, simplify_geometry 20 | 21 | __version__ = '0.3.0' 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This file is part of visvalingamwyatt. 2 | # https://github.com/fitnr/visvalingamwyatt 3 | name: Publish to PyPi 4 | 5 | on: 6 | release: 7 | types: [created, workflow_dispatch] 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4.2.2 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: "3.11" 19 | cache: 'pip' 20 | cache-dependency-path: setup.cfg 21 | 22 | - name: Install build requirements 23 | run: | 24 | python -m pip install -U pip 25 | pip install build 26 | 27 | - run: make build 28 | 29 | - name: Publish package 30 | uses: pypa/gh-action-pypi-publish@release/v1 31 | with: 32 | user: __token__ 33 | password: ${{ secrets.PYPI_API_TOKEN }} 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2014 Elliot Hallmark 3 | Copyright (c) 2015 Neil Freeman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = visvalingamwyatt 3 | version = attr: visvalingamwyatt.__version__ 4 | description = Simplify geometries with the Visvalingam-Wyatt algorithm 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | author = fitnr 8 | author_email = contact@fakeisthenewreal.org 9 | url = https://github.com/fitnr/visvalingamwyatt 10 | license = MIT 11 | classifiers = 12 | Development Status :: 4 - Beta 13 | Intended Audience :: Developers 14 | License :: OSI Approved :: MIT License 15 | Programming Language :: Python :: 3.7 16 | Programming Language :: Python :: 3.8 17 | Programming Language :: Python :: 3.9 18 | Programming Language :: Python :: 3.10 19 | Programming Language :: Python :: 3.11 20 | Programming Language :: Python :: 3.12 21 | Programming Language :: Python :: Implementation :: PyPy 22 | Operating System :: OS Independent 23 | 24 | [options] 25 | zip_safe = True 26 | packages = find: 27 | package_dir = 28 | =src 29 | install_requires = 30 | numpy>=1.8 31 | 32 | [options.extras_require] 33 | tests = 34 | coverage 35 | 36 | [options.entry_points] 37 | console_scripts = 38 | vwsimplify=visvalingamwyatt.__main__:main 39 | 40 | [options.packages.find] 41 | where = src 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Visvalingam-Wyatt 2 | 3 | A Python implementation of the Visvalingam-Wyatt line simplification algorithm. 4 | 5 | This implementation is due to [Eliot Hallmark](https://github.com/Permafacture/Py-Visvalingam-Whyatt/). This release simply packages it as a Python module. 6 | 7 | ## Use 8 | 9 | ```python 10 | >>> import visvalingamwyatt as vw 11 | >>> points = [(1, 2), (2, 3), (3, 4), ...] 12 | >>> vw.simplify(points) 13 | [(1, 2), (3, 4), ...] 14 | ``` 15 | 16 | Points may be any `Sequence`-like object that (`list`, `tuple`, a custom class that exposes an `__iter__` method). 17 | 18 | Test different methods and thresholds: 19 | ```python 20 | simplifier = vw.Simplifier(points) 21 | 22 | # Simplify by percentage of points to keep 23 | simplifier.simplify(ratio=0.5) 24 | 25 | # Simplify by giving number of points to keep 26 | simplifier.simplify(number=1000) 27 | 28 | # Simplify by giving an area threshold (in the units of the data) 29 | simplifier.simplify(threshold=0.01) 30 | ``` 31 | 32 | Shorthands for working with geodata: 33 | 34 | ````python 35 | import visvalingamwyatt as vw 36 | 37 | feature = { 38 | "properties": {"foo": "bar"}, 39 | "geometry": { 40 | "type": "Polygon", 41 | "coordinates": [...] 42 | } 43 | } 44 | 45 | # returns a copy of the geometry, simplified (keeping 90% of points) 46 | vw.simplify_geometry(feature['geometry'], ratio=0.90) 47 | 48 | # returns a copy of the feature, simplified (using an area threshold) 49 | vw.simplify_feature(feature, threshold=0.90) 50 | ```` 51 | 52 | The command line tool `vwsimplify` is available to simplify GeoJSON files: 53 | 54 | ```` 55 | # Simplify using a ratio of points 56 | vwsimplify --ratio 0.90 in.geojson -o simple.geojson 57 | 58 | # Simplify using the number of points to keep 59 | vwsimplify --number 1000 in.geojson -o simple.geojson 60 | 61 | # Simplify using a minimum area 62 | vwsimplify --threshold 0.001 in.geojson -o simple.geojson 63 | ```` 64 | 65 | Install [Fiona](https://github.com/Toblerity/Fiona) for the additional ability to simplify any geodata layer. 66 | 67 | ## License 68 | 69 | MIT 70 | -------------------------------------------------------------------------------- /src/visvalingamwyatt/__main__.py: -------------------------------------------------------------------------------- 1 | # This file is part of visvalingamwyatt. 2 | # https://github.com/fitnr/visvalingamwyatt 3 | 4 | # Licensed under the MIT license: 5 | # http://www.opensource.org/licenses/MIT-license 6 | # Copyright (c) 2015, 2017, fitnr 7 | 8 | import argparse 9 | import json 10 | 11 | from .visvalingamwyatt import simplify_feature 12 | 13 | try: 14 | import fiona 15 | 16 | inputhelp = 'geodata file' 17 | 18 | def simplify(inp, output, **kwargs): 19 | if output == '/dev/stdout': 20 | output = '/vsistdout' 21 | 22 | if inp == '/dev/stdin': 23 | output = '/vsistdin' 24 | 25 | with fiona.drivers(): 26 | with fiona.open(inp, 'r') as src: 27 | with fiona.open( 28 | output, 'w', schema=src.schema, driver=src.driver, crs=src.crs 29 | ) as sink: 30 | for f in src: 31 | sink.write(simplify_feature(f, **kwargs)) 32 | 33 | except ImportError: 34 | inputhelp = 'geojson file' 35 | 36 | def simplify(inp, output, **kwargs): 37 | with open(inp, 'r') as f: 38 | geojson = json.load(f) 39 | 40 | with open(output, 'w') as g: 41 | geojson['features'] = [ 42 | simplify_feature(f, **kwargs) for f in geojson['features'] 43 | ] 44 | json.dump(geojson, g) 45 | 46 | 47 | def main(): 48 | parser = argparse.ArgumentParser( 49 | 'simplify', 50 | description='Simplify geospatial data using with the Visvalingam-Wyatt algorithm', 51 | ) 52 | parser.add_argument('input', default='/dev/stdin', help=inputhelp) 53 | 54 | group = parser.add_mutually_exclusive_group() 55 | group.add_argument( 56 | '-t', '--threshold', type=float, metavar='float', help='minimum area' 57 | ) 58 | group.add_argument( 59 | '-n', '--number', type=int, metavar='int', help='number of points to keep' 60 | ) 61 | group.add_argument( 62 | '-r', 63 | '--ratio', 64 | type=float, 65 | metavar='float', 66 | help='fraction of points to keep (default: 0.90)', 67 | ) 68 | 69 | parser.add_argument('-o', '--output', metavar='file', default='/dev/stdout') 70 | 71 | args = parser.parse_args() 72 | 73 | if args.input == '-': 74 | args.input = '/dev/stdin' 75 | 76 | if args.output == '-': 77 | args.output = '/dev/stdout' 78 | 79 | kwargs = {} 80 | 81 | if args.number: 82 | kwargs['number'] = args.number 83 | 84 | elif args.threshold: 85 | kwargs['threshold'] = args.threshold 86 | 87 | else: 88 | kwargs['ratio'] = args.ratio or 0.90 89 | 90 | simplify(args.input, args.output, **kwargs) 91 | 92 | 93 | if __name__ == '__main__': 94 | main() 95 | -------------------------------------------------------------------------------- /tests/test_vw.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This file is part of visvalingamwyatt. 3 | # https://github.com/fitnr/visvalingamwyatt 4 | # Licensed under the MIT license: 5 | # http://www.opensource.org/licenses/MIT-license 6 | # Copyright (c) 2015, fitnr 7 | """visvalingamwyatt module tests""" 8 | import json 9 | import os 10 | import unittest 11 | from collections import namedtuple 12 | 13 | import numpy as np 14 | 15 | import visvalingamwyatt as vw 16 | from visvalingamwyatt import __main__ as cli 17 | 18 | 19 | class TestVW(unittest.TestCase): 20 | def setUp(self): 21 | self.samplefile = os.path.join(os.path.dirname(__file__), 'data', 'sample.json') 22 | with open(self.samplefile) as f: 23 | self.fixture = json.load(f).get('features')[0] 24 | 25 | def standard(self, **kwargs): 26 | result = vw.simplify_feature(self.fixture, **kwargs) 27 | 28 | self.assertIn('geometry', result) 29 | self.assertIn('properties', result) 30 | self.assertEqual(result['properties'], self.fixture['properties']) 31 | self.assertEqual(self.fixture['geometry']['type'], result['geometry']['type']) 32 | self.assertEqual( 33 | self.fixture['geometry']['coordinates'][0], 34 | result['geometry']['coordinates'][0], 35 | ) 36 | 37 | self.assertGreater( 38 | len(self.fixture['geometry']['coordinates']), 39 | len(result['geometry']['coordinates']), 40 | ) 41 | 42 | return result 43 | 44 | def testSimplifyFeature(self): 45 | self.standard() 46 | 47 | def testSimplifyFeatureThreshold(self): 48 | self.standard(threshold=0.1) 49 | 50 | def testSimplifyFeatureRatio(self): 51 | result = self.standard(ratio=0.1) 52 | 53 | b = vw.simplify_feature(self.fixture, ratio=0.90) 54 | assert len(b['geometry']['coordinates']) > len( 55 | result['geometry']['coordinates'] 56 | ) 57 | for i, j in zip(range(1, 9), range(2, 10)): 58 | r = vw.simplify_feature(self.fixture, ratio=float(i) / 10) 59 | s = vw.simplify_feature(self.fixture, ratio=float(j) / 10) 60 | assert len(r['geometry']['coordinates']) <= len( 61 | s['geometry']['coordinates'] 62 | ) 63 | 64 | def testSimplifyFeatureNumber(self): 65 | result = self.standard(number=10) 66 | self.assertEqual(len(result['geometry']['coordinates']), 10) 67 | 68 | def test3dCoords(self): 69 | coordinates = [ 70 | [0.0, 0.0, 0.0], 71 | [1.1, 0, 1], 72 | [2.1, 3, 0], 73 | [4.1, 5, 10], 74 | [1.1, 2, 0], 75 | [5.1, 2, 0], 76 | ] 77 | a = vw.simplify(coordinates) 78 | self.assertEqual(a[0], [0, 0, 0]) 79 | self.assertLessEqual(len(a), len(coordinates)) 80 | 81 | def testSimplifyTupleLike(self): 82 | Point = namedtuple("Point", ("x", "y")) 83 | 84 | # coordinates are in the shape 85 | # 86 | # c 87 | # b d 88 | # a e 89 | # 90 | # so b and d are eliminated 91 | a, b, c, d, e = Point(0, 0), Point(1, 1), Point(2, 2), Point(3, 1), Point(4, 0) 92 | inp = [a, b, c, d, e] 93 | expected_output = np.array([a, c, e]) 94 | 95 | actual_output = vw.simplify(inp, threshold=0.001) 96 | self.assertTrue(np.array_equal(actual_output, expected_output)) 97 | 98 | def testSimplifyIntegerCoords(self): 99 | # coordinates are in the shape 100 | # 101 | # c 102 | # b d 103 | # a e 104 | # 105 | # so b and d are eliminated 106 | a, b, c, d, e = (0, 0), (1, 1), (2, 2), (3, 1), (4, 0) 107 | inp = [a, b, c, d, e] 108 | expected_output = np.array([a, c, e]) 109 | 110 | actual_output = vw.simplify(inp, threshold=0.001) 111 | self.assertTrue(np.array_equal(actual_output, expected_output)) 112 | 113 | def testSimplifyClosedFeature(self): 114 | '''When simplifying geometries with closed rings (Polygons and MultiPolygons), 115 | the first and last points in each ring should remain the same''' 116 | test_ring = [ 117 | [121.20803833007811, 24.75431413309125], 118 | [121.1846923828125, 24.746831298412058], 119 | [121.1517333984375, 24.74059525872194], 120 | [121.14486694335936, 24.729369599118222], 121 | [121.12152099609375, 24.693191139677126], 122 | [121.13525390625, 24.66449040712424], 123 | [121.10504150390625, 24.66449040712424], 124 | [121.10092163085936, 24.645768980151793], 125 | [121.0748291015625, 24.615808859044243], 126 | [121.09405517578125, 24.577099744289427], 127 | [121.12564086914062, 24.533381526147682], 128 | [121.14624023437499, 24.515889973088104], 129 | [121.19018554687499, 24.528384188171866], 130 | [121.19430541992186, 24.57959746772822], 131 | [121.23687744140624, 24.587090339209634], 132 | [121.24099731445311, 24.552119771544227], 133 | [121.2451171875, 24.525885444592642], 134 | [121.30279541015624, 24.55087064225044], 135 | [121.27258300781251, 24.58958786341259], 136 | [121.26708984374999, 24.623299562653035], 137 | [121.32614135742188, 24.62579636412304], 138 | [121.34674072265624, 24.602074737077242], 139 | [121.36871337890625, 24.580846310771612], 140 | [121.40853881835936, 24.653257887871963], 141 | [121.40853881835936, 24.724380091871726], 142 | [121.37283325195312, 24.716895455859337], 143 | [121.3604736328125, 24.693191139677126], 144 | [121.343994140625, 24.69942955501979], 145 | [121.32888793945312, 24.728122241065808], 146 | [121.3714599609375, 24.743089712134605], 147 | [121.37695312499999, 24.77177232822881], 148 | [121.35635375976562, 24.792968265314457], 149 | [121.32476806640625, 24.807927923059236], 150 | [121.29730224609375, 24.844072974931866], 151 | [121.24923706054688, 24.849057671305268], 152 | [121.24786376953125, 24.816653556469955], 153 | [121.27944946289062, 24.79047481357294], 154 | [121.30142211914061, 24.761796517185815], 155 | [121.27258300781251, 24.73311159823193], 156 | [121.25335693359374, 24.708162811665265], 157 | [121.20391845703125, 24.703172454280217], 158 | [121.19979858398438, 24.731864277701714], 159 | [121.20803833007811, 24.75431413309125], 160 | ] 161 | multipolygon = {"type": "MultiPolygon", "coordinates": [[test_ring]]} 162 | number = vw.simplify_geometry(multipolygon, number=10) 163 | self.assertEqual( 164 | number['coordinates'][0][0][0], number['coordinates'][0][0][-1] 165 | ) 166 | 167 | ratio = vw.simplify_geometry(multipolygon, ratio=0.3) 168 | self.assertEqual(ratio['coordinates'][0][0][0], ratio['coordinates'][0][0][-1]) 169 | 170 | thres = vw.simplify_geometry(multipolygon, threshold=0.01) 171 | self.assertEqual(thres['coordinates'][0][0][0], thres['coordinates'][0][0][-1]) 172 | 173 | number = vw.simplify_geometry(multipolygon, number=10) 174 | self.assertEqual( 175 | number['coordinates'][0][0][0], number['coordinates'][0][0][-1] 176 | ) 177 | 178 | ratio = vw.simplify_geometry(multipolygon, ratio=0.3) 179 | self.assertEqual(ratio['coordinates'][0][0][0], ratio['coordinates'][0][0][-1]) 180 | 181 | thres = vw.simplify_geometry(multipolygon, threshold=0.01) 182 | self.assertEqual(thres['coordinates'][0][0][0], thres['coordinates'][0][0][-1]) 183 | 184 | def testCli(self): 185 | pass 186 | 187 | def testSimplify(self): 188 | '''Use the command-line function to simplify the sample data.''' 189 | try: 190 | output = 'tmp.json' 191 | cli.simplify(self.samplefile, output, number=9) 192 | 193 | self.assertTrue(os.path.exists(output)) 194 | 195 | with open('tmp.json', 'r') as f: 196 | result = json.load(f) 197 | coords = result['features'][0]['geometry']['coordinates'] 198 | self.assertEqual(len(coords), 9) 199 | 200 | finally: 201 | os.remove(output) 202 | 203 | if __name__ == '__main__': 204 | unittest.main() 205 | -------------------------------------------------------------------------------- /src/visvalingamwyatt/visvalingamwyatt.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | # Copyright (c) 2014 Elliot Hallmark 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 | ''' 23 | Visvalingam-Whyatt method of poly-line vertex reduction 24 | 25 | Visvalingam, M and Whyatt J D (1993) 26 | "Line Generalisation by Repeated Elimination of Points", Cartographic J., 30 (1), 46 - 51 27 | 28 | Described here: 29 | http://web.archive.org/web/20100428020453/http://www2.dcs.hull.ac.uk/CISRG/publications/DPs/DP10/DP10.html 30 | 31 | source: https://github.com/Permafacture/Py-Visvalingam-Whyatt/ 32 | ''' 33 | import numpy as np 34 | 35 | 36 | def triangle_area(p1, p2, p3): 37 | """ 38 | calculates the area of a triangle given its vertices 39 | """ 40 | return ( 41 | abs(p1[0] * (p2[1] - p3[1]) + p2[0] * (p3[1] - p1[1]) + p3[0] * (p1[1] - p2[1])) 42 | / 2.0 43 | ) 44 | 45 | 46 | def triangle_areas_from_array(arr): 47 | ''' 48 | take an (N,2) array of points and return an (N,1) 49 | array of the areas of those triangles, where the first 50 | and last areas are np.inf 51 | 52 | see triangle_area for algorithm 53 | ''' 54 | 55 | result = np.empty((len(arr),), arr.dtype) 56 | result[0] = np.inf 57 | result[-1] = np.inf 58 | 59 | p1 = arr[:-2] 60 | p2 = arr[1:-1] 61 | p3 = arr[2:] 62 | 63 | # an accumulators to avoid unnecessary intermediate arrays 64 | accr = result[1:-1] # Accumulate directly into result 65 | acc1 = np.empty_like(accr) 66 | 67 | np.subtract(p2[:, 1], p3[:, 1], out=accr) 68 | np.multiply(p1[:, 0], accr, out=accr) 69 | np.subtract(p3[:, 1], p1[:, 1], out=acc1) 70 | np.multiply(p2[:, 0], acc1, out=acc1) 71 | np.add(acc1, accr, out=accr) 72 | np.subtract(p1[:, 1], p2[:, 1], out=acc1) 73 | np.multiply(p3[:, 0], acc1, out=acc1) 74 | np.add(acc1, accr, out=accr) 75 | np.abs(accr, out=accr) 76 | accr /= 2.0 77 | # Notice: accr was writing into result, so the answer is in there 78 | return result 79 | 80 | 81 | # the final value in thresholds is np.inf, which will never be 82 | # the min value. So, I am safe in "deleting" an index by 83 | # just shifting the array over on top of it 84 | 85 | 86 | def remove(s, i): 87 | ''' 88 | Quick trick to remove an item from a numpy array without 89 | creating a new object. Rather than the array shape changing, 90 | the final value just gets repeated to fill the space. 91 | 92 | ~3.5x faster than numpy.delete 93 | ''' 94 | s[i:-1] = s[i + 1 :] 95 | 96 | 97 | class Simplifier: 98 | 99 | """Performs VW simplification on lists of points""" 100 | 101 | def __init__(self, pts): 102 | '''Initialize with points. takes some time to build 103 | the thresholds but then all threshold filtering later 104 | is ultra fast''' 105 | self.pts_in = np.array(pts) 106 | self.pts = self.pts_in.astype(float) 107 | self.thresholds = self.build_thresholds() 108 | self.ordered_thresholds = sorted(self.thresholds, reverse=True) 109 | 110 | def build_thresholds(self): 111 | '''compute the area value of each vertex, which one would 112 | use to mask an array of points for any threshold value. 113 | 114 | returns a numpy.array (length of pts) of the areas. 115 | ''' 116 | nmax = len(self.pts) 117 | real_areas = triangle_areas_from_array(self.pts) 118 | real_indices = list(range(nmax)) 119 | 120 | # destructable copies 121 | # ARG! areas=real_areas[:] doesn't make a copy! 122 | areas = np.copy(real_areas) 123 | i = real_indices[:] 124 | 125 | # pick first point and set up for loop 126 | min_vert = np.argmin(areas) 127 | this_area = areas[min_vert] 128 | # areas and i are modified for each point finished 129 | remove(areas, min_vert) # faster 130 | # areas = np.delete(areas,min_vert) #slower 131 | _ = i.pop(min_vert) 132 | 133 | # cntr = 3 134 | while this_area < np.inf: 135 | # min_vert was removed from areas and i. 136 | # Now, adjust the adjacent areas and remove the new min_vert. 137 | # Now that min_vert was filtered out, min_vert points 138 | # to the point after the deleted point. 139 | 140 | skip = False # modified area may be the next minvert 141 | 142 | try: 143 | right_area = triangle_area( 144 | self.pts[i[min_vert - 1]], 145 | self.pts[i[min_vert]], 146 | self.pts[i[min_vert + 1]], 147 | ) 148 | except IndexError: 149 | # trying to update area of endpoint. Don't do it 150 | pass 151 | else: 152 | right_idx = i[min_vert] 153 | if right_area <= this_area: 154 | # even if the point now has a smaller area, 155 | # it ultimately is not more significant than 156 | # the last point, which needs to be removed 157 | # first to justify removing this point. 158 | # Though this point is the next most significant 159 | right_area = this_area 160 | 161 | # min_vert refers to the point to the right of 162 | # the previous min_vert, so we can leave it 163 | # unchanged if it is still the min_vert 164 | skip = min_vert 165 | 166 | # update both collections of areas 167 | real_areas[right_idx] = right_area 168 | areas[min_vert] = right_area 169 | 170 | if min_vert > 1: 171 | # cant try/except because 0-1=-1 is a valid index 172 | left_area = triangle_area( 173 | self.pts[i[min_vert - 2]], 174 | self.pts[i[min_vert - 1]], 175 | self.pts[i[min_vert]], 176 | ) 177 | if left_area <= this_area: 178 | # same justification as above 179 | left_area = this_area 180 | skip = min_vert - 1 181 | real_areas[i[min_vert - 1]] = left_area 182 | areas[min_vert - 1] = left_area 183 | 184 | # only argmin if we have too. 185 | min_vert = skip or np.argmin(areas) 186 | 187 | _ = i.pop(min_vert) 188 | 189 | this_area = areas[min_vert] 190 | # areas = np.delete(areas,min_vert) #slower 191 | remove(areas, min_vert) # faster 192 | 193 | return real_areas 194 | 195 | def simplify(self, number=None, ratio=None, threshold=None): 196 | if threshold is not None: 197 | return self.by_threshold(threshold) 198 | 199 | if number is not None: 200 | return self.by_number(number) 201 | 202 | ratio = ratio or 0.90 203 | return self.by_ratio(ratio) 204 | 205 | def by_threshold(self, threshold): 206 | return self.pts_in[self.thresholds >= threshold] 207 | 208 | def by_number(self, n): 209 | n = int(n) 210 | try: 211 | threshold = self.ordered_thresholds[n] 212 | except IndexError: 213 | return self.pts_in 214 | 215 | # return the first n points since by_threshold 216 | # could return more points if the threshold is the same 217 | # for some points 218 | return self.by_threshold(threshold)[:n] 219 | 220 | def by_ratio(self, r): 221 | if r <= 0 or r > 1: 222 | raise ValueError("Ratio must be 0