├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.yml └── workflows │ └── python-publish-pypi.yml ├── .gitignore ├── .idea ├── .gitignore ├── PyTrack.iml ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── other.xml └── vcs.xml ├── .readthedocs.yml ├── CITATION.cff ├── LICENSE ├── README.md ├── docs ├── Makefile ├── figures │ ├── logo.png │ └── pycharmnewproj.png ├── make.bat ├── requirements.txt └── source │ ├── 1_gettingStarted.rst │ ├── API │ ├── analytics.rst │ ├── graph.rst │ ├── index.rst │ └── matching.rst │ ├── Examples │ └── index.rst │ ├── conf.py │ ├── fordummies.rst │ ├── index.rst │ └── notebooks │ ├── create_video_path.ipynb │ ├── extract_graph.ipynb │ ├── map-matching.ipynb │ └── segmentation_video_path.ipynb ├── examples ├── create_video_path.ipynb ├── dataset.xlsx ├── extract_graph.ipynb ├── map-matching.ipynb └── segmentation_video_path.ipynb ├── logo ├── example.png ├── pytracklogo.svg ├── video.gif └── video_seg.gif ├── pyproject.toml ├── pytrack ├── __init__.py ├── _version.py ├── analytics │ ├── __init__.py │ ├── plugins.py │ ├── video.py │ └── visualization.py ├── graph │ ├── __init__.py │ ├── distance.py │ ├── download.py │ ├── graph.py │ └── utils.py ├── matching │ ├── __init__.py │ ├── candidate.py │ ├── cleaning.py │ ├── matcher.py │ ├── mpmatching.py │ └── mpmatching_utils.py └── video │ ├── __init__.py │ ├── create_video.py │ └── streetview.py ├── requirements.txt └── setup.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report incorrect behavior in the PyTrack library. 3 | title: 'BUG: ' 4 | labels: bug 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: > 10 | Thank you for taking the time to file a bug report. 11 | Before creating a new issue, please make sure to take 12 | a few minutes to check the issue tracker for existing issues about the bug. 13 | 14 | - type: checkboxes 15 | id: checks 16 | attributes: 17 | label: PyTrack version checks 18 | options: 19 | - label: > 20 | I have checked that this issue has not already been reported. 21 | required: true 22 | - label: > 23 | I have confirmed this bug exists on the 24 | latest version of PyTrack. 25 | required: true 26 | - label: > 27 | I have confirmed this bug exists on the main branch of PyTrack. 28 | 29 | - type: textarea 30 | attributes: 31 | label: "Issue Description" 32 | validations: 33 | required: true 34 | 35 | - type: textarea 36 | attributes: 37 | label: "Reproducible Example" 38 | description: > 39 | A short code example that reproduces the problem/missing feature. It 40 | should be self-contained, i.e., can be copy-pasted into the Python 41 | interpreter or run as-is via `python myproblem.py`. 42 | placeholder: | 43 | import pytrack 44 | << your code here >> 45 | render: python 46 | validations: 47 | required: true 48 | 49 | - type: textarea 50 | attributes: 51 | label: "Error Message" 52 | description: > 53 | Please include full error message, if any. 54 | placeholder: | 55 | << Full traceback starting from `Traceback: ...` >> 56 | render: shell 57 | validations: 58 | required: true 59 | 60 | - type: textarea 61 | attributes: 62 | label: "PyTrack/Python version information" 63 | description: Report the version of your environment (Python and PyTrack) for reproducibility. 64 | validations: 65 | required: true 66 | 67 | - type: textarea 68 | attributes: 69 | label: "Additional Context" 70 | description: | 71 | Add any other context about the problem here and enhance reproducibility. 72 | 73 | Tip: You can attach files, screenshot, data or log files by clicking this area to highlight it and then dragging files in. 74 | placeholder: | 75 | << your explanation here >> 76 | validations: 77 | required: false -------------------------------------------------------------------------------- /.github/workflows/python-publish-pypi.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: >- 35 | python -m 36 | build 37 | --sdist 38 | --wheel 39 | --outdir dist/ 40 | . 41 | - name: Publish package 42 | uses: pypa/gh-action-pypi-publish@master 43 | with: 44 | user: __token__ 45 | password: ${{ secrets.PYPI_API_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | .DS_Store 148 | # PyCharm 149 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 150 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 151 | # and can be added to the global gitignore or merged into this file. For a more nuclear 152 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 153 | #.idea/ 154 | /cache/ 155 | examples/~$dataset.xlsx 156 | .idea/PyTrack.iml 157 | .idea/PyTrack.iml 158 | pytrack/meta.yaml 159 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/PyTrack.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 18 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/other.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-20.04 11 | tools: 12 | python: "3.9" 13 | # You can also specify other tool versions: 14 | # nodejs: "16" 15 | # rust: "1.55" 16 | # golang: "1.17" 17 | 18 | # Build documentation in the docs/ directory with Sphinx 19 | sphinx: 20 | configuration: docs/source/conf.py 21 | 22 | # If using Sphinx, optionally build your docs in additional formats such as PDF 23 | # formats: 24 | # - pdf 25 | 26 | formats: 27 | - pdf 28 | - epub 29 | 30 | # Optionally declare the Python requirements required to build your docs 31 | python: 32 | install: 33 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use PyTrack in your work, please cite the journal article." 3 | preferred-citation: 4 | type: article 5 | authors: 6 | - family-names: "Tortora" 7 | given-names: "Matteo" 8 | orcid: "https://orcid.org/0000-0002-3932-7380" 9 | - family-names: "Cordelli" 10 | given-names: "Ermanno" 11 | - family-names: "Soda" 12 | given-names: "Paolo" 13 | journal: "IEEE Access" 14 | title: "PyTrack: a Map-Matching-based Python Toolbox for Vehicle Trajectory Reconstruction" 15 | year: 2022 16 | volume: 10 17 | start: 112713 # First page number 18 | end: 112720 # Last page number 19 | doi: "10.1109/ACCESS.2022.3216565" 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The Clear BSD License 2 | 3 | Copyright (c) 2022, Matteo Tortora 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted (subject to the limitations in the disclaimer 8 | below) provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, 11 | this list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in the 15 | documentation and/or other materials provided with the distribution. 16 | 17 | * Neither the name of the copyright holder nor the names of its 18 | contributors may be used to endorse or promote products derived from this 19 | software without specific prior written permission. 20 | 21 | NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY 22 | THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 23 | CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 24 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 25 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 26 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 27 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 28 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 29 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 30 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 31 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 32 | POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |


4 | 5 | ----------------- 6 | 7 | # PyTrack: a Map-Matching-based Python Toolbox for Vehicle Trajectory Reconstruction 8 | [![All platforms](https://dev.azure.com/conda-forge/feedstock-builds/_apis/build/status/pytrack-feedstock?branchName=main)](https://dev.azure.com/conda-forge/feedstock-builds/_build/latest?definitionId=16366&branchName=main) 9 | [![PyPI Latest Release](https://img.shields.io/pypi/v/PyTrack-lib)](https://pypi.org/project/PyTrack-lib/) 10 | [![Conda Version](https://img.shields.io/conda/vn/conda-forge/pytrack.svg)](https://anaconda.org/conda-forge/pytrack) 11 | [![License](https://img.shields.io/pypi/l/pandas.svg)](LICENSE) 12 | [![docs](https://img.shields.io/readthedocs/pytrack-lib)](https://pytrack-lib.readthedocs.io/en/latest) 13 | [![PyPI downloads](https://img.shields.io/pypi/dm/pytrack-lib?label=PyPI%20downloads)](https://pypi.org/project/PyTrack-lib/) 14 | [![Anaconda downloads](https://img.shields.io/conda/dn/conda-forge/pytrack?label=conda%20downloads)](https://anaconda.org/conda-forge/pytrack) 15 | 16 | ## What is it? 17 | **PyTrack** is a Python package that integrates the recorded GPS coordinates with data provided by the open-source OpenStreetMap (OSM). 18 | PyTrack can serve the intelligent transport research, e.g. to reconstruct the video of a vehicle’s route by exploiting available data and without equipping it with camera sensors, to update the urban road network, and so on. 19 | 20 | If you use PyTrack in your work, please cite the journal article. 21 | 22 | **Citation info**: M. Tortora, E. Cordelli and P. Soda, "[PyTrack: a Map-Matching-based Python Toolbox for Vehicle Trajectory Reconstruction](https://ieeexplore.ieee.org/document/9927417)," in IEEE Access, vol. 10, pp. 112713-112720, 2022, doi: 10.1109/ACCESS.2022.3216565. 23 | 24 | ```latex 25 | @ARTICLE{mtpytrack, 26 | author={Tortora, Matteo and Cordelli, Ermanno and Soda, Paolo}, 27 | journal={IEEE Access}, 28 | title={PyTrack: A Map-Matching-Based Python Toolbox for Vehicle Trajectory Reconstruction}, 29 | year={2022}, 30 | volume={10}, 31 | number={}, 32 | pages={112713-112720}, 33 | doi={10.1109/ACCESS.2022.3216565}} 34 | ``` 35 | 36 | ## Main Features 37 | The following are the main features that PyTrack includes: 38 | - Generation of the street network graph using geospatial data from OpenStreetMap 39 | - Map-matching 40 | - Data cleaning 41 | - Video reconstruction of the GPS route 42 | - Visualisation and analysis capabilities 43 | 44 | ## Getting Started 45 | ### Installation 46 | The source code is currently hosted on GitHub at: 47 | https://github.com/cosbidev/PyTrack. 48 | PyTrack can be installed using*: 49 | ```sh 50 | # conda 51 | conda install pytrack 52 | ``` 53 | 54 | ```sh 55 | # or PyPI 56 | pip install PyTrack-lib 57 | ``` 58 | **for Mac m1 users, it is recommended to use conda in order to be able to install all dependencies.* 59 | ## Documentation 60 | Checkout the official [documentation](https://pytrack-lib.readthedocs.io/en/latest). 61 | Besides, [here](https://github.com/cosbidev/PyTrack/tree/main/examples) you can see some examples of the application of the library. 62 | 63 | ## Examples 64 | ### Map-Matching 65 | To see the map-matching feature of PyTrack in action please go to [map-matching documentation](https://pytrack-lib.readthedocs.io/en/latest/notebooks/map-matching.html). 66 | 67 |

68 | map-matching 69 |

70 | 71 | ### Video reconstruction of the itinerary 72 |

73 | video_track 74 | video_seg_track 75 |

76 | 77 | 78 | 79 | ## Contributing to PyTrack 80 | All contributions, bug reports, bug fixes, documentation improvements, enhancements, and ideas are welcome. 81 | 82 | ## Author 83 | Created by [Matteo Tortora](https://matteotortora.github.io) - feel free to contact me! 84 | 85 | 93 | 94 | ## License 95 | PyTrack is distributed under a [BSD-3-Clause-Clear Licence](https://github.com/cosbidev/PyTrack/blob/main/LICENSE). 96 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 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) 21 | -------------------------------------------------------------------------------- /docs/figures/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosbidev/PyTrack/45c0b7bfcfe815f4f90545e3e5079d0c75028512/docs/figures/logo.png -------------------------------------------------------------------------------- /docs/figures/pycharmnewproj.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosbidev/PyTrack/45c0b7bfcfe815f4f90545e3e5079d0c75028512/docs/figures/pycharmnewproj.png -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==5.0.1 2 | sphinx_autodoc_typehints==1.18.3 3 | sphinx-rtd-theme==1.0.0 4 | pygments-style-monokailight==0.4 5 | nbsphinx==0.8.9 6 | ipython==8.4.0 7 | docutils==0.16 8 | -r ../requirements.txt 9 | -------------------------------------------------------------------------------- /docs/source/1_gettingStarted.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | Installation 5 | ------------ 6 | 7 | **PyTrack** can be installed with *pip* with the following command: 8 | 9 | .. code-block:: shell 10 | 11 | pip install pytrack-lib 12 | 13 | or via *conda*: 14 | 15 | .. code-block:: shell 16 | 17 | conda install pytrack 18 | 19 | .. warning:: 20 | for mac M1 users, it is recommended to use conda in order to be able to install all dependencies. In particular, the *pyproj* library can be installed via conda. 21 | -------------------------------------------------------------------------------- /docs/source/API/analytics.rst: -------------------------------------------------------------------------------- 1 | Analytics 2 | ======== 3 | 4 | visualization 5 | ------------- 6 | .. automodule:: pytrack.analytics.visualization 7 | :members: 8 | :undoc-members: 9 | :show-inheritance: 10 | :inherited-members: 11 | 12 | video 13 | ------------- 14 | .. automodule:: pytrack.analytics.video 15 | :members: 16 | :undoc-members: 17 | 18 | plugins 19 | ------------- 20 | .. automodule:: pytrack.analytics.plugins 21 | :members: 22 | :undoc-members: 23 | -------------------------------------------------------------------------------- /docs/source/API/graph.rst: -------------------------------------------------------------------------------- 1 | Graph 2 | ======== 3 | 4 | distance 5 | ------------- 6 | .. automodule:: pytrack.graph.distance 7 | :members: 8 | :undoc-members: 9 | :show-inheritance: 10 | 11 | download 12 | ------------- 13 | .. automodule:: pytrack.graph.download 14 | :members: 15 | :undoc-members: 16 | :show-inheritance: 17 | 18 | graph 19 | ------------- 20 | .. automodule:: pytrack.graph.graph 21 | :members: 22 | :undoc-members: 23 | :show-inheritance: 24 | 25 | utils 26 | ------------- 27 | .. automodule:: pytrack.graph.utils 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | -------------------------------------------------------------------------------- /docs/source/API/index.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | API 4 | ********* 5 | 6 | :Release: |release| 7 | :Date: |today| 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | graph 13 | matching 14 | analytics -------------------------------------------------------------------------------- /docs/source/API/matching.rst: -------------------------------------------------------------------------------- 1 | Matching 2 | ======== 3 | 4 | candidate 5 | ------------- 6 | .. automodule:: pytrack.matching.candidate 7 | :members: 8 | :undoc-members: 9 | 10 | cleaning 11 | ------------- 12 | .. automodule:: pytrack.matching.cleaning 13 | :members: 14 | :undoc-members: 15 | 16 | matcher 17 | ------------- 18 | .. automodule:: pytrack.matching.matcher 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | mpmatching 24 | ------------- 25 | .. automodule:: pytrack.matching.mpmatching 26 | :members: 27 | :undoc-members: 28 | :show-inheritance: 29 | 30 | mpmatching_utils 31 | ------------- 32 | .. automodule:: pytrack.matching.mpmatching_utils 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: -------------------------------------------------------------------------------- /docs/source/Examples/index.rst: -------------------------------------------------------------------------------- 1 | .. _examples: 2 | 3 | Examples 4 | ********* 5 | 6 | :Release: |release| 7 | :Date: |today| 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | ../notebooks/extract_graph 14 | ../notebooks/map-matching 15 | ../notebooks/create_video_path 16 | ../notebooks/segmentation_video_path -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import sys 14 | from pathlib import Path 15 | 16 | # go up two levels from /docs/source to the package root 17 | sys.path.insert(0, str(Path().resolve().parent.parent)) 18 | import pytrack 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'PyTrack' 23 | copyright = '2022, Matteo Tortora' 24 | author = 'Matteo Tortora' 25 | 26 | # The full version, including alpha/beta/rc tags 27 | release = f'{pytrack.__version__}' 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | autoclass_content = "both" 35 | autosummary_generate = True 36 | autodoc_default_flags = [ 37 | # Make sure that any autodoc declarations show the right members 38 | "members", 39 | # "inherited-members", 40 | "private-members", 41 | "show-inheritance", 42 | ] 43 | 44 | extensions = ['sphinx.ext.autodoc', 45 | 'sphinx.ext.doctest', 46 | 'sphinx.ext.todo', 47 | 'sphinx.ext.coverage', 48 | 'sphinx.ext.mathjax', 49 | 'sphinx.ext.ifconfig', 50 | 'sphinx.ext.viewcode', 51 | 'sphinx.ext.autosummary', 52 | 'sphinx.ext.inheritance_diagram', 53 | 'sphinx_autodoc_typehints', 54 | 'sphinx.ext.intersphinx', 55 | "sphinx.ext.napoleon", 56 | "sphinx.ext.doctest", 57 | "IPython.sphinxext.ipython_console_highlighting", 58 | 'IPython.sphinxext.ipython_console_highlighting', 59 | 'IPython.sphinxext.ipython_directive', 60 | "nbsphinx"] 61 | 62 | # Add any paths that contain templates here, relative to this directory. 63 | templates_path = ['_templates'] 64 | 65 | # The master toctree document. 66 | master_doc = 'index' 67 | 68 | intersphinx_mapping = {'python': ('https://docs.python.org/3', None), 69 | "folium": ("https://python-visualization.github.io/folium/", None)} 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This pattern also affects html_static_path and html_extra_path. 74 | exclude_patterns = ["**.ipynb_checkpoints"] 75 | 76 | # -- Options for HTML output ------------------------------------------------- 77 | 78 | # The theme to use for HTML and HTML Help pages. See the documentation for 79 | # a list of builtin themes. 80 | # 81 | html_theme = 'sphinx_rtd_theme' 82 | 83 | # Add any paths that contain custom static files (such as style sheets) here, 84 | # relative to this directory. They are copied after the builtin static files, 85 | # so a file named "default.css" will overwrite the builtin "default.css". 86 | html_static_path = ['_static'] 87 | -------------------------------------------------------------------------------- /docs/source/fordummies.rst: -------------------------------------------------------------------------------- 1 | PyTrack for dummies 2 | =============== 3 | 4 | Setup of the environment on M1 processor 5 | ------------ 6 | 7 | **Step 1** 8 | Install ``Miniforge`` (minimal installer for Conda specific to conda-forge) using this `Link `_. 9 | 10 | **Step 1.1.1** install xcode: 11 | 12 | .. code-block:: shell 13 | 14 | xcode-select –install 15 | 16 | **Step 1.1.2** 17 | install MiniForge by terminal: 18 | 19 | .. code-block:: shell 20 | 21 | Miniforge3-MacOSX-arm64.sh 22 | 23 | Select ``no`` to conda init 24 | 25 | .. note:: 26 | Follow this `guide `_ for a more detailed description of the various steps. 27 | 28 | 29 | **Step 2** 30 | Install Python 3.9.13 (if you don’t have any other version later than 3.X) from this `link `_ by downloading ``macOS 64-bit universal2 installer``. 31 | 32 | **Step 3** Set a project in PyCharm using the ``New project`` button. 33 | 34 | .. figure:: ../figures/pycharmnewproj.png 35 | :align: center 36 | 37 | **Step 4** Install Pytrack, from terminal in PyCharm, using the following command: 38 | 39 | .. code-block:: shell 40 | 41 | conda install pytrack 42 | 43 | Perform map-matching process 44 | ------------ 45 | **Step 1** 46 | Download the ``dataset.xlsx`` file and the notebook file ``map-matching.ipynb`` from the official GitHub repository of this library. 47 | This Jupyter file permits you to test the map matching, whilst, the ``dataset.xlsx`` file lists a set of gps positions. 48 | 49 | **Step 2** 50 | Double click on the Jupyter file directly in PyCharm, and then ``Run all`` (double green arrows) 51 | 52 | .. note:: 53 | The ``openpyxl`` library is required to execute the code. If it is not already installed, it can be installed with the following terminal command: 54 | 55 | .. code-block:: shell 56 | 57 | conda install openpyxl 58 | 59 | The results can be inspected directly in the jupyter notebook. 60 | 61 | 62 | Make a video of the reconstructed path 63 | ------------ 64 | 65 | **Step 1** 66 | Go to the `official GitHub repository <_https://github.com/cosbidev/PyTrack>`_ of this library, download the code and pick the Jupyter file ``create_video_path.ipynb`` located in the ``examples`` folder. 67 | 68 | **Step 2** 69 | Open the same project of ``Perform map-matching process`` tutorial and paste ``create_video_path.ipynb`` in the working directory. 70 | Please check to have there the same ``dataset.xlsx`` used in the previous tutorial. 71 | 72 | **Step 3** 73 | Go to this `link `_ and click on ``Get started`` button (upper right corner) and log in. 74 | You can use the free credit offered by Google, which usually consists of 200$ each month free of charge for Google Maps API. 75 | At the end of the registration please save your key for API Google Maps Platform. 76 | In Google Cloud resume, clink on the API menu in the left and check that the ``Street View Static API`` has been enabled. 77 | 78 | **Step 4** 79 | Open the ``create_video_path.ipynb`` file and insert your key for API Google Maps Platform in line 2 of cell 3. 80 | 81 | **Step 5** 82 | Check if you already have the libraries needed to run this example, but not needed to run pytrack. 83 | To this end, check the libraries listed from rows 4 to 19 of cell 1. In the example below, I need to install natsort, tqdm, cv2 (i.e. OpenCV) . 84 | I can do that from PyCharm terminal, typing: 85 | 86 | .. code-block:: shell 87 | 88 | conda install natsort 89 | conda install tqdm 90 | pip install opencv-contrib-python 91 | 92 | **Step 6** 93 | Run the code, and you will find the following results: a) in the working directory, in the new created folder ``SV_panoramas``: 94 | you will have a folder for each Street View Image, each containing the image and the metadata. 95 | In this example you have 346 folders, from 0 to 345; b) the output video, concatenating the images in SV_panoramas, is located in the working directory. 96 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. PyTrack documentation master file, created by 2 | sphinx-quickstart on Mon May 30 20:04:37 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. figure:: ../figures/logo.png 7 | :align: center 8 | 9 | PyTrack |version| 10 | =================================== 11 | **PyTrack** is a Python package that integrate the recorded GPS coordinates with data provided by the open-source OpenStreetMap (OSM). 12 | PyTrack can serve the intelligent transport research, e.g. to reconstruct the video of a vehicle’s route by exploiting available data and without equipping it with camera sensors, to update the urban road network, and so on. 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | :caption: Contents: 17 | 18 | 1_gettingStarted.rst 19 | API/index.rst 20 | Examples/index.rst 21 | fordummies.rst 22 | 23 | 24 | Indices and tables 25 | ================== 26 | 27 | * :ref:`genindex` 28 | * :ref:`modindex` 29 | * :ref:`search` 30 | -------------------------------------------------------------------------------- /examples/dataset.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosbidev/PyTrack/45c0b7bfcfe815f4f90545e3e5079d0c75028512/examples/dataset.xlsx -------------------------------------------------------------------------------- /logo/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosbidev/PyTrack/45c0b7bfcfe815f4f90545e3e5079d0c75028512/logo/example.png -------------------------------------------------------------------------------- /logo/pytracklogo.svg: -------------------------------------------------------------------------------- 1 | PyTrack -------------------------------------------------------------------------------- /logo/video.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosbidev/PyTrack/45c0b7bfcfe815f4f90545e3e5079d0c75028512/logo/video.gif -------------------------------------------------------------------------------- /logo/video_seg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosbidev/PyTrack/45c0b7bfcfe815f4f90545e3e5079d0c75028512/logo/video_seg.gif -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /pytrack/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ 2 | __import__('pkg_resources').declare_namespace(__name__) 3 | -------------------------------------------------------------------------------- /pytrack/_version.py: -------------------------------------------------------------------------------- 1 | """PyTrack package version.""" 2 | 3 | __version__ = "2.0.8" 4 | -------------------------------------------------------------------------------- /pytrack/analytics/__init__.py: -------------------------------------------------------------------------------- 1 | # This lets you use package.module.Class as package.Class in your code. 2 | from .visualization import Map 3 | 4 | # This lets Sphinx know you want to document package.module.Class as package.Class. 5 | __all__ = ['Map'] -------------------------------------------------------------------------------- /pytrack/analytics/plugins.py: -------------------------------------------------------------------------------- 1 | class Segmenter: 2 | """ Skeleton parent class to perform the segmentation operation. 3 | 4 | """ 5 | def __init__(self): 6 | pass 7 | 8 | def processing(self, img): 9 | """ It takes an input image and returns the processed image. 10 | """ 11 | pass 12 | 13 | def run(self, img): 14 | """ It takes an input image and returns the mask of the segmented image. 15 | """ 16 | pass 17 | -------------------------------------------------------------------------------- /pytrack/analytics/video.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from pathlib import Path 4 | 5 | import cv2 6 | import requests 7 | 8 | from pytrack.analytics import plugins 9 | 10 | PREV_PAN_ID = None 11 | 12 | 13 | def make_video(images, output_path, fourcc, fps=1, size=(640, 640), is_color=True): 14 | """ Makes video using xvid codec. Increase FPS for faster timelapse. 15 | 16 | Parameters 17 | ---------- 18 | images: list 19 | List of the image paths to be added as frames to the video. 20 | output_path: str 21 | Path to output video, must end with .avi. 22 | fps: int, optional, default: 1 23 | Desired frame rate. 24 | size: tuple, optional, default: (640, 640) 25 | Size of the video frames. 26 | is_color: bool, optional, default: True 27 | If it is True, the encoder will expect and encode color frames, otherwise it will work with grayscale frames. 28 | 29 | :return: The function does not return anything. It directly saves the video at the position indicated in output_path. 30 | """ 31 | vid = cv2.VideoWriter(output_path, fourcc, fps, size, is_color) 32 | for image in images: 33 | vid.write(cv2.imread(image)) 34 | vid.release() 35 | cv2.destroyAllWindows() 36 | 37 | 38 | def extract_streetview_pic(point, api_key, size="640x640", heading=90, pitch=-10): 39 | """ Extract street view pic. 40 | 41 | Parameters 42 | ---------- 43 | point: tuple 44 | The lat/lng value of desired location. 45 | api_key: str 46 | Street View Static API key. It allows you to monitor your application's API usage in the Google Cloud Console, 47 | and ensures that Google can contact you about your application if necessary. 48 | size: str, optional, default: "640x640" 49 | Specifies the output size of the image in pixels. 50 | heading: int, optional, default: 90 51 | indicates the compass heading of the camera. Accepted values are from 0 to 360 (both values indicating North, 52 | with 90 indicating East, and 180 South). If no heading is specified, a value will be calculated that directs 53 | the camera towards the specified location, from the point at which the closest photograph was taken. 54 | pitch: int, optional, default: -10 55 | specifies the up or down angle of the camera relative to the Street View vehicle. This is often, but not always, 56 | flat horizontal. Positive values angle the camera up (with 90 degrees indicating straight up); negative values 57 | angle the camera down (with -90 indicating straight down). 58 | 59 | Returns 60 | ------- 61 | pic: request.content 62 | Image get from Street View. 63 | meta: json 64 | Data about Street View panoramas. 65 | """ 66 | 67 | global PREV_PAN_ID 68 | 69 | meta_base = 'https://maps.googleapis.com/maps/api/streetview/metadata?' 70 | pic_base = 'https://maps.googleapis.com/maps/api/streetview?' 71 | 72 | lat = point[0] 73 | lon = point[1] 74 | 75 | meta_params = {'key': api_key, 76 | 'location': f'{lat}, {lon}', 77 | 'source': 'outdoor'} 78 | 79 | pic_params = { 80 | 'size': size, # max 640x640 pixels 81 | 'location': f'{lat}, {lon}', 82 | 'heading': str(heading), 83 | 'pitch': str(pitch), 84 | 'key': api_key, 85 | 'source': 'outdoor' 86 | } 87 | 88 | # Do the request and get the response data 89 | meta_response = requests.get(meta_base, params=meta_params) 90 | meta = meta_response.json() 91 | meta_response.close() 92 | if meta["status"] == "REQUEST_DENIED": 93 | print(meta) 94 | if (meta["status"] == "OK") and (meta["pano_id"] != PREV_PAN_ID): 95 | PREV_PAN_ID = meta["pano_id"] 96 | pic_response = requests.get(pic_base, params=pic_params) 97 | 98 | pic = pic_response.content 99 | pic_response.close() 100 | else: 101 | meta = None 102 | pic = None 103 | 104 | return pic, meta 105 | 106 | 107 | def save_streetview(pic, meta, folder_path, model=None): 108 | """ Save streetview pic and metadata in the desired path. 109 | 110 | Parameters 111 | ---------- 112 | pic: request.content 113 | Image get from Street View. 114 | meta: json 115 | Data about Street View panoramas. 116 | folder_path: str 117 | Desired path of the folder where save pic and metadata. 118 | 119 | :return: The function does not return anything. It directly saves the pic and metadata at the position indicated in folder_path. 120 | """ 121 | Path(os.path.join(folder_path)).mkdir(parents=True, exist_ok=True) 122 | 123 | with open(os.path.join(folder_path, 'pic.png'), 'wb') as file: 124 | file.write(pic) 125 | 126 | if isinstance(model, plugins.Segmenter): 127 | with open(os.path.join(folder_path, 'pic_seg.png'), 'wb') as file: 128 | file.write(model.run(pic)) 129 | 130 | with open(os.path.join(folder_path, 'metadata.json'), 'w+') as out_file: 131 | json.dump(meta, out_file) 132 | -------------------------------------------------------------------------------- /pytrack/analytics/visualization.py: -------------------------------------------------------------------------------- 1 | import random 2 | from inspect import signature 3 | 4 | import folium 5 | import matplotlib.pyplot as plt 6 | import networkx as nx 7 | from shapely.geometry import LineString 8 | 9 | from pytrack.graph import utils 10 | from pytrack.matching import mpmatching_utils 11 | 12 | 13 | class Map(folium.Map): 14 | """ This class extends the ``folium.Map`` to add functionality useful to represent graphs and road paths. 15 | 16 | Parameters 17 | ---------- 18 | location: tuple or list, optional, default: None 19 | Latitude and Longitude of Map (Northing, Easting). 20 | width: int or percentage string, optional, default: '100%') 21 | Width of the map. 22 | height: int or percentage string, optional, default: '100%' 23 | Height of the map. 24 | tiles: str, optional, default: 'OpenStreetMap' 25 | Map tileset to use. Can choose from a list of built-in tiles, 26 | pass a custom URL or pass `None` to create a map without tiles. 27 | For more advanced tile layer options, use the `TileLayer` class. 28 | min_zoom: int, optional, default: 0 29 | Minimum allowed zoom level for the tile layer that is created. 30 | max_zoom: int, optional, default: 18 31 | Maximum allowed zoom level for the tile layer that is created. 32 | zoom_start: int, optional, default 10 33 | Initial zoom level for the map. 34 | attr: string, optional, default: None 35 | Map tile attribution; only required if passing custom tile URL. 36 | crs : str, optional, default: 'EPSG3857' 37 | Defines coordinate reference systems for projecting geographical points 38 | into pixel (screen) coordinates and back. 39 | You can use Leaflet's values : 40 | * EPSG3857 : The most common CRS for online maps, used by almost all 41 | free and commercial tile providers. Uses Spherical Mercator projection. 42 | Set in by default in Map's crs option. 43 | * EPSG4326 : A common CRS among GIS enthusiasts. 44 | Uses simple Equirectangular projection. 45 | * EPSG3395 : Rarely used by some commercial tile providers. 46 | Uses Elliptical Mercator projection. 47 | * Simple : A simple CRS that maps longitude and latitude into 48 | x and y directly. May be used for maps of flat surfaces 49 | (e.g. game maps). Note that the y axis should still be inverted 50 | (going from bottom to top). 51 | control_scale : bool, optional, default: False 52 | Whether to add a control scale on the map. 53 | prefer_canvas : bool, optional, default: False 54 | Forces Leaflet to use the Canvas back-end (if available) for 55 | vector layers instead of SVG. This can increase performance 56 | considerably in some cases (e.g. many thousands of circle 57 | markers on the map). 58 | no_touch : bool, optional, default: False 59 | Forces Leaflet to not use touch events even if it detects them. 60 | disable_3d : bool, optional, default: False 61 | Forces Leaflet to not use hardware-accelerated CSS 3D 62 | transforms for positioning (which may cause glitches in some 63 | rare environments) even if they're supported. 64 | zoom_control : bool, optional, default: True 65 | Display zoom controls on the map. 66 | **kwargs : keyword arguments, optional, default: no attributes 67 | Additional keyword arguments are passed to Leaflets Map class: 68 | https://leafletjs.com/reference-1.6.0.html#map 69 | 70 | Returns 71 | ------- 72 | Folium Map Object 73 | 74 | Notes 75 | ----- 76 | See https://github.com/python-visualization/folium/blob/551b2420150ab56b71dcf14c62e5f4b118caae32/folium/folium.py#L69 77 | for a more detailed description 78 | 79 | """ 80 | 81 | def __init__( 82 | self, 83 | location=None, 84 | width='100%', 85 | height='100%', 86 | left='0%', 87 | top='0%', 88 | position='relative', 89 | tiles='CartoDB positron', 90 | attr=None, 91 | min_zoom=0, 92 | max_zoom=18, 93 | zoom_start=15, 94 | min_lat=-90, 95 | max_lat=90, 96 | min_lon=-180, 97 | max_lon=180, 98 | max_bounds=False, 99 | crs='EPSG3857', 100 | control_scale=False, 101 | prefer_canvas=False, 102 | no_touch=False, 103 | disable_3d=False, 104 | png_enabled=False, 105 | zoom_control=True, 106 | **kwargs 107 | ): 108 | super().__init__(location, width, height, left, top, position, tiles, attr, min_zoom, max_zoom, zoom_start, 109 | min_lat, max_lat, min_lon, max_lon, max_bounds, crs, control_scale, prefer_canvas, no_touch, 110 | disable_3d, png_enabled, zoom_control, **kwargs) 111 | self.tiles = tiles 112 | folium.LatLngPopup().add_to(self) 113 | 114 | def _render_reset(self): 115 | for key in list(self._children.keys()): 116 | if key.startswith('cartodbpositron') or key.startswith('lat_lng_popup'): 117 | self._children.pop(key) 118 | children = self._children 119 | self.__init__(self.location, tiles=self.tiles) 120 | self.options = self.options 121 | for k, v in children.items(): 122 | self.add_child(v) 123 | 124 | def _layer_control_exist(self): 125 | # Check if a layer control already exists on the map 126 | self.layer_control_exist = False 127 | for child in self._children: 128 | if child.startswith('layer_control'): 129 | self.layer_control_exist = True 130 | break 131 | 132 | def _manage_layer_control(self): 133 | # Check if a layer control already exists on the map 134 | self._layer_control_exist() 135 | 136 | # Add a new layer control if one doesn't exist 137 | if self.layer_control_exist: 138 | del self._children[next(k for k in self._children.keys() if k.startswith('layer_control'))] 139 | self.add_child(folium.LayerControl()) 140 | self._render_reset() 141 | 142 | # Add a new layer control if one doesn't exist 143 | else: 144 | folium.LayerControl().add_to(self) 145 | 146 | def add_graph(self, G, plot_nodes=False, edge_color="#3388ff", edge_width=3, 147 | edge_opacity=1, radius=1.7, node_color="red", fill=True, fill_color=None, 148 | fill_opacity=1): 149 | """ Add the road network graph created with ``pytrack.graph.graph.graph_from_bbox`` method 150 | 151 | Parameters 152 | ---------- 153 | G: networkx.MultiDiGraph 154 | Road network graph. 155 | plot_nodes: bool, optional, default: False 156 | If true, it will show the vertices of the graph. 157 | edge_color: str, optional, default: "#3388ff" 158 | Colour of graph edges. 159 | edge_width: float, optional, default: 3 160 | Width of graph edges. 161 | edge_opacity: float, optional, default: 1 162 | Opacity of graph edges. 163 | radius: float, optional, default: 1.7 164 | Radius of graph vertices. 165 | node_color: str, optional, default: "red" 166 | Colour of graph vertices. 167 | fill: bool, optional, default: True 168 | Whether to fill the nodes with color. Set it to false to disable filling on the nodes. 169 | fill_color: str or NoneType, default: None 170 | Fill color. Defaults to the value of the color option. 171 | fill_opacity: float, optional, default: 1 172 | Fill opacity. 173 | """ 174 | edge_attr = dict() 175 | edge_attr["color"] = edge_color 176 | edge_attr["weight"] = edge_width 177 | edge_attr["opacity"] = edge_opacity 178 | 179 | node_attr = dict() 180 | node_attr["color"] = node_color 181 | node_attr["fill"] = fill 182 | node_attr["fill_color"] = fill_color 183 | node_attr["fill_opacity"] = fill_opacity 184 | 185 | nodes, edges = utils.graph_to_gdfs(G) 186 | 187 | fg_graph = folium.FeatureGroup(name='Graph edges', show=True) 188 | self.add_child(fg_graph) 189 | 190 | for geom in edges.geometry: 191 | edge = [(lat, lng) for lng, lat in geom.coords] 192 | folium.PolyLine(locations=edge, **edge_attr, ).add_to(fg_graph) 193 | 194 | if plot_nodes: 195 | fg_point = folium.FeatureGroup(name='Graph vertices', show=True) 196 | self.add_child(fg_point) 197 | for point, osmid in zip(nodes.geometry, nodes.osmid): 198 | folium.Circle(location=(point.y, point.x), popup=f"osmid: {osmid}", radius=radius, **node_attr).add_to( 199 | fg_point) 200 | 201 | # Update layer_control if it exists, otherwise create it 202 | self._manage_layer_control() 203 | 204 | def draw_candidates(self, candidates, radius, point_radius=1, point_color="black", point_fill=True, 205 | point_fill_opacity=1, area_weight=1, area_color="black", area_fill=True, area_fill_opacity=0.2, 206 | cand_radius=1, cand_color="orange", cand_fill=True, cand_fill_opacity=1): 207 | """ Draw the candidate nodes of the HMM matcher 208 | 209 | Parameters 210 | ---------- 211 | candidates: dict 212 | Candidates' dictionary computed via ``pytrack.matching.candidate.get_candidates`` method 213 | radius: float 214 | Candidate search radius. 215 | point_radius: float, optional, default: 1 216 | Radius of the actual GPS points. 217 | point_color: str, optional, default: "black" 218 | Colour of actual GPS points. 219 | point_fill: bool, optional, default: True 220 | Whether to fill the actual GPS points with color. Set it to false to disable filling on the nodes. 221 | point_fill_opacity: float, optional, default: 1 222 | Fill opacity of the actual GPS points. 223 | area_weight: float, optional, default: 1 224 | Stroke width in pixels of the search area. 225 | area_color: str, optional, default: "black" 226 | Colour of search area. 227 | area_fill: bool, optional, default: True 228 | Whether to fill the search area with color. Set it to false to disable filling on the nodes. 229 | area_fill_opacity: float, optional, default: 0.2 230 | Fill opacity of the search area. 231 | cand_radius: float, optional, default: 2 232 | Radius of the candidate points. 233 | cand_color: str, optional, default: "orange" 234 | Colour of candidate points. 235 | cand_fill: bool, optional, default: True 236 | Whether to fill the candidate points with color. Set it to false to disable filling on the nodes. 237 | cand_fill_opacity: float, optional, default: 1 238 | Fill opacity of the candidate GPS points. 239 | """ 240 | fg_cands = folium.FeatureGroup(name='Candidates', show=True, control=True) 241 | fg_gps = folium.FeatureGroup(name="Actual GPS points", show=True, control=True) 242 | fg_area = folium.FeatureGroup(name="Candidate search area", show=True, control=True) 243 | self.add_child(fg_cands) 244 | self.add_child(fg_gps) 245 | self.add_child(fg_area) 246 | 247 | for i, obs in enumerate(candidates.keys()): 248 | folium.Circle(location=candidates[obs]["observation"], radius=radius, weight=area_weight, color=area_color, 249 | fill=area_fill, fill_opacity=area_fill_opacity).add_to(fg_area) 250 | popup = f'{i}-th point \n Latitude: {candidates[obs]["observation"][0]}\n Longitude: ' \ 251 | f'{candidates[obs]["observation"][1]}' 252 | folium.Circle(location=candidates[obs]["observation"], popup=popup, radius=point_radius, color=point_color, 253 | fill=point_fill, fill_opacity=point_fill_opacity).add_to(fg_gps) 254 | 255 | # plot candidates 256 | for cand, label, cand_type in zip(candidates[obs]["candidates"], candidates[obs]["edge_osmid"], 257 | candidates[obs]["candidate_type"]): 258 | popup = f"coord: {cand} \n edge_osmid: {label}" 259 | if cand_type: 260 | folium.Circle(location=cand, popup=popup, radius=2, color="yellow", fill=True, 261 | fill_opacity=1).add_to(fg_cands) 262 | else: 263 | folium.Circle(location=cand, popup=popup, radius=cand_radius, color=cand_color, fill=cand_fill, 264 | fill_opacity=cand_fill_opacity).add_to(fg_cands) 265 | 266 | # Update layer_control if it exists, otherwise create it 267 | self._manage_layer_control() 268 | 269 | def draw_path(self, G, trellis, predecessor, path_name="Matched path", path_color="green", path_weight=4, 270 | path_opacity=1): 271 | """ Draw the map-matched path 272 | 273 | Parameters 274 | ---------- 275 | G: networkx.MultiDiGraph 276 | Road network graph. 277 | trellis: nx.DiGraph 278 | Trellis DAG graph created with ``pytrack.matching.mpmatching_utils.create_trellis`` method 279 | predecessor: dict 280 | Predecessors' dictionary computed with ``pytrack.matching.mpmatching.viterbi_search`` method 281 | path_name: str, optional, default: "Matched path" 282 | Name of the path to be drawn 283 | path_color: str, optional, default: "green" 284 | Stroke color 285 | path_weight: float, optional, default: 4 286 | Stroke width in pixels 287 | path_opacity: float, optional, default: 1 288 | Stroke opacity 289 | """ 290 | 291 | fg_matched = folium.FeatureGroup(name=path_name, show=True, control=True) 292 | self.add_child(fg_matched) 293 | 294 | path_elab = mpmatching_utils.create_path(G, trellis, predecessor) 295 | 296 | edge_attr = dict() 297 | edge_attr["color"] = path_color 298 | edge_attr["weight"] = path_weight 299 | edge_attr["opacity"] = path_opacity 300 | 301 | edge = [(lat, lng) for lng, lat in LineString([G.nodes[node]["geometry"] for node in path_elab]).coords] 302 | folium.PolyLine(locations=edge, **edge_attr).add_to(fg_matched) 303 | 304 | # Update layer_control if it exists, otherwise create it 305 | self._manage_layer_control() 306 | 307 | def add_geojson(self, geojson_data_list, styles=None, layer_names=None): 308 | """ Add a GeoJSON layer to a Folium map object. 309 | 310 | Parameters 311 | ---------- 312 | geojson_data_list : list of str, dict, or file 313 | The list of GeoJSON data as strings, dictionaries, or files. 314 | styles : list of function, optional 315 | The list of style functions for each GeoJSON layer. Each function should take a 'feature' argument 316 | and return a dictionary of style options. If None, a random color will be assigned to each layer. 317 | Default is None. 318 | layer_names : list of str, optional 319 | The list of names for each GeoJSON layer. If None, each layer will be named "Layer i" where i is 320 | its index in geojson_data_list. If provided, the length of layer_names must match the length of 321 | geojson_data_list. Default is None. 322 | """ 323 | # Generate a random color for each layer if no style is provided 324 | if styles is None: 325 | styles = [lambda feature, color=f"#{random.randint(0, 0xFFFFFF):06x}": { 326 | "color": color, 327 | "weight": 2, 328 | "opacity": 0.8, 329 | "fillColor": color, 330 | "fillOpacity": 0.5 331 | } for _ in geojson_data_list] 332 | 333 | # Create a FeatureGroup to aggregate the GeoJSON layers 334 | feature_group = folium.FeatureGroup(name="GeoJSON Layers") 335 | self.add_child(feature_group) 336 | 337 | # Create a GeoJSON layer for each input data and add to the map 338 | for i, (geojson_data, style) in enumerate(zip(geojson_data_list, styles)): 339 | folium.GeoJson( 340 | geojson_data, 341 | style_function=style, 342 | name=layer_names[i] if layer_names is not None and i < len(layer_names) else f"Layer {i + 1}" 343 | ).add_to(feature_group) 344 | 345 | # Update layer_control if it exists, otherwise create it 346 | self._manage_layer_control() 347 | 348 | 349 | def draw_trellis(T, figsize=(15, 12), dpi=300, node_size=500, font_size=8, **kwargs): 350 | """ Draw a trellis graph 351 | 352 | Parameters 353 | ---------- 354 | T: networkx.DiGraph 355 | A directed acyclic graph 356 | figsize: (float, float), optional, default: [15.0, 12.0] 357 | Width, height figure size tuple in inches, optional 358 | dpi: float, optional, default: 300.0 359 | The resolution of the figure in dots-per-inch 360 | node_size: scalar or array, optional, default: 500 361 | Size of nodes. If an array is specified it must be the same length as nodelist. 362 | font_size: int, optional, default: 8 363 | Font size for text labels 364 | kwargs: keyword arguments, optional, default: no attributes 365 | See networkx.draw_networkx_nodes(), networkx.draw_networkx_edges(), 366 | networkx.draw_networkx_labels() and matplotlib.pyplot.figure() for a description of optional keywords. 367 | 368 | Returns 369 | ------- 370 | trellis_diag: matplotlib.pyplot.Figure 371 | Graphical illustration of the Trellis diagram used in the Hidden Markov Model process to find the path that best 372 | matches the actual GPS data 373 | """ 374 | 375 | valid_node_kwargs = signature(nx.draw_networkx_nodes).parameters.keys() 376 | valid_edge_kwargs = signature(nx.draw_networkx_edges).parameters.keys() 377 | valid_label_kwargs = signature(nx.draw_networkx_labels).parameters.keys() 378 | valid_plt_kwargs = signature(plt.figure).parameters.keys() 379 | 380 | valid_nx_kwargs = (valid_node_kwargs | valid_edge_kwargs | valid_label_kwargs) 381 | 382 | # Create a set with all valid keywords across the three functions and 383 | # remove the arguments of this function (draw_networkx) 384 | valid_kwargs = (valid_nx_kwargs | valid_plt_kwargs) - { 385 | "G", 386 | "figsize", 387 | "dpi", 388 | "pos", 389 | "node_size", 390 | "font_size", 391 | } 392 | 393 | if any([k not in valid_kwargs for k in kwargs]): 394 | invalid_args = ", ".join([k for k in kwargs if k not in valid_kwargs]) 395 | raise ValueError(f"Received invalid argument(s): {invalid_args}") 396 | 397 | nx_kwargs = {k: v for k, v in kwargs.items() if k in valid_nx_kwargs} 398 | plt_kwargs = {k: v for k, v in kwargs.items() if k in valid_plt_kwargs} 399 | 400 | plt.figure(figsize=figsize, dpi=dpi, **plt_kwargs) 401 | 402 | pos = nx.drawing.nx_pydot.graphviz_layout(T, prog='dot', root='start') 403 | trellis_diag = nx.draw_networkx(T, pos, node_size=node_size, font_size=font_size, **nx_kwargs) 404 | 405 | return trellis_diag 406 | -------------------------------------------------------------------------------- /pytrack/graph/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosbidev/PyTrack/45c0b7bfcfe815f4f90545e3e5079d0c75028512/pytrack/graph/__init__.py -------------------------------------------------------------------------------- /pytrack/graph/distance.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | import numpy as np 3 | from pyproj import Geod 4 | from shapely.geometry import Point, LineString 5 | 6 | from pytrack.graph import utils 7 | 8 | geod = Geod(ellps="WGS84") 9 | EARTH_RADIUS_M = 6_371_009 # distance in meters 10 | 11 | 12 | def get_bearing(lat1, lon1, lat2, lon2): 13 | """ Get bearing between two points. 14 | 15 | Parameters 16 | ---------- 17 | lat1: float 18 | Latitude of the first point specified in decimal degrees 19 | lon1: float 20 | Longitude of the first point specified in decimal degrees 21 | lat2: float 22 | Latitude of the second point specified in decimal degrees 23 | lon2: float 24 | Longitude of the second point specified in decimal degrees 25 | 26 | Returns 27 | ---------- 28 | bearing: float 29 | Bearing between two points. 30 | """ 31 | 32 | bearing, _, _ = geod.inv(lon1, lat1, lon2, lat2) 33 | return bearing 34 | 35 | 36 | def haversine_dist(lat1, lon1, lat2, lon2, earth_radius=EARTH_RADIUS_M): 37 | """ Calculate the great circle distance between two points on the earth (specified in decimal degrees) 38 | 39 | Parameters 40 | ---------- 41 | lat1: float 42 | Latitude of the first point specified in decimal degrees 43 | lon1: float 44 | Longitude of the first point specified in decimal degrees 45 | lat2: float 46 | Latitude of the second point specified in decimal degrees 47 | lon2: float 48 | Longitude of the second point specified in decimal degrees 49 | 50 | earth_radius: float, optional, default: 6371009.0 meters 51 | Earth's radius 52 | Returns 53 | ---------- 54 | dist: float 55 | Distance in units of earth_radius 56 | """ 57 | # convert decimal degrees to radians 58 | lon1, lat1, lon2, lat2 = map(np.deg2rad, [lon1, lat1, lon2, lat2]) 59 | 60 | dlon = lon2 - lon1 61 | dlat = lat2 - lat1 62 | 63 | # haversine formula 64 | h = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2 65 | h = np.minimum(1, h) # protect against floating point errors 66 | arc = 2 * np.arcsin(np.sqrt(h)) 67 | 68 | dist = arc * earth_radius 69 | 70 | return dist 71 | 72 | 73 | def enlarge_bbox(north, south, west, east, dist): 74 | """ Method that expands a bounding box by a specified distance. 75 | 76 | Parameters 77 | ---------- 78 | north: float 79 | Northern latitude of bounding box. 80 | south: float 81 | Southern latitude of bounding box. 82 | west: float 83 | Western longitude of bounding box. 84 | east: float 85 | Eastern longitude of bounding box. 86 | 87 | dist: float 88 | Distance in meters indicating how much to expand the bounding box. 89 | 90 | Returns 91 | ---------- 92 | north, south, west, east: float 93 | North, south, west, east coordinates of the expanded bounding box 94 | """ 95 | delta_lat = (dist / EARTH_RADIUS_M) * (180 / np.pi) 96 | lat_mean = np.mean([north, south]) 97 | delta_lng = (dist / EARTH_RADIUS_M) * (180 / np.pi) / np.cos(lat_mean * np.pi / 180) 98 | north = north + delta_lat 99 | south = south - delta_lat 100 | east = east + delta_lng 101 | west = west - delta_lng 102 | 103 | return north, south, west, east 104 | 105 | 106 | def add_edge_lengths(G, precision=3): 107 | """ Method that adds the length of individual edges to the graph. 108 | 109 | Parameters 110 | ---------- 111 | G: networkx.MultiDiGraph 112 | Road network graph. 113 | precision: float, optional, default: 3 114 | Number of decimal digits of the length of individual edges. 115 | Returns 116 | ---------- 117 | G: networkx.MultiDiGraph 118 | Street network graph. 119 | """ 120 | uvk = tuple(G.edges) 121 | 122 | lat = G.nodes(data='y') 123 | lon = G.nodes(data='x') 124 | 125 | lat_u, lon_u, lat_v, lon_v = list(zip(*[(lat[u], lon[u], lat[v], lon[v]) for u, v, _ in uvk])) 126 | 127 | dists = haversine_dist(lat_u, lon_u, lat_v, lon_v).round(precision) 128 | dists[np.isnan(dists)] = 0 129 | nx.set_edge_attributes(G, values=dict(zip(uvk, dists)), name='length') 130 | return G 131 | 132 | 133 | def interpolate_graph(G, dist=1): 134 | """ Method that creates a graph by interpolating the nodes of a graph. 135 | 136 | Parameters 137 | ---------- 138 | G: networkx.MultiDiGraph 139 | Road network graph. 140 | dist: float, optional, default: 1 141 | Distance between one node and the next. 142 | Returns 143 | ---------- 144 | G: networkx.MultiDiGraph 145 | Street network graph. 146 | """ 147 | G = G.copy() 148 | 149 | edges_toadd = [] 150 | nodes_toadd = [] 151 | 152 | for edge in list(G.edges(data=True)): 153 | u, v, data = edge 154 | # oneway = data["oneway"] 155 | geom = data["geometry"] 156 | data["length"] = dist 157 | 158 | G.remove_edge(u, v) 159 | 160 | XY = [xy for xy in _interpolate_geom(geom, dist=dist)] 161 | 162 | # generate nodes id 163 | uv = [(utils.get_unique_number(*u), utils.get_unique_number(*v)) for u, v in zip(XY[:-1], XY[1:])] 164 | # edges_interp = [LineString([u, v]) for u, v in zip(XY[:-1], XY[1:])] 165 | data_edges = [(lambda d: d.update({"geometry": LineString([u, v])}) or d)(data.copy()) for u, v in 166 | zip(XY[:-1], XY[1:])] 167 | edges_toadd.extend([(*uv, data) for uv, data in zip(uv, data_edges)]) 168 | 169 | nodes_toadd.extend([(utils.get_unique_number(lon, lat), 170 | {"x": lon, "y": lat, "geometry": Point(lon, lat)}) for lon, lat in XY]) 171 | 172 | G.add_edges_from(edges_toadd) 173 | G.add_nodes_from(nodes_toadd) 174 | 175 | G.remove_nodes_from(list(nx.isolates(G))) 176 | G.interpolation = True 177 | return G 178 | 179 | 180 | def _interpolate_geom(geom, dist=1): 181 | """ Generator that interpolates a geometry created using the ``shapely.geometry.LineString`` method. 182 | 183 | Parameters 184 | ---------- 185 | geom: shapely.geometry.LineString 186 | Geometry to be interpolated. 187 | dist: float, optional, default: 1 188 | Distance between one node and the next. 189 | Returns 190 | ---------- 191 | ret: generator 192 | Interpolated geometry. 193 | """ 194 | # TODO: use geospatial interpolation see: npts method in https://pyproj4.github.io/pyproj/stable/api/geod.html 195 | num_vert = max(round(geod.geometry_length(geom) / dist), 1) 196 | for n in range(num_vert + 1): 197 | point = geom.interpolate(n / num_vert, normalized=True) 198 | yield point.x, point.y 199 | 200 | 201 | def interpolate_geom(geom, dist=1): 202 | """ Method that interpolates a geometry created using the ``shapely.geometry.LineString`` method. 203 | 204 | Parameters 205 | ---------- 206 | geom: shapely.geometry.LineString 207 | Geometry to be interpolated. 208 | dist: float, optional, default: 1 209 | Distance between one node and the next. 210 | Returns 211 | ---------- 212 | geom: shapely.geometry 213 | Interpolated geometry. 214 | """ 215 | if isinstance(geom, LineString): 216 | return LineString([xy for xy in _interpolate_geom(geom, dist)]) 217 | -------------------------------------------------------------------------------- /pytrack/graph/download.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def get_filters(network_type='drive'): 5 | """ Get the filters with which to interrogate the OpenStreetMao API service. 6 | 7 | Parameters 8 | ---------- 9 | network_type: str, optional, default: 'drive' 10 | Type of street network to obtain. 11 | Returns 12 | ------- 13 | osm_filters: str 14 | Filters identifying the type of road network to be obtained 15 | """ 16 | osm_filters = dict() 17 | 18 | osm_filters['drive'] = ('["highway"]["area"!~"yes"]["access"!~"private"]' 19 | '["highway"!~"abandoned|bridleway|bus_guideway|construction|corridor|cycleway|' 20 | 'elevator|escalator|footway|path|pedestrian|planned|platform|proposed|raceway|steps|track"]' 21 | '["service"!~"emergency_access|parking|parking_aisle|private"]') 22 | 23 | osm_filters['bicycle'] = ('["highway"]["area"!~"yes"]["access"!~"private"]' 24 | '["highway"!~"abandoned|bridleway|footway|bus_guideway|construction|corridor|elevator|' 25 | 'escalator|planned|platform|proposed|raceway|steps|footway"]' 26 | '["service"!~"private"]["bicycle"!~"no"])') 27 | 28 | osm_filters['service'] = ('["highway"]["area"!~"yes"]["access"!~"private"]["highway"!~"abandoned|bridleway|' 29 | 'construction|corridor|platform|cycleway|elevator|escalator|footway|path|planned|' 30 | 'proposed|raceway|steps|track"]["service"!~"emergency_access|parking|' 31 | 'parking_aisle|private"]["psv"!~"no"]["footway"!~"yes"]') 32 | 33 | return osm_filters[network_type] 34 | 35 | 36 | def osm_download(bbox, network_type='drive', custom_filter=None): 37 | """ Get the OpenStreetMap response. 38 | 39 | Parameters 40 | ---------- 41 | bbox: tuple 42 | bounding box within N, S, E, W coordinates. 43 | network_type: str, optional, default: 'drive' 44 | Type of street network to obtain. 45 | custom_filter: str or None, optional, default: None 46 | Custom filter to be used instead of the predefined ones to query the Overpass API. 47 | An example of a custom filter is the following '[highway][!"footway"]'. 48 | For more information visit https://overpass-turbo.eu and https://taginfo.openstreetmap.org. 49 | 50 | Returns 51 | ------- 52 | response: json 53 | Response of the OpenStreetMao API service. 54 | """ 55 | north, south, west, east = bbox 56 | 57 | if custom_filter is not None: 58 | osm_filter = custom_filter 59 | else: 60 | osm_filter = get_filters(network_type=network_type) 61 | 62 | url_endpoint = 'https://maps.mail.ru/osm/tools/overpass/api/interpreter' 63 | 64 | timeout = 180 65 | out_resp = 'json' 66 | 67 | overpass_settings = f'[out:{out_resp}][timeout:{timeout}]' 68 | 69 | query_str = f'{overpass_settings};(way{osm_filter}({south}, {west}, {north}, {east});>;);out;' 70 | 71 | response_json = requests.post(url_endpoint, data={'data': query_str}) 72 | 73 | size_kb = len(response_json.content) / 1000 74 | print(f'Downloaded {size_kb:,.2f}kB') 75 | 76 | return response_json.json() 77 | -------------------------------------------------------------------------------- /pytrack/graph/graph.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from itertools import groupby 3 | 4 | import networkx as nx 5 | from shapely.geometry import Point, LineString 6 | 7 | import pytrack 8 | from . import distance 9 | from . import download 10 | 11 | useful_tags_way = [ 12 | "bridge", 13 | "tunnel", 14 | "oneway", 15 | "lanes", 16 | "ref", 17 | "name", 18 | "highway", 19 | "maxspeed", 20 | "service", 21 | "access", 22 | "area", 23 | "landuse", 24 | "width", 25 | "est_width", 26 | "junction", 27 | ] 28 | 29 | 30 | def graph_from_bbox(north, south, west, east, simplify=True, network_type='drive', custom_filter=None, buffer_dist=0): 31 | """ Create a graph from OpenStreetMap within some bounding box. 32 | 33 | Parameters 34 | ---------- 35 | north: float 36 | Northern latitude of bounding box. 37 | south: float 38 | southern latitude of bounding box. 39 | west: float 40 | Western longitude of bounding box. 41 | east: float 42 | Eastern longitude of bounding box. 43 | simplify: bool, optional, default: True 44 | if True, simplify graph topology with the ``simplify_graph`` method. 45 | network_type: str, optional, default: 'drive' 46 | Type of street network to obtain. 47 | custom_filter: str or None, optional, default: None 48 | Custom filter to be used instead of the predefined ones to query the Overpass API. 49 | buffer_dist: float, optional, default: 0 50 | Distance in meters indicating how much to expand the bounding box. 51 | Returns 52 | ------- 53 | G: networkx.MultiDiGraph 54 | Street network graph. 55 | """ 56 | bbox_buffer = distance.enlarge_bbox(north, south, west, east, buffer_dist) 57 | response_json = download.osm_download(bbox_buffer, network_type=network_type, custom_filter=custom_filter) 58 | G = create_graph(response_json) 59 | 60 | if simplify: 61 | G.graph["simplified"] = True 62 | G = _simplification(G, response_json) 63 | else: 64 | G.graph["simplified"] = False 65 | return G 66 | 67 | 68 | def _to_simplify(segment): 69 | """ Method that determines whether a segment of the graph must be simplified. 70 | 71 | Parameters 72 | ---------- 73 | segment: list 74 | Graph segment to be evaluated. A segment is a list of node IDs. 75 | Returns 76 | ------- 77 | ret: bool 78 | Whether or not to simplify a segment. 79 | """ 80 | if len(segment) > 2: 81 | return True 82 | else: 83 | return False 84 | 85 | 86 | def _simplification(G, response_json): 87 | """ Method that simplify a networkx graph. 88 | 89 | Parameters 90 | ---------- 91 | G: networkx.MultiDiGraph 92 | Street network graph. 93 | response_json: json 94 | Response of OpenStreetMap API service got with``osm_download``method 95 | Returns 96 | ------- 97 | G: networkx.MultiDiGraph 98 | Simplified street network graph. 99 | """ 100 | _, paths = get_nodes_edges(response_json) 101 | for path in paths.values(): 102 | is_oneway = _is_oneway(path, False) 103 | 104 | nodes = path.pop("nodes") 105 | 106 | junction = [False, False] 107 | if is_oneway: 108 | junction[1:1] = [True if G.degree[node] > 2 else False for node in nodes[1:-1]] # Changed >3 to >2 V2.0.4 109 | else: 110 | junction[1:1] = [True if G.degree[node] > 4 else False for node in nodes[1:-1]] 111 | 112 | all_nodes_to_remove = [] 113 | all_edges_to_add = [] 114 | 115 | for segment in _split_at_values(nodes, junction): 116 | if _to_simplify(segment): 117 | all_edges_to_add.append(_build_edge(G, segment, path, is_oneway, reverse=False)) 118 | 119 | if not is_oneway: 120 | all_edges_to_add.append(_build_edge(G, segment, path, is_oneway, reverse=True)) 121 | 122 | all_nodes_to_remove.extend(segment[1:-1]) 123 | 124 | G.remove_nodes_from(set(all_nodes_to_remove)) 125 | 126 | for edge in all_edges_to_add: 127 | G.add_edge(edge["origin"], edge["destination"], **edge["attr_dict"]) 128 | 129 | return G 130 | 131 | 132 | def _build_edge_attribute(G, segment, segment_attributes, is_oneway): 133 | """ Method that build the dictionary of attributes of a segment. 134 | 135 | Parameters 136 | ---------- 137 | G: networkx.MultiDiGraph 138 | Street network graph. 139 | segment: list 140 | Graph segment to be evaluated. A segment is a list of node IDs. 141 | segment_attributes: dict 142 | Dictionary of road segment attributes. 143 | is_oneway: bool 144 | Indicates whether a street segment is oneway. 145 | Returns 146 | ------- 147 | attribute: dict 148 | Dictionary of road segment attributes. 149 | """ 150 | attribute = segment_attributes.copy() 151 | # attribute = {k: segment_attributes[k] for k in useful_tags_way if k in segment_attributes} 152 | 153 | attribute["geometry"] = LineString([Point((G.nodes[node]["x"], 154 | G.nodes[node]["y"])) for node in segment]) 155 | attribute["oneway"] = is_oneway 156 | 157 | lat, lon = zip(*[(G.nodes[node]["y"], G.nodes[node]["x"]) for node in segment]) 158 | edge_lengths = sum(distance.haversine_dist(lat[:-1], lon[:-1], lat[1:], lon[1:])).round(3) 159 | 160 | attribute["length"] = edge_lengths 161 | 162 | return attribute 163 | 164 | 165 | def _build_edge(G, segment, segment_attributes, is_oneway, reverse=False): 166 | """ Method that builds an edge of the network street graph. 167 | 168 | Parameters 169 | ---------- 170 | G: networkx.MultiDiGraph 171 | Street network graph. 172 | segment: list 173 | Graph segment to be evaluated. A segment is a list of node IDs. 174 | segment_attributes: dict 175 | Dictionary of road segment attributes. 176 | is_oneway: bool 177 | Indicates whether a street segment is oneway. 178 | reverse: bool, optional, default: False 179 | Indicates whether invert the direction of the edge. 180 | Returns 181 | ------- 182 | edge: dict 183 | Edges to be added to the street network graph. 184 | """ 185 | if not reverse: 186 | segment_attributes = _build_edge_attribute(G, segment, segment_attributes, is_oneway) 187 | 188 | edge = {"origin": segment[0], "destination": segment[-1], 189 | "attr_dict": segment_attributes} 190 | return edge 191 | else: 192 | segment_attributes = _build_edge_attribute(G, segment[::-1], segment_attributes, is_oneway) 193 | edge = {"origin": segment[-1], "destination": segment[0], 194 | "attr_dict": segment_attributes} 195 | return edge 196 | 197 | 198 | def _split_at_values(nodes, junction): 199 | """ Method that creates a networkx.MultiDiGraph representing a network street graph. 200 | 201 | Parameters 202 | ---------- 203 | nodes: list 204 | List of node IDs. 205 | junction: list 206 | It is a list of boolean conditions. If true, split the segment, otherwise not. 207 | Returns 208 | ------- 209 | ret: generator 210 | Street network graph. 211 | """ 212 | indices = [i for i, x in enumerate(nodes) if junction[i]] 213 | for start, end in zip([0, *indices], [*indices, len(nodes)]): 214 | yield nodes[start:end + 1] 215 | 216 | 217 | def create_graph(response_json): 218 | """ Method that creates a networkx.MultiDiGraph representing a network street graph. 219 | 220 | Parameters 221 | ---------- 222 | response_json: json 223 | Response of OpenStreetMap API service got with``osm_download``method. 224 | Returns 225 | ------- 226 | G: networkx.MultiDiGraph 227 | Street network graph. 228 | """ 229 | # TODO: nella creazione del grafo rimuovi intersezioni multiple, in modo tale che c'è un solo nodo intersezione 230 | # per grafo create the graph as a MultiDiGraph and set its meta-attributes 231 | metadata = { 232 | 'created_date': "{:%Y-%m-%d %H:%M:%S}".format(dt.datetime.now()), 233 | 'created_with': f"PyTrack {pytrack.__version__}", 234 | 'crs': "epsg:4326", 235 | 'geometry': False 236 | } 237 | G = nx.MultiDiGraph(**metadata) 238 | 239 | nodes, paths = get_nodes_edges(response_json) 240 | 241 | # add each osm node to the graph 242 | for node, data in nodes.items(): 243 | G.add_node(node, **data) 244 | 245 | add_edges(G, paths, bidirectional=False) 246 | 247 | # add length (haversine distance between nodes) attribute to each edge 248 | if len(G.edges) > 0: 249 | G = distance.add_edge_lengths(G) 250 | 251 | return G 252 | 253 | 254 | def _oneway_path_values(path): 255 | """ Checks whether an OSM path is oneway. 256 | 257 | Parameters 258 | ---------- 259 | path: dict 260 | Dictionary that describes an OSM path. 261 | Returns 262 | ------- 263 | ret: dict 264 | Indicates whether an OSM path is oneway. 265 | """ 266 | return {path[key] for key in path.keys() if key.startswith("oneway")} # Removed 'and path[key] == "no"' v2.0.4 267 | 268 | 269 | def _is_oneway(path, bidirectional): 270 | """ Checks whether an OSM path is oneway. 271 | 272 | Parameters 273 | ---------- 274 | path: dict 275 | Dictionary that describes an OSM path. 276 | bidirectional: bool 277 | Indicates whether an edge is bidirectional. 278 | Returns 279 | ------- 280 | is_oneway: bool 281 | Indicates whether an OSM path is oneway. 282 | """ 283 | 284 | no_oneway_values = {"no", "false", "0", "reversible", "alternating"} 285 | 286 | oneway_path_values = _oneway_path_values(path) 287 | is_oneway = oneway_path_values.isdisjoint(no_oneway_values) and ( 288 | not not oneway_path_values) and not bidirectional 289 | 290 | return is_oneway 291 | 292 | 293 | def _is_reversed(path): 294 | """ Checks whether an OSM path is reversed. 295 | 296 | Parameters 297 | ---------- 298 | path: dict 299 | Dictionary that describes an OSM path. 300 | Returns 301 | ------- 302 | is_reversed: bool 303 | Indicates whether an OSM path is reversed. 304 | """ 305 | reversed_values = {"-1", "reverse", "T"} 306 | 307 | oneway_path_values = _oneway_path_values(path) 308 | is_reversed = not oneway_path_values.isdisjoint(reversed_values) 309 | 310 | return is_reversed 311 | 312 | 313 | def add_edges(G, paths, bidirectional=False, all_oneway=False): 314 | """ Add OSM edges to a ``networkx.MultiDiGraph``. 315 | 316 | Parameters 317 | ---------- 318 | G: networkx.MultiDiGraph 319 | Street network graph. 320 | paths: dict 321 | Dictionary of OSM paths. 322 | bidirectional: bool, optional, default: False 323 | Indicates whether an edge is bidirectional. 324 | all_oneway: bool, optional, default: False 325 | Indicates whether an edge is oneway. 326 | """ 327 | 328 | # TODO: "junction": "roundabout" è oneway 329 | for path in paths.values(): 330 | nodes = path.pop('nodes') 331 | 332 | is_oneway = _is_oneway(path, bidirectional) 333 | is_reversed = _is_reversed(path) 334 | 335 | if is_oneway and is_reversed: 336 | nodes.reverse() 337 | 338 | if not all_oneway: 339 | path['oneway'] = is_oneway 340 | 341 | edges = list(zip(nodes[:-1], nodes[1:])) 342 | if not is_oneway: 343 | edges.extend([(v, u) for u, v in edges]) 344 | 345 | G.add_edges_from(edges, **path) 346 | 347 | 348 | def convert_edge(element): 349 | """ Convert an OSM edge into an edge to construct a street network graph. 350 | 351 | Parameters 352 | ---------- 353 | element: dict 354 | An OSM path. 355 | Returns 356 | ------- 357 | path: dict 358 | Dictionary for an OSM path. 359 | """ 360 | # add OSM path id and remove consecutive duplicate nodes in the list of path's nodes 361 | path = {'osmid': element['id'], 'nodes': [group for group, _ in groupby(element['nodes'])]} 362 | 363 | # add tags 364 | path.update(element['tags']) 365 | 366 | return path 367 | 368 | 369 | def convert_node(element): 370 | """ Convert an OSM node into a node to construct a street network graph. 371 | 372 | Parameters 373 | ---------- 374 | element: dict 375 | An OSM node. 376 | Returns 377 | ------- 378 | path: dict 379 | Dictionary for an OSM node. 380 | """ 381 | 382 | # add node's GPS coordinates 383 | node = {'y': element['lat'], 'x': element['lon']} 384 | 385 | # add tags 386 | node.update(element['tags']) if "tags" in element else None 387 | 388 | return node 389 | 390 | 391 | def get_nodes_edges(response_json): 392 | """ Extract nodes and paths from the OpenStreetMap query response. 393 | 394 | Parameters 395 | ---------- 396 | response_json: json 397 | Response of OpenStreetMap API service got with``osm_download``method 398 | Returns 399 | ------- 400 | nodes: dict 401 | Dictionary of OSM nodes. 402 | paths: dict 403 | Dictionary of OSM paths. 404 | """ 405 | 406 | nodes = dict() 407 | paths = dict() 408 | 409 | for element in response_json['elements']: 410 | if element['type'] == "node": 411 | nodes[element['id']] = convert_node(element) 412 | elif element['type'] == "way": 413 | paths[element['id']] = convert_edge(element) 414 | 415 | return nodes, paths 416 | -------------------------------------------------------------------------------- /pytrack/graph/utils.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | import pandas as pd 4 | from shapely.geometry import LineString, Point 5 | 6 | 7 | def get_unique_number(lon, lat): 8 | """ Assigns a unique identifier to a geographical coordinate. 9 | Parameters 10 | ---------- 11 | lon: float 12 | Longitude of the point 13 | lat: float 14 | Latitude of the point 15 | Returns 16 | ------- 17 | val: float 18 | Unique identifier. 19 | """ 20 | if isinstance(lat, str): 21 | lat_double = float(lat) 22 | else: 23 | lat_double = lat 24 | if isinstance(lon, str): 25 | lon_double = float(lon) 26 | else: 27 | lon_double = lon 28 | 29 | lat_int = int((lat_double * 10 ** abs(Decimal(str(lat_double)).as_tuple().exponent))) 30 | lon_int = int((lon_double * 10 ** abs(Decimal(str(lon_double)).as_tuple().exponent))) 31 | 32 | val = abs((lat_int << 16 & 0xffff0000) | (lon_int & 0x0000ffff)) 33 | val = val % 2147483647 34 | 35 | # alternative use hash or other 36 | # return int(str(lat_int)+str(lon_int)) 37 | return val 38 | 39 | 40 | def graph_to_gdfs(G, nodes=True, edges=True, node_geometry=True, edge_geometry=True): 41 | """ Convert a networkx.MultiDiGraph to node and/or edge pandas DataFrame. 42 | 43 | Parameters 44 | ---------- 45 | G: networkx.MultiDiGraph 46 | Street network graph. 47 | nodes: bool, optional, default: True 48 | Whether to extract graph nodes. 49 | edges: bool, optional, default: True 50 | Whether to extract graph edges. 51 | node_geometry: bool, optional, default: True 52 | Whether to compute graph node geometries. 53 | edge_geometry: bool, optional, default: True 54 | Whether to extract graph edge geometries. 55 | Returns 56 | ------- 57 | gdf_nodes: pandas.DataFrame 58 | Dataframe collecting graph nodes. 59 | gdf_edges: pandas.DataFrame 60 | Dataframe collecting graph edges 61 | """ 62 | crs = G.graph["crs"] 63 | 64 | if nodes: 65 | nodes, data = zip(*G.nodes(data=True)) 66 | 67 | if node_geometry: 68 | # convert node x/y attributes to Points for geometry column 69 | for d in data: 70 | d["geometry"] = Point(d["x"], d["y"]) 71 | gdf_nodes = pd.DataFrame(data) 72 | gdf_nodes.insert(loc=0, column='osmid', value=nodes) 73 | else: 74 | gdf_nodes = pd.DataFrame(data) 75 | gdf_nodes.insert(loc=0, column='osmid', value=nodes) 76 | 77 | if edges: 78 | u, v, k, data = zip(*G.edges(keys=True, data=True)) 79 | if edge_geometry: 80 | G.graph["geometry"] = True 81 | 82 | longs = G.nodes(data="x") 83 | lats = G.nodes(data="y") 84 | 85 | for d, src, tgt in zip(data, u, v): 86 | if "geometry" not in d: 87 | d["geometry"] = LineString((Point((longs[src], lats[src])), 88 | Point((longs[tgt], lats[tgt])))) 89 | gdf_edges = pd.DataFrame(data) 90 | 91 | else: 92 | gdf_edges = pd.DataFrame(data) 93 | gdf_edges["geometry"] = None 94 | gdf_edges.crs = crs 95 | 96 | gdf_edges["u"], gdf_edges["v"], gdf_edges["key"] = u, v, k 97 | 98 | gdf_edges = gdf_edges[["u", "v", "key"] + gdf_edges.columns.to_list()[:-3]] 99 | 100 | if nodes and edges: 101 | return gdf_nodes, gdf_edges 102 | elif nodes: 103 | return gdf_nodes 104 | elif edges: 105 | return gdf_edges 106 | -------------------------------------------------------------------------------- /pytrack/matching/__init__.py: -------------------------------------------------------------------------------- 1 | # This lets you use package.module.Class as package.Class in your code. 2 | from .candidate import Candidate 3 | 4 | # This lets Sphinx know you want to document package.module.Class as package.Class. 5 | __all__ = ['Candidate'] -------------------------------------------------------------------------------- /pytrack/matching/candidate.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | import numpy as np 4 | import pandas as pd 5 | from sklearn.neighbors import BallTree 6 | 7 | from pytrack.graph import distance, utils 8 | 9 | 10 | class Candidate: 11 | """ Class to represent a candidate element. 12 | 13 | Parameters 14 | ---------- 15 | node_id: str 16 | OSM node ID. 17 | edge_osmid: str 18 | OSM edge ID. 19 | obs: tuple 20 | Coordinate of actual GPS points. 21 | great_dist: float 22 | Distance between candidate and actual GPS point. 23 | coord: tuple 24 | Candidate coordinate. 25 | Returns 26 | ------- 27 | Candidate object 28 | """ 29 | __slots__ = ["node_id", "edge_osmid", "obs", "great_dist", "coord"] 30 | 31 | def __init__(self, node_id, edge_osmid, obs, great_dist, coord): 32 | self.node_id = node_id 33 | self.edge_osmid = edge_osmid 34 | self.obs = obs 35 | self.great_dist = great_dist 36 | self.coord = coord 37 | 38 | 39 | def get_candidates(G, points, interp_dist=1, closest=True, radius=10): 40 | """ Extract candidate points for Hidden-Markov Model map-matching approach. 41 | 42 | Parameters 43 | ---------- 44 | G: networkx.MultiDiGraph 45 | Street network graph. 46 | points: list 47 | The actual GPS points. 48 | interp_dist: float, optional, default: 1 49 | Step to interpolate the graph. The smaller the interp_dist, the greater the precision and the longer 50 | the computational time. 51 | closest: bool, optional, default: True 52 | If true, only the closest point is considered for each edge. 53 | radius: float, optional, default: 10 54 | Radius of the search circle. 55 | Returns 56 | ------- 57 | G: networkx.MultiDiGraph 58 | Street network graph. 59 | results: dict 60 | Results to be used for the map-matching algorithm. 61 | """ 62 | 63 | G = G.copy() 64 | 65 | if interp_dist: 66 | if G.graph["geometry"]: 67 | G = distance.interpolate_graph(G, dist=interp_dist) 68 | else: 69 | _ = utils.graph_to_gdfs(G, nodes=False) 70 | G = distance.interpolate_graph(G, dist=interp_dist) 71 | 72 | geoms = utils.graph_to_gdfs(G, nodes=False).set_index(["u", "v"])[["osmid", "geometry"]] 73 | 74 | uv_xy = [[u, osmid, np.deg2rad(xy)] for uv, geom, osmid in 75 | zip(geoms.index, geoms.geometry.values, geoms.osmid.values) 76 | for u, xy in zip(uv, geom.coords[:])] 77 | 78 | index, osmid, xy = zip(*uv_xy) 79 | 80 | nodes = pd.DataFrame(xy, index=osmid, columns=["x", "y"])[["y", "x"]] 81 | 82 | ball = BallTree(nodes, metric='haversine') 83 | 84 | idxs, dists = ball.query_radius(np.deg2rad(points), radius / distance.EARTH_RADIUS_M, return_distance=True) 85 | dists = dists * distance.EARTH_RADIUS_M # radians to meters 86 | 87 | if closest: 88 | results = dict() 89 | for i, (point, idx, dist) in enumerate(zip(points, idxs, dists)): 90 | df = pd.DataFrame({"osmid": list(np.array(index)[idx]), 91 | "edge_osmid": list(nodes.index[idx]), 92 | "coords": tuple(map(tuple, np.rad2deg(nodes.values[idx]))), 93 | "dist": dist}) 94 | 95 | df = df.loc[df.groupby('edge_osmid')['dist'].idxmin()].reset_index(drop=True) 96 | 97 | results[i] = {"observation": point, 98 | "osmid": list(df["osmid"]), 99 | "edge_osmid": list(df["edge_osmid"]), 100 | "candidates": list(df["coords"]), 101 | "candidate_type": np.full(len(df["coords"]), False), 102 | "dists": list(df["dist"])} 103 | 104 | else: 105 | results = {i: {"observation": point, 106 | "osmid": list(np.array(index)[idx]), 107 | "edge_osmid": list(nodes.index[idx]), 108 | "candidates": list(map(tuple, np.rad2deg(nodes.values[idx]))), 109 | "candidate_type": np.full(len(nodes.index[idx]), False), 110 | "dists": list(dist)} for i, (point, idx, dist) in enumerate(zip(points, idxs, dists))} 111 | 112 | no_cands = [node_id for node_id, cand in results.items() if not cand["candidates"]] 113 | 114 | if no_cands: 115 | for cand in no_cands: 116 | del results[cand] 117 | print(f"A total of {len(no_cands)} points has no candidates: {*no_cands,}") 118 | return G, results 119 | 120 | 121 | def elab_candidate_results(results, predecessor): 122 | """ Elaborate results of ``candidate.get_candidates`` method. It selects which candidate best matches the actual 123 | GPS points. 124 | 125 | Parameters 126 | ---------- 127 | results: dict 128 | Output of ``candidate.get_candidates`` method. 129 | predecessor: dict 130 | Output of ``mpmatching.viterbi_search`` method. 131 | Returns 132 | ------- 133 | results: dict 134 | Elaborated results. 135 | """ 136 | results = copy.deepcopy(results) 137 | for key in list(results.keys())[:-1]: 138 | win_cand_idx = int(predecessor[str(key + 1)].split("_")[1]) 139 | results[key]["candidate_type"][win_cand_idx] = True 140 | 141 | win_cand_idx = int(predecessor["target"].split("_")[1]) 142 | results[list(results.keys())[-1]]["candidate_type"][win_cand_idx] = True 143 | return results 144 | -------------------------------------------------------------------------------- /pytrack/matching/cleaning.py: -------------------------------------------------------------------------------- 1 | from pytrack.graph import distance 2 | 3 | 4 | def veldist_filter(traj, th_dist=5, th_vel=3): 5 | """ It filters the GPS trajectory combining speed and distance between adjacent points. 6 | If the adjacent point distance does not exceed the threshold and the speed is less than th_vel (m/s), the current 7 | trajectory point is ignored. 8 | 9 | Parameters 10 | ---------- 11 | traj: pandas.DataFrame 12 | Dataframe containing 3 columns [timestamp, latitude, longitude]. 13 | th_dist: float, optional, default: 5 meters. 14 | Threshold for the distance of adjacent points. 15 | th_vel: float, optional, default: 3 m/s. 16 | Threshold for the velocity. 17 | 18 | Returns 19 | ------- 20 | df: pandas.DataFrame 21 | Filtered version of the input dataframe. 22 | """ 23 | 24 | df = traj.copy() 25 | 26 | i = 0 27 | while True: 28 | if i == df.shape[0]-1: 29 | break 30 | deltat = (df["datetime"][i+1]-df["datetime"][i]).total_seconds() 31 | dist = distance.haversine_dist(*tuple(df.iloc[i, [1, 2]]), *tuple(df.iloc[i+1, [1, 2]])) 32 | 33 | if dist < th_dist and dist/deltat < th_vel: 34 | df.drop([i+1], inplace=True) 35 | df.reset_index(drop=True, inplace=True) 36 | else: 37 | i += 1 38 | 39 | return df 40 | 41 | 42 | def park_filter(traj, th_dist=50, th_time=30): 43 | """ It removes parking behaviour by eliminating those points that remain in a certain area 44 | for a given amount of time. 45 | 46 | Parameters 47 | ---------- 48 | traj: pandas.DataFrame 49 | Dataframe containing 3 columns [timestamp, latitude, longitude]. 50 | th_dist: float, optional, default: 50 meters. 51 | Threshold for the distance of adjacent points. 52 | th_time: float, optional, default: 30 min. 53 | Threshold for the delta time. 54 | 55 | Returns 56 | ------- 57 | df: pandas.DataFrame 58 | Filtered version of the input dataframe. 59 | """ 60 | 61 | df = traj.copy() 62 | 63 | i = 0 64 | while True: 65 | if i == df.shape[0]-1: 66 | break 67 | deltat = (df["datetime"][i+1]-df["datetime"][i]).total_seconds() 68 | deltad = distance.haversine_dist(*tuple(df.iloc[i, [1, 2]]), *tuple(df.iloc[i+1, [1, 2]])) 69 | 70 | if deltad < th_dist and deltat > th_time: 71 | df.drop([i+1], inplace=True) 72 | df.reset_index(drop=True, inplace=True) 73 | else: 74 | i += 1 75 | 76 | return df 77 | 78 | -------------------------------------------------------------------------------- /pytrack/matching/matcher.py: -------------------------------------------------------------------------------- 1 | class Matcher: 2 | """ Skeleton parent class to perform the matching operation. 3 | """ 4 | def __init__(self, G): 5 | self.G = G 6 | 7 | def match(self, points): 8 | pass 9 | -------------------------------------------------------------------------------- /pytrack/matching/mpmatching.py: -------------------------------------------------------------------------------- 1 | import math 2 | from collections import deque 3 | 4 | from . import mpmatching_utils 5 | 6 | 7 | def viterbi_search(G, trellis, start="start", target="target", beta=mpmatching_utils.BETA, 8 | sigma=mpmatching_utils.SIGMA_Z): 9 | """ Function to compute viterbi search and perform Hidden-Markov Model map-matching. 10 | 11 | Parameters 12 | ---------- 13 | G: networkx.MultiDiGraph 14 | Street network graph. 15 | trellis: 16 | start: str, optional, default: "start" 17 | Starting node. 18 | target: str, optional, default: "target" 19 | Target node. 20 | beta: float 21 | This describes the difference between route distances and great circle distances. See https://www.ismll.uni-hildesheim.de/lehre/semSpatial-10s/script/6.pdf 22 | for a more detailed description of its calculation. 23 | 24 | sigma: float 25 | It is an estimate of the magnitude of the GPS error. See https://www.ismll.uni-hildesheim.de/lehre/semSpatial-10s/script/6.pdf 26 | for a more detailed description of its calculation. 27 | 28 | Returns 29 | ------- 30 | joint_prob: dict 31 | Joint probability for each node. 32 | predecessor: dict 33 | Predecessor for each node. 34 | 35 | Notes 36 | ----- 37 | See https://www.ismll.uni-hildesheim.de/lehre/semSpatial-10s/script/6.pdf for a more detailed description of this 38 | method. 39 | """ 40 | 41 | # Initialize joint probability for each node 42 | joint_prob = {} 43 | for u_name in trellis.nodes(): 44 | joint_prob[u_name] = -float('inf') 45 | predecessor = {} 46 | predecessor_val = {} 47 | 48 | queue = deque() 49 | 50 | queue.append(start) 51 | joint_prob[start] = math.log10(mpmatching_utils.emission_prob(trellis.nodes[start]["candidate"], sigma)) 52 | predecessor[start] = None 53 | 54 | while queue: 55 | # Extract node u 56 | u_name = queue.popleft() 57 | u = trellis.nodes[u_name]["candidate"] 58 | 59 | if u_name == target: 60 | break 61 | for v_name in trellis.successors(u_name): 62 | v = trellis.nodes[v_name]["candidate"] 63 | 64 | try: 65 | new_prob = joint_prob[u_name] + math.log10(mpmatching_utils.emission_prob(v, sigma)) \ 66 | + math.log10(mpmatching_utils.transition_prob(G, u, v, beta)) 67 | 68 | if joint_prob[v_name] < new_prob: 69 | joint_prob[v_name] = new_prob 70 | if v_name not in predecessor: 71 | predecessor[v_name] = u_name 72 | predecessor_val[v_name] = new_prob 73 | elif v_name in predecessor and predecessor_val[v_name] < new_prob: 74 | predecessor[v_name] = u_name 75 | predecessor_val[v_name] = new_prob 76 | if v_name not in queue: 77 | queue.append(v_name) 78 | 79 | except Exception as error: 80 | print(error) 81 | 82 | predecessor = mpmatching_utils.get_predecessor("target", predecessor) 83 | 84 | return joint_prob[target], predecessor 85 | -------------------------------------------------------------------------------- /pytrack/matching/mpmatching_utils.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import math 3 | 4 | import networkx as nx 5 | from shapely.geometry import LineString 6 | 7 | from pytrack.graph import distance 8 | from pytrack.matching import candidate 9 | 10 | SIGMA_Z = 4.07 11 | BETA = 3 12 | 13 | 14 | def _emission_prob(dist, sigma=SIGMA_Z): 15 | """ Compute emission probability of a node 16 | 17 | Parameters 18 | ---------- 19 | dist: float 20 | Distance between a real GPS point and a candidate node. 21 | sigma: float, optional, default: SIGMA_Z 22 | It is an estimate of the magnitude of the GPS error. See https://www.ismll.uni-hildesheim.de/lehre/semSpatial-10s/script/6.pdf 23 | for a more detailed description of its calculation. 24 | 25 | Returns 26 | ------- 27 | ret: float 28 | Emission probability of a node. 29 | """ 30 | c = 1 / (sigma * math.sqrt(2 * math.pi)) 31 | return c * math.exp(-(dist / sigma) ** 2) 32 | 33 | 34 | # A gaussian distribution 35 | def emission_prob(u, sigma=SIGMA_Z): 36 | """ Compute emission probability of a node 37 | 38 | Parameters 39 | ---------- 40 | u: pytrack.matching.Candidate 41 | Node of the graph. 42 | sigma: float, optional, default: SIGMA_Z 43 | It is an estimate of the magnitude of the GPS error. See https://www.ismll.uni-hildesheim.de/lehre/semSpatial-10s/script/6.pdf 44 | for a more detailed description of its calculation. 45 | 46 | Returns 47 | ------- 48 | ret: float 49 | Emission probability of a node. 50 | """ 51 | if u.great_dist: 52 | c = 1 / (sigma * math.sqrt(2 * math.pi)) 53 | return c * math.exp(-(u.great_dist / sigma) ** 2) 54 | else: 55 | return 1 56 | 57 | 58 | # A empirical distribution 59 | def transition_prob(G, u, v, beta=BETA): 60 | """ Compute transition probability between node u and v. 61 | 62 | Parameters 63 | ---------- 64 | G: networkx.MultiDiGraph 65 | Road network graph. 66 | u: dict 67 | Starting node of the graph. 68 | v: dict 69 | Target node of the graph. 70 | beta: float 71 | This describes the difference between route distances and great circle distances. See https://www.ismll.uni-hildesheim.de/lehre/semSpatial-10s/script/6.pdf 72 | for a more detailed description of its calculation. 73 | 74 | Returns 75 | ------- 76 | ret: float 77 | Transition probability between node u and v. 78 | """ 79 | c = 1 / beta 80 | 81 | if u.great_dist and v.great_dist: 82 | delta = abs( 83 | nx.shortest_path_length(G, u.node_id, v.node_id, weight="length", method='dijkstra') 84 | - distance.haversine_dist(*u.coord, *v.coord)) 85 | return c * math.exp(-delta / beta) 86 | else: 87 | return 1 88 | 89 | 90 | def create_trellis(results): 91 | """ Create a Trellis graph. 92 | 93 | Parameters 94 | ---------- 95 | results: dict 96 | Output of ``candidate.get_candidates`` method. 97 | Returns 98 | ------- 99 | G: networkx.DiGraph 100 | A directed acyclic Trellis graph. 101 | """ 102 | 103 | G = nx.DiGraph() 104 | 105 | prev_nodes = ["start"] 106 | G.add_node("start", candidate=candidate.Candidate("start", None, None, None, None)) 107 | G.add_node("target", candidate=candidate.Candidate("start", None, None, None, None)) 108 | 109 | for idx, item in results.items(): 110 | obs = item["observation"] 111 | nodes, data_node = zip( 112 | *[(f"{idx}_{j}", {"candidate": candidate.Candidate(node_id, edge_osmid, obs, dist, cand)}) 113 | for j, (node_id, edge_osmid, dist, cand) in 114 | enumerate(zip(item["osmid"], item["edge_osmid"], item["dists"], item["candidates"]))]) 115 | edges = [(u, v) for u in prev_nodes for v in nodes] 116 | 117 | G.add_nodes_from(zip(nodes, data_node)) 118 | G.add_edges_from(edges) 119 | prev_nodes = nodes 120 | 121 | edges = [(u, "target") for u in prev_nodes] 122 | G.add_edges_from(edges) 123 | return G 124 | 125 | 126 | def create_path(G, trellis, predecessor): 127 | """ Create the path that best matches the actual GPS data. 128 | 129 | Parameters 130 | ---------- 131 | G: networkx.MultiDiGraph 132 | Road network graph. 133 | trellis: networkx.DiGraph 134 | A directed acyclic graph. 135 | predecessor: dict 136 | Predecessor for each node. 137 | Returns 138 | ------- 139 | path_elab: list 140 | List of node IDs. 141 | """ 142 | u, v = list(zip(*[(u, v) for v, u in predecessor.items()][::-1])) 143 | path = [(u, v) for u, v in zip(u, u[1:])] 144 | 145 | path_elab = [node for u, v in path for node in nx.shortest_path(G, trellis.nodes[u]["candidate"].node_id, 146 | trellis.nodes[v]["candidate"].node_id, 147 | weight='length')] 148 | path_elab = [k for k, g in itertools.groupby(path_elab)] 149 | return path_elab 150 | 151 | 152 | def create_matched_path(G, trellis, predecessor): 153 | """ Create the path that best matches the actual GPS points. Route created based on results obtained from ``pmatching_utils.viterbi_search`` and ``mpmatching_utils.create_trellis`` methods. 154 | 155 | Parameters 156 | ---------- 157 | G: networkx.MultiDiGraph 158 | Street network graph used to create trellis graph. 159 | trellis: networkx.DiGraph 160 | A directed acyclic Trellis graph. 161 | predecessor: dict 162 | Predecessor for each node. 163 | 164 | Returns 165 | ------- 166 | node_ids: list 167 | List of ids of the nodes that compose the path. 168 | path_coords: list 169 | List of nodes' coordinates, in the form of tuple (lat, lon), composing the path. 170 | """ 171 | node_ids = create_path(G, trellis, predecessor) 172 | path_coords = [(lat, lng) for lng, lat in LineString([G.nodes[node]["geometry"] for node in node_ids]).coords] 173 | return node_ids, path_coords 174 | 175 | 176 | def get_predecessor(target, predecessor): 177 | """ Reconstruct predecessor dictionary of a decoded trellis DAG. 178 | 179 | Parameters 180 | ---------- 181 | target: str 182 | Target node of the trellis DAG. 183 | predecessor: dict 184 | Dictionary containing the predecessors of the nodes of a decoded Trellis DAG. 185 | Returns 186 | ------- 187 | pred_elab: dict 188 | Dictionary containing the predecessors of the best nodes of a decoded Trellis DAG. 189 | """ 190 | pred_elab = {} 191 | pred = predecessor[target] 192 | while pred != "start": 193 | pred_elab[target.split("_")[0]] = pred 194 | target = pred 195 | pred = predecessor[target] 196 | return pred_elab 197 | -------------------------------------------------------------------------------- /pytrack/video/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosbidev/PyTrack/45c0b7bfcfe815f4f90545e3e5079d0c75028512/pytrack/video/__init__.py -------------------------------------------------------------------------------- /pytrack/video/create_video.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | 3 | 4 | def make_video(images, output_path, fps=1, size=(640, 640), is_color=True): 5 | """ Makes video using xvid codec. Increase FPS for faster timelapse. 6 | 7 | Parameters 8 | ---------- 9 | images: list 10 | List of the image paths to be added as frames to the video. 11 | output_path: str 12 | Path to output video, must end with .avi. 13 | fps: int, optional, default: 1 14 | Desired frame rate. 15 | size: tuple, optional, default: (640, 640) 16 | Size of the video frames. 17 | is_color: bool, optional, default: True 18 | If it is True, the encoder will expect and encode color frames, otherwise it will work with grayscale frames. 19 | 20 | :return: The function does not return anything. It directly saves the video at the position indicated in output_path. 21 | """ 22 | fourcc = cv2.VideoWriter_fourcc(*"XVID") 23 | vid = cv2.VideoWriter(output_path, fourcc, fps, size, is_color) 24 | for image in images: 25 | vid.write(cv2.imread(image)) 26 | vid.release() 27 | cv2.destroyAllWindows() 28 | -------------------------------------------------------------------------------- /pytrack/video/streetview.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from pathlib import Path 4 | 5 | import requests 6 | 7 | 8 | def extract_streetview_pic(point, api_key, size="640x640", heading=90, pitch=-10): 9 | """ Extract street view pic. 10 | 11 | Parameters 12 | ---------- 13 | point: tuple 14 | The lat/lng value of desired location. 15 | api_key: str 16 | Street View Static API key. It allows you to monitor your application's API usage in the Google Cloud Console, 17 | and ensures that Google can contact you about your application if necessary. 18 | size: str, optional, default: "640x640" 19 | Specifies the output size of the image in pixels. 20 | heading: int, optional, default: 90 21 | indicates the compass heading of the camera. Accepted values are from 0 to 360 (both values indicating North, 22 | with 90 indicating East, and 180 South). If no heading is specified, a value will be calculated that directs 23 | the camera towards the specified location, from the point at which the closest photograph was taken. 24 | pitch: int, optional, default: -10 25 | specifies the up or down angle of the camera relative to the Street View vehicle. This is often, but not always, 26 | flat horizontal. Positive values angle the camera up (with 90 degrees indicating straight up); negative values 27 | angle the camera down (with -90 indicating straight down). 28 | 29 | Returns 30 | ------- 31 | pic: request.content 32 | Image get from Street View. 33 | meta: json 34 | Data about Street View panoramas. 35 | """ 36 | meta_base = 'https://maps.googleapis.com/maps/api/streetview/metadata?' 37 | pic_base = 'https://maps.googleapis.com/maps/api/streetview?' 38 | 39 | lat = point[0] 40 | lon = point[1] 41 | 42 | meta_params = {'key': api_key, 43 | 'location': f'{lat}, {lon}', 44 | 'source': 'outdoor'} 45 | 46 | pic_params = { 47 | 'size': size, # max 640x640 pixels 48 | 'location': f'{lat}, {lon}', 49 | 'heading': str(heading), 50 | 'pitch': str(pitch), 51 | 'key': api_key, 52 | 'source': 'outdoor' 53 | } 54 | 55 | # Do the request and get the response data 56 | meta_response = requests.get(meta_base, params=meta_params) 57 | meta = meta_response.json() 58 | meta_response.close() 59 | # if (meta["pano_id"] != PREV_PAN_ID) and (meta["status"] == "OK"): 60 | # PREV_PAN_ID = meta["pano_id"] 61 | # pic_response = requests.get(pic_base, params=pic_params) 62 | 63 | # pic = pic_response.content 64 | # pic_response.close() 65 | # else: 66 | # meta = None 67 | # pic = None 68 | pic_response = requests.get(pic_base, params=pic_params) 69 | pic = pic_response.content 70 | pic_response.close() 71 | 72 | return pic, meta 73 | 74 | 75 | def save_streetview(pic, meta, folder_path): 76 | """ Save streetview pic and metadata in the desired path. 77 | 78 | Parameters 79 | ---------- 80 | pic: request.content 81 | Image get from Street View. 82 | meta: json 83 | Data about Street View panoramas. 84 | folder_path: str 85 | Desired path of the folder where save pic and metadata. 86 | 87 | :return: The function does not return anything. It directly saves the pic and metadata at the position indicated in folder_path. 88 | """ 89 | Path(os.path.join(folder_path)).mkdir(parents=True, exist_ok=True) 90 | 91 | with open(os.path.join(folder_path, 'pic.png'), 'wb') as file: 92 | file.write(pic) 93 | 94 | with open(os.path.join(folder_path, 'metadata.json'), 'w+') as out_file: 95 | json.dump(meta, out_file) 96 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy~=1.23.2 2 | pandas~=1.4.4 3 | networkx~=2.8.6 4 | shapely~=1.8.4 5 | pyproj~=3.3.1 6 | requests~=2.28.1 7 | scikit-learn~=1.1.2 8 | folium~=0.13.0 9 | matplotlib~=3.5.3 10 | setuptools~=65.3.0 11 | pydot~=1.4.2 12 | graphviz~=0.20.1 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | import os 3 | 4 | 5 | def strip_comments(line): 6 | return line.split('#', 1)[0].strip() 7 | 8 | 9 | def reqs(*f): 10 | return list(filter(None, [strip_comments(line) for line in open( 11 | os.path.join(os.getcwd(), *f)).readlines()])) 12 | 13 | 14 | with open("README.md", "r", encoding="utf-8") as fh: 15 | long_description = fh.read() 16 | 17 | setuptools.setup( 18 | name='PyTrack-lib', 19 | version='2.0.8', 20 | packages=setuptools.find_packages(), 21 | # namespace_packages=['pytrack'], 22 | url='https://github.com/cosbidev/PyTrack', 23 | license='BSD-3-Clause-Clear', 24 | author='Matteo Tortora', 25 | author_email='m.tortora@unicampus.it', 26 | description='a Map-Matching-based Python Toolbox for Vehicle Trajectory Reconstruction', 27 | long_description=long_description, 28 | long_description_content_type="text/markdown", 29 | classifiers=[ 30 | "Programming Language :: Python :: 3", 31 | "License :: OSI Approved :: BSD License", 32 | "Operating System :: OS Independent", 33 | "Topic :: Scientific/Engineering :: GIS", 34 | "Topic :: Scientific/Engineering :: Visualization", 35 | "Topic :: Scientific/Engineering :: Physics", 36 | "Topic :: Scientific/Engineering :: Mathematics", 37 | "Topic :: Scientific/Engineering :: Information Analysis" 38 | ], 39 | install_requires=reqs('requirements.txt') 40 | ) 41 | --------------------------------------------------------------------------------