├── .dockerignore ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── python-package.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── README_osx_package.txt ├── docker ├── Dockerfile └── Dockerfile-dev ├── docs └── images │ └── logo.png ├── mapillary_tools.spec ├── mapillary_tools ├── __init__.py ├── api_v4.py ├── authenticate.py ├── blackvue_parser.py ├── camm │ ├── camm_builder.py │ └── camm_parser.py ├── commands │ ├── __init__.py │ ├── __main__.py │ ├── authenticate.py │ ├── process.py │ ├── process_and_upload.py │ ├── sample_video.py │ ├── upload.py │ ├── video_process.py │ ├── video_process_and_upload.py │ └── zip.py ├── config.py ├── constants.py ├── exceptions.py ├── exif_read.py ├── exif_write.py ├── exiftool_read.py ├── exiftool_read_video.py ├── exiftool_runner.py ├── ffmpeg.py ├── geo.py ├── geotag │ ├── base.py │ ├── factory.py │ ├── geotag_images_from_exif.py │ ├── geotag_images_from_exiftool.py │ ├── geotag_images_from_gpx.py │ ├── geotag_images_from_gpx_file.py │ ├── geotag_images_from_nmea_file.py │ ├── geotag_images_from_video.py │ ├── geotag_videos_from_exiftool.py │ ├── geotag_videos_from_gpx.py │ ├── geotag_videos_from_video.py │ ├── image_extractors │ │ ├── base.py │ │ ├── exif.py │ │ └── exiftool.py │ ├── options.py │ ├── utils.py │ └── video_extractors │ │ ├── base.py │ │ ├── exiftool.py │ │ ├── gpx.py │ │ └── native.py ├── gpmf │ ├── gpmf_gps_filter.py │ ├── gpmf_parser.py │ └── gps_filter.py ├── history.py ├── ipc.py ├── mp4 │ ├── __init__.py │ ├── construct_mp4_parser.py │ ├── io_utils.py │ ├── mp4_sample_parser.py │ ├── simple_mp4_builder.py │ └── simple_mp4_parser.py ├── process_geotag_properties.py ├── process_sequence_properties.py ├── sample_video.py ├── telemetry.py ├── types.py ├── upload.py ├── upload_api_v4.py ├── uploader.py └── utils.py ├── mapillary_tools_folder.spec ├── mypy.ini ├── pyinstaller └── main.py ├── requirements-dev.txt ├── requirements.txt ├── schema └── image_description_schema.json ├── script ├── build_bootloader.ps1 ├── build_linux ├── build_osx └── build_win.ps1 ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── cli ├── __init__.py ├── blackvue_parser.py ├── camm_parser.py ├── exif_read.py ├── exif_write.py ├── exiftool_runner.py ├── gpmf_parser.py ├── gps_filter.py ├── process_sequence_properties.py ├── simple_mp4_builder.py ├── simple_mp4_parser.py └── upload_api_v4.py ├── data ├── adobe_coords │ └── adobe_coords.jpg ├── gopro_data │ ├── README │ ├── hero8.mp4 │ └── max-360mode.mp4 ├── gpx │ └── sf_30km_h.gpx ├── images │ ├── DSC00001.JPG │ ├── DSC00497.JPG │ ├── README │ └── V0370574.JPG └── videos │ ├── README │ └── sample-5s.mp4 ├── integration ├── __init__.py ├── fixtures.py ├── test_gopro.py ├── test_history.py ├── test_process.py ├── test_process_and_upload.py └── test_upload.py └── unit ├── __init__.py ├── data ├── corrupt_exif.jpg ├── corrupt_exif_2.jpg ├── empty_exif.jpg ├── fixed_exif.jpg ├── fixed_exif_2.jpg ├── mock_sample_video │ └── videos │ │ └── hello.mp4 └── test_exif.jpg ├── generate_test_image.py ├── test_blackvue_parser.py ├── test_camm_parser.py ├── test_config.py ├── test_description.py ├── test_exceptions.py ├── test_exifedit.py ├── test_exifread.py ├── test_ffmpeg.py ├── test_geo.py ├── test_gpmf_parser.py ├── test_gps_filter.py ├── test_io_utils.py ├── test_mp4_sample_parser.py ├── test_sample_video.py ├── test_sequence_processing.py ├── test_simple_mp4_builder.py ├── test_simple_mp4_parser.py ├── test_types.py ├── test_upload_api_v4.py ├── test_uploader.py └── test_utils.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore generated files 2 | __pycache__ 3 | /venv/ 4 | /dist/ 5 | /publish/ 6 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '35 9 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | # Runs your workflow when activity on a pull request in the workflow's repository occurs. 8 | # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request 9 | pull_request: 10 | # only run on pull requests that target specific branches 11 | branches: [main] 12 | push: 13 | branches: [main] 14 | 15 | jobs: 16 | build: 17 | strategy: 18 | matrix: 19 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 20 | platform: ["ubuntu-latest", "macos-latest", "windows-latest"] 21 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 22 | # Optional - x64 or x86 architecture, defaults to x64 23 | # architecture: "x64" 24 | 25 | runs-on: ${{ matrix.platform }} 26 | 27 | defaults: 28 | run: 29 | working-directory: ./main 30 | 31 | steps: 32 | # https://github.com/actions/checkout#Checkout-multiple-repos-side-by-side 33 | # pull into mapillary/mapillary_tools/main 34 | - uses: actions/checkout@v4 35 | with: 36 | path: main 37 | 38 | - name: Set up Python ${{ matrix.python-version }} 39 | uses: actions/setup-python@v5 40 | with: 41 | python-version: ${{ matrix.python-version }} 42 | 43 | - name: Install dependencies 44 | run: | 45 | python -m pip install --upgrade pip 46 | python -m pip install . 47 | python -m pip install -r requirements-dev.txt 48 | 49 | - name: Lint with ruff 50 | run: | 51 | ruff check mapillary_tools 52 | 53 | - name: Format with ruff 54 | run: | 55 | ruff format --check mapillary_tools tests 56 | 57 | - name: Sort imports with usort 58 | run: | 59 | usort diff mapillary_tools/ 60 | 61 | - name: Type check with mypy 62 | run: | 63 | mypy mapillary_tools tests/cli 64 | 65 | # Begin of expensive steps: run after the quick checks done above 66 | 67 | # https://github.com/actions/checkout#Checkout-multiple-repos-side-by-side 68 | # pull into mapillary/mapillary_tools/exiftool 69 | - name: Setup ExifTool 70 | uses: actions/checkout@v4 71 | with: 72 | repository: "exiftool/exiftool" 73 | path: exiftool 74 | 75 | - name: Check ExifTool version 76 | # DO NOT USE envvars here which does not work on Windows (needs prefixing with $env:) 77 | # need to rename exiftool to exiftool.pl according to https://exiftool.org/install.html 78 | run: | 79 | mv ${{ github.workspace }}/exiftool/exiftool ${{ github.workspace }}/exiftool/exiftool.pl 80 | perl ${{ github.workspace }}/exiftool/exiftool.pl -ver 81 | 82 | - name: Setup FFmpeg 83 | uses: FedericoCarboni/setup-ffmpeg@v3 84 | # ffmpeg is not supported in the latest macOS arch: 85 | # Error: setup-ffmpeg can only be run on 64-bit systems 86 | if: matrix.platform != 'macos-latest' 87 | # Allow this step to fail (it frequently fails) 88 | continue-on-error: true 89 | 90 | # End of expensive steps 91 | 92 | - name: Test with pytest 93 | run: | 94 | mapillary_tools --version 95 | pytest -s -vv tests 96 | env: 97 | MAPILLARY_TOOLS__TESTS_EXECUTABLE: mapillary_tools 98 | MAPILLARY_TOOLS__TESTS_EXIFTOOL_EXECUTABLE: perl ${{ github.workspace }}/exiftool/exiftool.pl 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .project 3 | .pydevproject 4 | *.egg-info 5 | .settings 6 | /.cache/ 7 | /.idea/ 8 | /.mypy_cache/ 9 | /publish/ 10 | /build/ 11 | /dist/ 12 | /venv/ 13 | /.pyre/ 14 | .DS_Store 15 | *.log 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to mapillary_tools 2 | We want to make contributing to this project as easy and transparent as 3 | possible. 4 | 5 | ## Pull Requests 6 | We actively welcome your pull requests. 7 | 8 | 1. Fork the repo and create your branch from `master`. 9 | 2. If you've added code that should be tested, add tests. 10 | 3. If you've changed APIs, update the documentation. 11 | 4. Ensure the test suite passes. 12 | 5. Make sure your code lints. 13 | 6. If you haven't already, complete the Contributor License Agreement ("CLA"). 14 | 15 | ## Contributor License Agreement ("CLA") 16 | In order to accept your pull request, we need you to submit a CLA. You only need 17 | to do this once to work on any of Facebook's open source projects. 18 | 19 | Complete your CLA here: 20 | 21 | ## Issues 22 | We use GitHub issues to track public bugs. Please ensure your description is 23 | clear and has sufficient instructions to be able to reproduce the issue. 24 | 25 | Facebook has a [bounty program](https://www.facebook.com/whitehat/) for the safe 26 | disclosure of security bugs. In those cases, please go through the process 27 | outlined on that page and do not file a public issue. 28 | 29 | ## License 30 | By contributing to mapillary_tools, you agree that your contributions will be licensed 31 | under the LICENSE file in the root directory of this source tree. -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 15 | 16 | ### Basic information 17 | * Release version: `0.0.0` | `0.0.1` | `...` 18 | * System: `Windows` | `Linux` | `...` 19 | * Capture Device: `iOS` | `Android` | `Action Camera` | `...` 20 | 21 | ### Steps to reproduce behavior 22 | 23 | 1. 24 | 2. 25 | 3. 26 | 27 | ### Expected behavior 28 | 29 | ... 30 | 31 | ### Actual behavior 32 | 33 | ... 34 | 35 | ### Corresponding data 36 | 37 | * sample images 38 | * meta data 39 | 40 | ... 41 | 42 | ### Additional information 43 | 44 | ... 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, mapillary 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include schema/*.json 2 | include requirements.txt 3 | -------------------------------------------------------------------------------- /README_osx_package.txt: -------------------------------------------------------------------------------- 1 | Thank you for downloading Mapillary tools for OSX!! 2 | 3 | This is binary, ready to use. You just need to give execution permissions to it with chmod: 4 | 5 | chmod +x mapillary_tools 6 | 7 | If you see the error "mapillary_tools is damaged and can’t be opened", try to clear the extended attributes: 8 | 9 | xattr -c mapillary_tools 10 | 11 | Check your available options with ./mapillary_tools --help 12 | 13 | For examples, see https://github.com/mapillary/mapillary_tools/blob/master/README.md 14 | 15 | If you need help, contact us at support@mapillary.com. 16 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN apt update -y && apt install -y python3 python3-pip git && apt install -y --no-install-recommends ffmpeg 6 | 7 | RUN python3 -m pip install --upgrade git+https://github.com/mapillary/mapillary_tools 8 | -------------------------------------------------------------------------------- /docker/Dockerfile-dev: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN apt update -y && apt install -y python3 python3-pip git && apt install -y --no-install-recommends ffmpeg 6 | 7 | WORKDIR /mapillary_tools 8 | ADD requirements.txt requirements-dev.txt /mapillary_tools 9 | RUN python3 -m pip install -r requirements.txt -r requirements-dev.txt 10 | ADD . /mapillary_tools 11 | -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/docs/images/logo.png -------------------------------------------------------------------------------- /mapillary_tools.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | block_cipher = None 4 | 5 | options = [("u", None, "OPTION")] 6 | 7 | a = Analysis( 8 | ["./pyinstaller/main.py"], 9 | pathex=[SPECPATH], 10 | binaries=[], 11 | datas=[], 12 | hiddenimports=[], 13 | excludes=[], 14 | win_no_prefer_redirects=False, 15 | win_private_assemblies=False, 16 | cipher=block_cipher, 17 | ) 18 | pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) 19 | exe = EXE( 20 | pyz, 21 | a.scripts, 22 | options, 23 | a.binaries, 24 | a.zipfiles, 25 | a.datas, 26 | name="mapillary_tools", 27 | debug=False, 28 | strip=False, 29 | upx=True, 30 | runtime_tmpdir=None, 31 | console=True, 32 | ) 33 | 34 | app = BUNDLE(exe, name="mapillary_tools.app", icon=None, bundle_identifier=None) 35 | -------------------------------------------------------------------------------- /mapillary_tools/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = "0.14.0a2" 2 | -------------------------------------------------------------------------------- /mapillary_tools/blackvue_parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | 5 | import json 6 | import logging 7 | import re 8 | import typing as T 9 | 10 | import pynmea2 11 | 12 | from . import geo 13 | from .mp4 import simple_mp4_parser as sparser 14 | 15 | 16 | LOG = logging.getLogger(__name__) 17 | # An example: [1623057074211]$GPVTG,,T,,M,0.078,N,0.144,K,D*28[1623057075215] 18 | NMEA_LINE_REGEX = re.compile( 19 | rb""" 20 | ^\s* 21 | \[(\d+)\] # timestamp 22 | \s* 23 | (\$\w{5}.*) # nmea line 24 | \s* 25 | (\[\d+\])? # strange timestamp 26 | \s*$ 27 | """, 28 | re.X, 29 | ) 30 | 31 | 32 | @dataclasses.dataclass 33 | class BlackVueInfo: 34 | # None and [] are equivalent here. Use None as default because: 35 | # ValueError: mutable default for field gps is not allowed: use default_factory 36 | gps: list[geo.Point] | None = None 37 | make: str = "BlackVue" 38 | model: str = "" 39 | 40 | 41 | def extract_blackvue_info(fp: T.BinaryIO) -> BlackVueInfo | None: 42 | try: 43 | gps_data = sparser.parse_mp4_data_first(fp, [b"free", b"gps "]) 44 | except sparser.ParsingError: 45 | gps_data = None 46 | 47 | if gps_data is None: 48 | return None 49 | 50 | points = list(_parse_gps_box(gps_data)) 51 | points.sort(key=lambda p: p.time) 52 | 53 | if points: 54 | first_point_time = points[0].time 55 | for p in points: 56 | p.time = (p.time - first_point_time) / 1000 57 | 58 | # Camera model 59 | try: 60 | cprt_bytes = sparser.parse_mp4_data_first(fp, [b"free", b"cprt"]) 61 | except sparser.ParsingError: 62 | cprt_bytes = None 63 | model = "" 64 | 65 | if cprt_bytes is None: 66 | model = "" 67 | else: 68 | model = _extract_camera_model_from_cprt(cprt_bytes) 69 | 70 | return BlackVueInfo(model=model, gps=points) 71 | 72 | 73 | def extract_camera_model(fp: T.BinaryIO) -> str: 74 | try: 75 | cprt_bytes = sparser.parse_mp4_data_first(fp, [b"free", b"cprt"]) 76 | except sparser.ParsingError: 77 | return "" 78 | 79 | if cprt_bytes is None: 80 | return "" 81 | 82 | return _extract_camera_model_from_cprt(cprt_bytes) 83 | 84 | 85 | def _extract_camera_model_from_cprt(cprt_bytes: bytes) -> str: 86 | # examples: b' {"model":"DR900X Plus","ver":0.918,"lang":"English","direct":1,"psn":"","temp":34,"GPS":1}\x00' 87 | # b' Pittasoft Co., Ltd.;DR900S-1CH;1.008;English;1;D90SS1HAE00661;T69;\x00' 88 | cprt_bytes = cprt_bytes.strip().strip(b"\x00") 89 | 90 | try: 91 | cprt_str = cprt_bytes.decode("utf8") 92 | except UnicodeDecodeError: 93 | return "" 94 | 95 | try: 96 | cprt_json = json.loads(cprt_str) 97 | except json.JSONDecodeError: 98 | cprt_json = None 99 | 100 | if cprt_json is not None: 101 | return str(cprt_json.get("model", "")).strip() 102 | 103 | fields = cprt_str.split(";") 104 | if 2 <= len(fields): 105 | model = fields[1] 106 | if model: 107 | return model.strip() 108 | else: 109 | return "" 110 | else: 111 | return "" 112 | 113 | 114 | def _parse_gps_box(gps_data: bytes) -> T.Generator[geo.Point, None, None]: 115 | for line_bytes in gps_data.splitlines(): 116 | match = NMEA_LINE_REGEX.match(line_bytes) 117 | if match is None: 118 | continue 119 | nmea_line_bytes = match.group(2) 120 | if nmea_line_bytes.startswith(b"$GPGGA"): 121 | try: 122 | nmea_line = nmea_line_bytes.decode("utf8") 123 | except UnicodeDecodeError: 124 | continue 125 | try: 126 | nmea = pynmea2.parse(nmea_line) 127 | except pynmea2.nmea.ParseError: 128 | continue 129 | if not nmea.is_valid: 130 | continue 131 | epoch_ms = int(match.group(1)) 132 | yield geo.Point( 133 | time=epoch_ms, 134 | lat=nmea.latitude, 135 | lon=nmea.longitude, 136 | alt=nmea.altitude, 137 | angle=None, 138 | ) 139 | -------------------------------------------------------------------------------- /mapillary_tools/commands/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | from . import ( 3 | authenticate, 4 | process, 5 | process_and_upload, 6 | sample_video, 7 | upload, 8 | video_process, 9 | video_process_and_upload, 10 | ) 11 | -------------------------------------------------------------------------------- /mapillary_tools/commands/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import enum 3 | import logging 4 | import sys 5 | from pathlib import Path 6 | 7 | import requests 8 | 9 | from .. import api_v4, constants, exceptions, VERSION 10 | from . import ( 11 | authenticate, 12 | process, 13 | process_and_upload, 14 | sample_video, 15 | upload, 16 | video_process, 17 | video_process_and_upload, 18 | zip, 19 | ) 20 | 21 | mapillary_tools_commands = [ 22 | process, 23 | upload, 24 | sample_video, 25 | video_process, 26 | authenticate, 27 | process_and_upload, 28 | video_process_and_upload, 29 | zip, 30 | ] 31 | 32 | 33 | # do not use __name__ here is because if you run tools as a module, __name__ will be "__main__" 34 | LOG = logging.getLogger("mapillary_tools") 35 | 36 | 37 | # Handle shared arguments/options here 38 | def add_general_arguments(parser, command): 39 | if command in ["sample_video", "video_process", "video_process_and_upload"]: 40 | parser.add_argument( 41 | "video_import_path", 42 | help="Path to a video or directory with one or more video files.", 43 | type=Path, 44 | ) 45 | parser.add_argument( 46 | "import_path", 47 | help=f"Path to where the images from video sampling will be saved. [default: {{VIDEO_IMPORT_PATH}}/{constants.SAMPLED_VIDEO_FRAMES_FILENAME}]", 48 | nargs="?", 49 | type=Path, 50 | ) 51 | parser.add_argument( 52 | "--skip_subfolders", 53 | help="Skip all subfolders and import only the images in the given VIDEO_IMPORT_PATH.", 54 | action="store_true", 55 | default=False, 56 | required=False, 57 | ) 58 | elif command in ["upload"]: 59 | parser.add_argument( 60 | "import_path", 61 | help="Paths to your images or videos.", 62 | nargs="+", 63 | type=Path, 64 | ) 65 | elif command in ["process", "process_and_upload"]: 66 | parser.add_argument( 67 | "import_path", 68 | help="Paths to your images or videos.", 69 | nargs="+", 70 | type=Path, 71 | ) 72 | parser.add_argument( 73 | "--skip_subfolders", 74 | help="Skip all subfolders and import only the images in the given IMPORT_PATH.", 75 | action="store_true", 76 | default=False, 77 | required=False, 78 | ) 79 | 80 | 81 | def configure_logger(logger: logging.Logger, stream=None) -> None: 82 | formatter = logging.Formatter("%(asctime)s - %(levelname)-7s - %(message)s") 83 | handler = logging.StreamHandler(stream) 84 | handler.setFormatter(formatter) 85 | logger.addHandler(handler) 86 | 87 | 88 | def _log_params(argvars: dict) -> None: 89 | MAX_ENTRIES = 5 90 | 91 | def _stringify(x) -> str: 92 | if isinstance(x, enum.Enum): 93 | return x.value 94 | else: 95 | return str(x) 96 | 97 | for k, v in argvars.items(): 98 | if v is None: 99 | continue 100 | if callable(v): 101 | continue 102 | if k in ["jwt", "user_password"]: 103 | assert isinstance(v, str), type(v) 104 | v = "******" 105 | if isinstance(v, (list, set, tuple)): 106 | entries = [_stringify(x) for x in v] 107 | if len(entries) <= MAX_ENTRIES: 108 | v = ", ".join(entries) 109 | else: 110 | v = ( 111 | ", ".join(entries[:MAX_ENTRIES]) 112 | ) + f" and {len(entries) - MAX_ENTRIES} more" 113 | else: 114 | v = _stringify(v) 115 | LOG.debug("CLI param: %s: %s", k, v) 116 | 117 | 118 | def main(): 119 | version_text = f"mapillary_tools version {VERSION}" 120 | 121 | parser = argparse.ArgumentParser( 122 | "mapillary_tool", 123 | ) 124 | parser.add_argument( 125 | "--version", 126 | help="show the version of mapillary tools and exit", 127 | action="version", 128 | version=version_text, 129 | ) 130 | parser.add_argument( 131 | "--verbose", 132 | help="show verbose", 133 | action="store_true", 134 | default=False, 135 | required=False, 136 | ) 137 | parser.set_defaults(func=lambda _: parser.print_help()) 138 | 139 | all_commands = [module.Command() for module in mapillary_tools_commands] 140 | 141 | subparsers = parser.add_subparsers( 142 | description="please choose one of the available subcommands", 143 | ) 144 | for command in all_commands: 145 | cmd_parser = subparsers.add_parser( 146 | command.name, help=command.help, conflict_handler="resolve" 147 | ) 148 | add_general_arguments(cmd_parser, command.name) 149 | command.add_basic_arguments(cmd_parser) 150 | cmd_parser.set_defaults(func=command.run) 151 | 152 | args = parser.parse_args() 153 | 154 | log_level = logging.DEBUG if args.verbose else logging.INFO 155 | configure_logger(LOG, sys.stderr) 156 | LOG.setLevel(log_level) 157 | 158 | LOG.debug("%s", version_text) 159 | argvars = vars(args) 160 | _log_params(argvars) 161 | 162 | try: 163 | args.func(argvars) 164 | except requests.HTTPError as ex: 165 | LOG.error("%s: %s", ex.__class__.__name__, api_v4.readable_http_error(ex)) 166 | # TODO: standardize exit codes as exceptions.MapillaryUserError 167 | sys.exit(16) 168 | 169 | except exceptions.MapillaryUserError as ex: 170 | LOG.error( 171 | "%s: %s", ex.__class__.__name__, ex, exc_info=log_level == logging.DEBUG 172 | ) 173 | sys.exit(ex.exit_code) 174 | 175 | 176 | if __name__ == "__main__": 177 | main() 178 | -------------------------------------------------------------------------------- /mapillary_tools/commands/authenticate.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import inspect 3 | 4 | from ..authenticate import authenticate 5 | 6 | 7 | class Command: 8 | name = "authenticate" 9 | help = "authenticate Mapillary users" 10 | 11 | def add_basic_arguments(self, parser: argparse.ArgumentParser): 12 | parser.add_argument( 13 | "--user_name", help="Mapillary user profile", default=None, required=False 14 | ) 15 | parser.add_argument( 16 | "--user_email", 17 | help="User email, used to create Mapillary account", 18 | default=None, 19 | required=False, 20 | ) 21 | parser.add_argument( 22 | "--user_password", 23 | help="Password associated with the Mapillary user account", 24 | default=None, 25 | required=False, 26 | ) 27 | parser.add_argument( 28 | "--jwt", help="Mapillary user access token", default=None, required=False 29 | ) 30 | parser.add_argument( 31 | "--delete", 32 | help="Delete the specified user profile", 33 | default=False, 34 | required=False, 35 | action="store_true", 36 | ) 37 | 38 | def run(self, vars_args: dict): 39 | authenticate( 40 | **( 41 | { 42 | k: v 43 | for k, v in vars_args.items() 44 | if k in inspect.getfullargspec(authenticate).args 45 | } 46 | ) 47 | ) 48 | -------------------------------------------------------------------------------- /mapillary_tools/commands/process_and_upload.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from ..authenticate import fetch_user_items 4 | 5 | from .process import Command as ProcessCommand 6 | from .upload import Command as UploadCommand 7 | 8 | 9 | class Command: 10 | name = "process_and_upload" 11 | help = "process images or videos and upload to Mapillary" 12 | 13 | def add_basic_arguments(self, parser): 14 | ProcessCommand().add_basic_arguments(parser) 15 | UploadCommand().add_basic_arguments(parser) 16 | 17 | def run(self, vars_args: dict): 18 | if vars_args.get("desc_path") is None: 19 | # \x00 is a special path similiar to /dev/null 20 | # it tells process command do not write anything 21 | vars_args["desc_path"] = "\x00" 22 | 23 | if "user_items" not in vars_args: 24 | vars_args["user_items"] = fetch_user_items( 25 | **{ 26 | k: v 27 | for k, v in vars_args.items() 28 | if k in inspect.getfullargspec(fetch_user_items).args 29 | } 30 | ) 31 | 32 | ProcessCommand().run(vars_args) 33 | UploadCommand().run(vars_args) 34 | -------------------------------------------------------------------------------- /mapillary_tools/commands/sample_video.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import inspect 3 | from pathlib import Path 4 | 5 | from .. import constants 6 | from ..sample_video import sample_video 7 | from .process import bold_text 8 | 9 | 10 | class Command: 11 | name = "sample_video" 12 | help = "sample video into images" 13 | 14 | def add_basic_arguments(self, parser: argparse.ArgumentParser): 15 | group = parser.add_argument_group(bold_text("VIDEO PROCESS OPTIONS")) 16 | group.add_argument( 17 | "--video_sample_distance", 18 | help="The minimal distance interval, in meters, for sampling video frames. [default: %(default)s]", 19 | default=constants.VIDEO_SAMPLE_DISTANCE, 20 | type=float, 21 | required=False, 22 | ) 23 | group.add_argument( 24 | "--video_sample_interval", 25 | help="[DEPRECATED since v0.10.0] Time interval, in seconds, for sampling video frames. Since v0.10.0 you must disable distance-sampling with --video_sample_distance=-1 in order to apply this option. [default: %(default)s]", 26 | default=constants.VIDEO_SAMPLE_INTERVAL, 27 | type=float, 28 | required=False, 29 | ) 30 | group.add_argument( 31 | "--video_duration_ratio", 32 | help="[DEPRECATED since v0.10.0] Real time video duration ratio of the under or oversampled video duration. [default: %(default)s]", 33 | type=float, 34 | default=constants.VIDEO_DURATION_RATIO, 35 | required=False, 36 | ) 37 | group.add_argument( 38 | "--video_start_time", 39 | help="Video start time specified in YYYY_MM_DD_HH_MM_SS_sss in UTC. For example 2020_12_28_12_36_36_508 represents 2020-12-28T12:36:36.508Z.", 40 | default=None, 41 | required=False, 42 | ) 43 | group.add_argument( 44 | "--skip_subfolders", 45 | help="Skip all subfolders and import only the images in the given directory path.", 46 | action="store_true", 47 | default=False, 48 | required=False, 49 | ) 50 | group.add_argument( 51 | "--rerun", 52 | help="Re-sample all videos. Note it will REMOVE all the existing video sample directories.", 53 | action="store_true", 54 | default=False, 55 | required=False, 56 | ) 57 | group.add_argument( 58 | "--skip_sample_errors", 59 | help="Skip errors from the video sampling.", 60 | action="store_true", 61 | default=False, 62 | required=False, 63 | ) 64 | 65 | def run(self, vars_args: dict): 66 | video_import_path: Path = vars_args["video_import_path"] 67 | import_path = vars_args["import_path"] 68 | if import_path is None: 69 | if video_import_path.is_dir(): 70 | import_path = video_import_path.joinpath( 71 | constants.SAMPLED_VIDEO_FRAMES_FILENAME 72 | ) 73 | else: 74 | import_path = video_import_path.resolve().parent.joinpath( 75 | constants.SAMPLED_VIDEO_FRAMES_FILENAME 76 | ) 77 | vars_args["import_path"] = import_path 78 | 79 | sample_video( 80 | **( 81 | { 82 | k: v 83 | for k, v in vars_args.items() 84 | if k in inspect.getfullargspec(sample_video).args 85 | } 86 | ) 87 | ) 88 | -------------------------------------------------------------------------------- /mapillary_tools/commands/upload.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from .. import constants 4 | from ..authenticate import fetch_user_items 5 | from ..upload import upload 6 | from .process import bold_text 7 | 8 | 9 | class Command: 10 | name = "upload" 11 | help = "upload images to Mapillary" 12 | 13 | @staticmethod 14 | def add_common_upload_options(group): 15 | group.add_argument( 16 | "--user_name", 17 | help="The Mapillary user account to upload to. If you only have one account authorized, it will upload to that account by default.", 18 | required=False, 19 | ) 20 | group.add_argument( 21 | "--organization_key", 22 | help="The Mapillary organization ID to upload to.", 23 | default=None, 24 | required=False, 25 | ) 26 | group.add_argument( 27 | "--dry_run", 28 | help='Instead of uploading to the Mapillary server, simulate uploading to the local directory "mapillary_public_uploads" for debugging purposes.', 29 | action="store_true", 30 | default=False, 31 | required=False, 32 | ) 33 | 34 | def add_basic_arguments(self, parser): 35 | group = parser.add_argument_group(bold_text("UPLOAD OPTIONS")) 36 | group.add_argument( 37 | "--desc_path", 38 | help=f'Path to the description file generated by the process command. The hyphen "-" indicates STDIN. [default: {{IMPORT_PATH}}/{constants.IMAGE_DESCRIPTION_FILENAME}]', 39 | default=None, 40 | required=False, 41 | ) 42 | Command.add_common_upload_options(group) 43 | 44 | def run(self, vars_args: dict): 45 | if "user_items" not in vars_args: 46 | user_items_args = { 47 | k: v 48 | for k, v in vars_args.items() 49 | if k in inspect.getfullargspec(fetch_user_items).args 50 | } 51 | vars_args["user_items"] = fetch_user_items(**user_items_args) 52 | 53 | upload( 54 | **{ 55 | k: v 56 | for k, v in vars_args.items() 57 | if k in inspect.getfullargspec(upload).args 58 | } 59 | ) 60 | -------------------------------------------------------------------------------- /mapillary_tools/commands/video_process.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from ..types import FileType 4 | from .process import Command as ProcessCommand 5 | from .sample_video import Command as SampleCommand 6 | 7 | 8 | LOG = logging.getLogger(__name__) 9 | 10 | 11 | class Command: 12 | name = "video_process" 13 | help = "sample video into images and process the images" 14 | 15 | def add_basic_arguments(self, parser): 16 | SampleCommand().add_basic_arguments(parser) 17 | ProcessCommand().add_basic_arguments(parser) 18 | 19 | def run(self, args: dict): 20 | SampleCommand().run(args) 21 | 22 | option = "filetypes" 23 | if args[option] != {FileType.IMAGE}: 24 | LOG.warning( 25 | 'Force the option "%s" to be "%s" to avoid processing and uploading both the video samples and the videos themselves', 26 | option, 27 | FileType.IMAGE.value, 28 | ) 29 | args[option] = {FileType.IMAGE} 30 | 31 | ProcessCommand().run(args) 32 | -------------------------------------------------------------------------------- /mapillary_tools/commands/video_process_and_upload.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from ..authenticate import fetch_user_items 4 | 5 | from .upload import Command as UploadCommand 6 | from .video_process import Command as VideoProcessCommand 7 | 8 | 9 | class Command: 10 | name = "video_process_and_upload" 11 | help = "sample video into images, process the images and upload to Mapillary" 12 | 13 | def add_basic_arguments(self, parser): 14 | VideoProcessCommand().add_basic_arguments(parser) 15 | UploadCommand().add_basic_arguments(parser) 16 | 17 | def run(self, vars_args: dict): 18 | if vars_args.get("desc_path") is None: 19 | # \x00 is a special path similiar to /dev/null 20 | # it tells process command do not write anything 21 | vars_args["desc_path"] = "\x00" 22 | 23 | if "user_items" not in vars_args: 24 | vars_args["user_items"] = fetch_user_items( 25 | **{ 26 | k: v 27 | for k, v in vars_args.items() 28 | if k in inspect.getfullargspec(fetch_user_items).args 29 | } 30 | ) 31 | 32 | VideoProcessCommand().run(vars_args) 33 | UploadCommand().run(vars_args) 34 | -------------------------------------------------------------------------------- /mapillary_tools/commands/zip.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import inspect 3 | from pathlib import Path 4 | 5 | from ..upload import zip_images 6 | 7 | 8 | class Command: 9 | name = "zip" 10 | help = "zip images into sequences" 11 | 12 | def add_basic_arguments(self, parser: argparse.ArgumentParser): 13 | parser.add_argument( 14 | "import_path", 15 | help="Path to your images.", 16 | type=Path, 17 | ) 18 | parser.add_argument( 19 | "zip_dir", 20 | help="Path to store zipped images.", 21 | type=Path, 22 | ) 23 | parser.add_argument( 24 | "--desc_path", 25 | help='Specify the path to read image description. If it is "-", then read from STDIN.', 26 | default=None, 27 | required=False, 28 | ) 29 | 30 | def run(self, vars_args: dict): 31 | zip_images( 32 | **( 33 | { 34 | k: v 35 | for k, v in vars_args.items() 36 | if k in inspect.getfullargspec(zip_images).args 37 | } 38 | ) 39 | ) 40 | -------------------------------------------------------------------------------- /mapillary_tools/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import configparser 4 | import os 5 | import typing as T 6 | 7 | from . import api_v4, types 8 | 9 | 10 | _CLIENT_ID = api_v4.MAPILLARY_CLIENT_TOKEN 11 | # Windows is not happy with | so we convert MLY|ID|TOKEN to MLY_ID_TOKEN 12 | _CLIENT_ID = _CLIENT_ID.replace("|", "_", 2) 13 | 14 | DEFAULT_MAPILLARY_FOLDER = os.path.join( 15 | os.path.expanduser("~"), 16 | ".config", 17 | "mapillary", 18 | ) 19 | 20 | MAPILLARY_CONFIG_PATH = os.getenv( 21 | "MAPILLARY_CONFIG_PATH", 22 | os.path.join( 23 | DEFAULT_MAPILLARY_FOLDER, 24 | "configs", 25 | _CLIENT_ID, 26 | ), 27 | ) 28 | 29 | 30 | def _load_config(config_path: str) -> configparser.ConfigParser: 31 | config = configparser.ConfigParser() 32 | # Override to not change option names (by default it will lower them) 33 | config.optionxform = str # type: ignore 34 | # If path not found, then config will be empty 35 | config.read(config_path) 36 | return config 37 | 38 | 39 | def load_user( 40 | profile_name: str, config_path: str | None = None 41 | ) -> types.UserItem | None: 42 | if config_path is None: 43 | config_path = MAPILLARY_CONFIG_PATH 44 | config = _load_config(config_path) 45 | if not config.has_section(profile_name): 46 | return None 47 | user_items = dict(config.items(profile_name)) 48 | return T.cast(types.UserItem, user_items) 49 | 50 | 51 | def list_all_users(config_path: str | None = None) -> dict[str, types.UserItem]: 52 | if config_path is None: 53 | config_path = MAPILLARY_CONFIG_PATH 54 | cp = _load_config(config_path) 55 | users = { 56 | profile_name: load_user(profile_name, config_path=config_path) 57 | for profile_name in cp.sections() 58 | } 59 | return {profile: item for profile, item in users.items() if item is not None} 60 | 61 | 62 | def update_config( 63 | profile_name: str, user_items: types.UserItem, config_path: str | None = None 64 | ) -> None: 65 | if config_path is None: 66 | config_path = MAPILLARY_CONFIG_PATH 67 | config = _load_config(config_path) 68 | if not config.has_section(profile_name): 69 | config.add_section(profile_name) 70 | for key, val in user_items.items(): 71 | config.set(profile_name, key, T.cast(str, val)) 72 | os.makedirs(os.path.dirname(os.path.abspath(config_path)), exist_ok=True) 73 | with open(config_path, "w") as fp: 74 | config.write(fp) 75 | 76 | 77 | def remove_config(profile_name: str, config_path: str | None = None) -> None: 78 | if config_path is None: 79 | config_path = MAPILLARY_CONFIG_PATH 80 | 81 | config = _load_config(config_path) 82 | if not config.has_section(profile_name): 83 | return 84 | 85 | config.remove_section(profile_name) 86 | 87 | os.makedirs(os.path.dirname(os.path.abspath(config_path)), exist_ok=True) 88 | with open(config_path, "w") as fp: 89 | config.write(fp) 90 | -------------------------------------------------------------------------------- /mapillary_tools/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import appdirs 6 | 7 | _ENV_PREFIX = "MAPILLARY_TOOLS_" 8 | 9 | 10 | def _yes_or_no(val: str) -> bool: 11 | return val.strip().upper() in [ 12 | "1", 13 | "TRUE", 14 | "YES", 15 | ] 16 | 17 | 18 | # In meters 19 | CUTOFF_DISTANCE = float(os.getenv(_ENV_PREFIX + "CUTOFF_DISTANCE", 600)) 20 | # In seconds 21 | CUTOFF_TIME = float(os.getenv(_ENV_PREFIX + "CUTOFF_TIME", 60)) 22 | DUPLICATE_DISTANCE = float(os.getenv(_ENV_PREFIX + "DUPLICATE_DISTANCE", 0.1)) 23 | DUPLICATE_ANGLE = float(os.getenv(_ENV_PREFIX + "DUPLICATE_ANGLE", 5)) 24 | MAX_AVG_SPEED = float( 25 | os.getenv(_ENV_PREFIX + "MAX_AVG_SPEED", 400_000 / 3600) 26 | ) # 400 KM/h 27 | # in seconds 28 | VIDEO_SAMPLE_INTERVAL = float(os.getenv(_ENV_PREFIX + "VIDEO_SAMPLE_INTERVAL", -1)) 29 | # in meters 30 | VIDEO_SAMPLE_DISTANCE = float(os.getenv(_ENV_PREFIX + "VIDEO_SAMPLE_DISTANCE", 3)) 31 | VIDEO_DURATION_RATIO = float(os.getenv(_ENV_PREFIX + "VIDEO_DURATION_RATIO", 1)) 32 | FFPROBE_PATH: str = os.getenv(_ENV_PREFIX + "FFPROBE_PATH", "ffprobe") 33 | FFMPEG_PATH: str = os.getenv(_ENV_PREFIX + "FFMPEG_PATH", "ffmpeg") 34 | # When not set, MT will try to check both "exiftool" and "exiftool.exe" from $PATH 35 | EXIFTOOL_PATH: str | None = os.getenv(_ENV_PREFIX + "EXIFTOOL_PATH") 36 | IMAGE_DESCRIPTION_FILENAME = os.getenv( 37 | _ENV_PREFIX + "IMAGE_DESCRIPTION_FILENAME", "mapillary_image_description.json" 38 | ) 39 | SAMPLED_VIDEO_FRAMES_FILENAME = os.getenv( 40 | _ENV_PREFIX + "SAMPLED_VIDEO_FRAMES_FILENAME", "mapillary_sampled_video_frames" 41 | ) 42 | USER_DATA_DIR = appdirs.user_data_dir(appname="mapillary_tools", appauthor="Mapillary") 43 | # The chunk size in MB (see chunked transfer encoding https://en.wikipedia.org/wiki/Chunked_transfer_encoding) 44 | # for uploading data to MLY upload service. 45 | # Changing this size does not change the number of requests nor affect upload performance, 46 | # but it affects the responsiveness of the upload progress bar 47 | UPLOAD_CHUNK_SIZE_MB = float(os.getenv(_ENV_PREFIX + "UPLOAD_CHUNK_SIZE_MB", 1)) 48 | 49 | # DoP value, the lower the better 50 | # See https://github.com/gopro/gpmf-parser#hero5-black-with-gps-enabled-adds 51 | # It is used to filter out noisy points 52 | GOPRO_MAX_DOP100 = int(os.getenv(_ENV_PREFIX + "GOPRO_MAX_DOP100", 1000)) 53 | # Within the GPS stream: 0 - no lock, 2 or 3 - 2D or 3D Lock 54 | GOPRO_GPS_FIXES: set[int] = set( 55 | int(fix) for fix in os.getenv(_ENV_PREFIX + "GOPRO_GPS_FIXES", "2,3").split(",") 56 | ) 57 | MAX_UPLOAD_RETRIES: int = int(os.getenv(_ENV_PREFIX + "MAX_UPLOAD_RETRIES", 200)) 58 | 59 | # GPS precision, in meters, is used to filter outliers 60 | GOPRO_GPS_PRECISION = float(os.getenv(_ENV_PREFIX + "GOPRO_GPS_PRECISION", 15)) 61 | 62 | # WARNING: Changing the following envvars might result in failed uploads 63 | # Max number of images per sequence 64 | MAX_SEQUENCE_LENGTH = int(os.getenv(_ENV_PREFIX + "MAX_SEQUENCE_LENGTH", 1000)) 65 | # Max file size per sequence (sum of image filesizes in the sequence) 66 | MAX_SEQUENCE_FILESIZE: str = os.getenv(_ENV_PREFIX + "MAX_SEQUENCE_FILESIZE", "110G") 67 | # Max number of pixels per sequence (sum of image pixels in the sequence) 68 | MAX_SEQUENCE_PIXELS: str = os.getenv(_ENV_PREFIX + "MAX_SEQUENCE_PIXELS", "6G") 69 | 70 | PROMPT_DISABLED: bool = _yes_or_no(os.getenv(_ENV_PREFIX + "PROMPT_DISABLED", "NO")) 71 | 72 | _AUTH_VERIFICATION_DISABLED: bool = _yes_or_no( 73 | os.getenv(_ENV_PREFIX + "_AUTH_VERIFICATION_DISABLED", "NO") 74 | ) 75 | 76 | MAPILLARY_DISABLE_API_LOGGING: bool = _yes_or_no( 77 | os.getenv("MAPILLARY_DISABLE_API_LOGGING", "NO") 78 | ) 79 | MAPILLARY__ENABLE_UPLOAD_HISTORY_FOR_DRY_RUN: bool = _yes_or_no( 80 | os.getenv("MAPILLARY__ENABLE_UPLOAD_HISTORY_FOR_DRY_RUN", "NO") 81 | ) 82 | MAPILLARY__EXPERIMENTAL_ENABLE_IMU: bool = _yes_or_no( 83 | os.getenv("MAPILLARY__EXPERIMENTAL_ENABLE_IMU", "NO") 84 | ) 85 | MAPILLARY_UPLOAD_HISTORY_PATH: str = os.getenv( 86 | "MAPILLARY_UPLOAD_HISTORY_PATH", 87 | os.path.join( 88 | USER_DATA_DIR, 89 | "upload_history", 90 | ), 91 | ) 92 | -------------------------------------------------------------------------------- /mapillary_tools/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as T 4 | 5 | 6 | class MapillaryUserError(Exception): 7 | exit_code: int 8 | 9 | 10 | class MapillaryProcessError(MapillaryUserError): 11 | """ 12 | Base exception for process specific errors 13 | """ 14 | 15 | exit_code = 6 16 | 17 | 18 | class MapillaryDescriptionError(Exception): 19 | pass 20 | 21 | 22 | class MapillaryBadParameterError(MapillaryUserError): 23 | exit_code = 2 24 | 25 | 26 | class MapillaryFileNotFoundError(MapillaryUserError): 27 | exit_code = 3 28 | 29 | 30 | class MapillaryInvalidDescriptionFile(MapillaryUserError): 31 | exit_code = 4 32 | 33 | 34 | class MapillaryVideoError(MapillaryUserError): 35 | exit_code = 7 36 | 37 | 38 | class MapillaryFFmpegNotFoundError(MapillaryUserError): 39 | exit_code = 8 40 | 41 | 42 | class MapillaryExiftoolNotFoundError(MapillaryUserError): 43 | exit_code = 8 44 | 45 | 46 | class MapillaryGeoTaggingError(MapillaryDescriptionError): 47 | pass 48 | 49 | 50 | class MapillaryVideoGPSNotFoundError(MapillaryDescriptionError): 51 | pass 52 | 53 | 54 | class MapillaryGPXEmptyError(MapillaryDescriptionError): 55 | pass 56 | 57 | 58 | class MapillaryGPSNoiseError(MapillaryDescriptionError): 59 | pass 60 | 61 | 62 | class MapillaryStationaryVideoError(MapillaryDescriptionError): 63 | pass 64 | 65 | 66 | class MapillaryOutsideGPXTrackError(MapillaryDescriptionError): 67 | def __init__( 68 | self, message: str, image_time: str, gpx_start_time: str, gpx_end_time: str 69 | ): 70 | super().__init__(message) 71 | self.image_time = image_time 72 | self.gpx_start_time = gpx_start_time 73 | self.gpx_end_time = gpx_end_time 74 | 75 | 76 | class MapillaryDuplicationError(MapillaryDescriptionError): 77 | def __init__( 78 | self, 79 | message: str, 80 | desc: T.Mapping[str, T.Any], 81 | distance: float, 82 | angle_diff: float | None, 83 | ) -> None: 84 | super().__init__(message) 85 | self.desc = desc 86 | self.distance = distance 87 | self.angle_diff = angle_diff 88 | 89 | 90 | class MapillaryExifToolXMLNotFoundError(MapillaryDescriptionError): 91 | pass 92 | 93 | 94 | class MapillaryFileTooLargeError(MapillaryDescriptionError): 95 | pass 96 | 97 | 98 | class MapillaryCaptureSpeedTooFastError(MapillaryDescriptionError): 99 | pass 100 | 101 | 102 | class MapillaryNullIslandError(MapillaryDescriptionError): 103 | pass 104 | 105 | 106 | class MapillaryUploadConnectionError(MapillaryUserError): 107 | exit_code = 12 108 | 109 | 110 | class MapillaryUploadTimeoutError(MapillaryUserError): 111 | exit_code = 13 112 | 113 | 114 | class MapillaryUploadUnauthorizedError(MapillaryUserError): 115 | exit_code = 14 116 | 117 | 118 | class MapillaryMetadataValidationError(MapillaryUserError): 119 | exit_code = 15 120 | -------------------------------------------------------------------------------- /mapillary_tools/exiftool_runner.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import platform 4 | import shutil 5 | import subprocess 6 | import typing as T 7 | from pathlib import Path 8 | 9 | 10 | class ExiftoolRunner: 11 | """ 12 | Wrapper around ExifTool to run it in a subprocess 13 | """ 14 | 15 | def __init__(self, exiftool_path: str | None = None, recursive: bool = False): 16 | if exiftool_path is None: 17 | exiftool_path = self._search_preferred_exiftool_path() 18 | self.exiftool_path = exiftool_path 19 | self.recursive = recursive 20 | 21 | def _search_preferred_exiftool_path(self) -> str: 22 | system = platform.system() 23 | 24 | if system and system.lower() == "windows": 25 | exiftool_paths = ["exiftool.exe", "exiftool"] 26 | else: 27 | exiftool_paths = ["exiftool", "exiftool.exe"] 28 | 29 | for path in exiftool_paths: 30 | full_path = shutil.which(path) 31 | if full_path: 32 | return path 33 | 34 | # Always return the prefered one, even if it is not found, 35 | # and let the subprocess.run figure out the error later 36 | return exiftool_paths[0] 37 | 38 | def _build_args_read_stdin(self) -> list[str]: 39 | args: list[str] = [ 40 | self.exiftool_path, 41 | "-fast", 42 | "-q", 43 | "-n", # Disable print conversion 44 | "-X", # XML output 45 | "-ee", 46 | *["-api", "LargeFileSupport=1"], 47 | *["-charset", "filename=utf8"], 48 | *["-@", "-"], 49 | ] 50 | 51 | if self.recursive: 52 | args.append("-r") 53 | 54 | return args 55 | 56 | def extract_xml(self, paths: T.Sequence[Path]) -> str: 57 | if not paths: 58 | # ExifTool will show its full manual if no files are provided 59 | raise ValueError("No files provided to exiftool") 60 | 61 | # To handle non-latin1 filenames under Windows, we pass the path 62 | # via stdin. See https://exiftool.org/faq.html#Q18 63 | stdin = "\n".join([str(p.resolve()) for p in paths]) 64 | 65 | args = self._build_args_read_stdin() 66 | 67 | # Raise FileNotFoundError here if self.exiftool_path not found 68 | process = subprocess.run( 69 | args, 70 | capture_output=True, 71 | text=True, 72 | input=stdin, 73 | encoding="utf-8", 74 | # Do not check exit status to allow some files not found 75 | # check=True, 76 | ) 77 | 78 | return process.stdout 79 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import logging 5 | import typing as T 6 | from pathlib import Path 7 | 8 | from tqdm import tqdm 9 | 10 | from .. import exceptions, types, utils 11 | from .image_extractors.base import BaseImageExtractor 12 | from .video_extractors.base import BaseVideoExtractor 13 | 14 | 15 | LOG = logging.getLogger(__name__) 16 | 17 | 18 | TImageExtractor = T.TypeVar("TImageExtractor", bound=BaseImageExtractor) 19 | 20 | 21 | class GeotagImagesFromGeneric(abc.ABC, T.Generic[TImageExtractor]): 22 | """ 23 | Extracts metadata from a list of image files with multiprocessing. 24 | """ 25 | 26 | def __init__(self, num_processes: int | None = None) -> None: 27 | self.num_processes = num_processes 28 | 29 | def to_description( 30 | self, image_paths: T.Sequence[Path] 31 | ) -> list[types.ImageMetadataOrError]: 32 | extractor_or_errors = self._generate_image_extractors(image_paths) 33 | 34 | assert len(extractor_or_errors) == len(image_paths) 35 | 36 | extractors, error_metadatas = types.separate_errors(extractor_or_errors) 37 | 38 | map_results = utils.mp_map_maybe( 39 | self.run_extraction, 40 | extractors, 41 | num_processes=self.num_processes, 42 | ) 43 | 44 | results = list( 45 | tqdm( 46 | map_results, 47 | desc="Extracting images", 48 | unit="images", 49 | disable=LOG.getEffectiveLevel() <= logging.DEBUG, 50 | total=len(extractors), 51 | ) 52 | ) 53 | 54 | return results + error_metadatas 55 | 56 | # This method is passed to multiprocessing 57 | # so it has to be classmethod or staticmethod to avoid pickling the instance 58 | @classmethod 59 | def run_extraction(cls, extractor: TImageExtractor) -> types.ImageMetadataOrError: 60 | image_path = extractor.image_path 61 | 62 | try: 63 | return extractor.extract() 64 | except exceptions.MapillaryDescriptionError as ex: 65 | return types.describe_error_metadata( 66 | ex, image_path, filetype=types.FileType.IMAGE 67 | ) 68 | except exceptions.MapillaryUserError as ex: 69 | # Considered as fatal error if not MapillaryDescriptionError 70 | raise ex 71 | except Exception as ex: 72 | # TODO: hide details if not verbose mode 73 | LOG.exception("Unexpected error extracting metadata from %s", image_path) 74 | return types.describe_error_metadata( 75 | ex, image_path, filetype=types.FileType.IMAGE 76 | ) 77 | 78 | def _generate_image_extractors( 79 | self, image_paths: T.Sequence[Path] 80 | ) -> T.Sequence[TImageExtractor | types.ErrorMetadata]: 81 | raise NotImplementedError 82 | 83 | 84 | TVideoExtractor = T.TypeVar("TVideoExtractor", bound=BaseVideoExtractor) 85 | 86 | 87 | class GeotagVideosFromGeneric(abc.ABC, T.Generic[TVideoExtractor]): 88 | """ 89 | Extracts metadata from a list of video files with multiprocessing. 90 | """ 91 | 92 | def __init__(self, num_processes: int | None = None) -> None: 93 | self.num_processes = num_processes 94 | 95 | def to_description( 96 | self, video_paths: T.Sequence[Path] 97 | ) -> list[types.VideoMetadataOrError]: 98 | extractor_or_errors = self._generate_video_extractors(video_paths) 99 | 100 | assert len(extractor_or_errors) == len(video_paths) 101 | 102 | extractors, error_metadatas = types.separate_errors(extractor_or_errors) 103 | 104 | map_results = utils.mp_map_maybe( 105 | self.run_extraction, 106 | extractors, 107 | num_processes=self.num_processes, 108 | ) 109 | 110 | results = list( 111 | tqdm( 112 | map_results, 113 | desc="Extracting videos", 114 | unit="videos", 115 | disable=LOG.getEffectiveLevel() <= logging.DEBUG, 116 | total=len(extractors), 117 | ) 118 | ) 119 | 120 | return results + error_metadatas 121 | 122 | # This method is passed to multiprocessing 123 | # so it has to be classmethod or staticmethod to avoid pickling the instance 124 | @classmethod 125 | def run_extraction(cls, extractor: TVideoExtractor) -> types.VideoMetadataOrError: 126 | video_path = extractor.video_path 127 | 128 | try: 129 | return extractor.extract() 130 | except exceptions.MapillaryDescriptionError as ex: 131 | return types.describe_error_metadata( 132 | ex, video_path, filetype=types.FileType.VIDEO 133 | ) 134 | except exceptions.MapillaryUserError as ex: 135 | # Considered as fatal error if not MapillaryDescriptionError 136 | raise ex 137 | except Exception as ex: 138 | # TODO: hide details if not verbose mode 139 | LOG.exception("Unexpected error extracting metadata from %s", video_path) 140 | return types.describe_error_metadata( 141 | ex, video_path, filetype=types.FileType.VIDEO 142 | ) 143 | 144 | def _generate_video_extractors( 145 | self, video_paths: T.Sequence[Path] 146 | ) -> T.Sequence[TVideoExtractor | types.ErrorMetadata]: 147 | raise NotImplementedError 148 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/geotag_images_from_exif.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sys 5 | import typing as T 6 | from pathlib import Path 7 | 8 | if sys.version_info >= (3, 12): 9 | from typing import override 10 | else: 11 | from typing_extensions import override 12 | 13 | from .base import GeotagImagesFromGeneric 14 | from .image_extractors.exif import ImageEXIFExtractor 15 | 16 | LOG = logging.getLogger(__name__) 17 | 18 | 19 | class GeotagImagesFromEXIF(GeotagImagesFromGeneric): 20 | @override 21 | def _generate_image_extractors( 22 | self, image_paths: T.Sequence[Path] 23 | ) -> T.Sequence[ImageEXIFExtractor]: 24 | return [ImageEXIFExtractor(path) for path in image_paths] 25 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/geotag_images_from_exiftool.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sys 5 | import typing as T 6 | import xml.etree.ElementTree as ET 7 | from pathlib import Path 8 | 9 | if sys.version_info >= (3, 12): 10 | from typing import override 11 | else: 12 | from typing_extensions import override 13 | 14 | from .. import constants, exceptions, exiftool_read, types, utils 15 | from ..exiftool_runner import ExiftoolRunner 16 | from .base import GeotagImagesFromGeneric 17 | from .geotag_images_from_video import GeotagImagesFromVideo 18 | from .geotag_videos_from_exiftool import GeotagVideosFromExifToolXML 19 | from .image_extractors.exiftool import ImageExifToolExtractor 20 | from .utils import index_rdf_description_by_path 21 | 22 | LOG = logging.getLogger(__name__) 23 | 24 | 25 | class GeotagImagesFromExifToolXML(GeotagImagesFromGeneric): 26 | def __init__( 27 | self, 28 | xml_path: Path, 29 | num_processes: int | None = None, 30 | ): 31 | self.xml_path = xml_path 32 | super().__init__(num_processes=num_processes) 33 | 34 | @classmethod 35 | def build_image_extractors( 36 | cls, 37 | rdf_by_path: dict[str, ET.Element], 38 | image_paths: T.Iterable[Path], 39 | ) -> list[ImageExifToolExtractor | types.ErrorMetadata]: 40 | results: list[ImageExifToolExtractor | types.ErrorMetadata] = [] 41 | 42 | for path in image_paths: 43 | rdf = rdf_by_path.get(exiftool_read.canonical_path(path)) 44 | if rdf is None: 45 | ex = exceptions.MapillaryExifToolXMLNotFoundError( 46 | "Cannot find the image in the ExifTool XML" 47 | ) 48 | results.append( 49 | types.describe_error_metadata( 50 | ex, path, filetype=types.FileType.IMAGE 51 | ) 52 | ) 53 | else: 54 | results.append(ImageExifToolExtractor(path, rdf)) 55 | 56 | return results 57 | 58 | @override 59 | def _generate_image_extractors( 60 | self, image_paths: T.Sequence[Path] 61 | ) -> T.Sequence[ImageExifToolExtractor | types.ErrorMetadata]: 62 | rdf_by_path = index_rdf_description_by_path([self.xml_path]) 63 | return self.build_image_extractors(rdf_by_path, image_paths) 64 | 65 | 66 | class GeotagImagesFromExifToolRunner(GeotagImagesFromGeneric): 67 | @override 68 | def _generate_image_extractors( 69 | self, image_paths: T.Sequence[Path] 70 | ) -> T.Sequence[ImageExifToolExtractor | types.ErrorMetadata]: 71 | runner = ExiftoolRunner(constants.EXIFTOOL_PATH) 72 | 73 | LOG.debug( 74 | "Extracting XML from %d images with ExifTool command: %s", 75 | len(image_paths), 76 | " ".join(runner._build_args_read_stdin()), 77 | ) 78 | try: 79 | xml = runner.extract_xml(image_paths) 80 | except FileNotFoundError as ex: 81 | raise exceptions.MapillaryExiftoolNotFoundError(ex) from ex 82 | 83 | try: 84 | xml_element = ET.fromstring(xml) 85 | except ET.ParseError as ex: 86 | LOG.warning( 87 | "Failed to parse ExifTool XML: %s", 88 | str(ex), 89 | exc_info=LOG.getEffectiveLevel() <= logging.DEBUG, 90 | ) 91 | rdf_by_path = {} 92 | else: 93 | rdf_by_path = exiftool_read.index_rdf_description_by_path_from_xml_element( 94 | xml_element 95 | ) 96 | 97 | return GeotagImagesFromExifToolXML.build_image_extractors( 98 | rdf_by_path, image_paths 99 | ) 100 | 101 | 102 | class GeotagImagesFromExifToolWithSamples(GeotagImagesFromGeneric): 103 | def __init__( 104 | self, 105 | xml_path: Path, 106 | offset_time: float = 0.0, 107 | num_processes: int | None = None, 108 | ): 109 | super().__init__(num_processes=num_processes) 110 | self.xml_path = xml_path 111 | self.offset_time = offset_time 112 | 113 | def geotag_samples( 114 | self, image_paths: T.Sequence[Path] 115 | ) -> list[types.ImageMetadataOrError]: 116 | # Find all video paths in self.xml_path 117 | rdf_by_path = index_rdf_description_by_path([self.xml_path]) 118 | video_paths = utils.find_videos( 119 | [Path(pathstr) for pathstr in rdf_by_path.keys()], 120 | skip_subfolders=True, 121 | ) 122 | # Find all video paths that have sample images 123 | samples_by_video = utils.find_all_image_samples(image_paths, video_paths) 124 | 125 | video_metadata_or_errors = GeotagVideosFromExifToolXML( 126 | self.xml_path, 127 | num_processes=self.num_processes, 128 | ).to_description(list(samples_by_video.keys())) 129 | sample_paths = sum(samples_by_video.values(), []) 130 | sample_metadata_or_errors = GeotagImagesFromVideo( 131 | video_metadata_or_errors, 132 | offset_time=self.offset_time, 133 | num_processes=self.num_processes, 134 | ).to_description(sample_paths) 135 | 136 | return sample_metadata_or_errors 137 | 138 | @override 139 | def to_description( 140 | self, image_paths: T.Sequence[Path] 141 | ) -> list[types.ImageMetadataOrError]: 142 | sample_metadata_or_errors = self.geotag_samples(image_paths) 143 | 144 | sample_paths = set(metadata.filename for metadata in sample_metadata_or_errors) 145 | 146 | non_sample_paths = [path for path in image_paths if path not in sample_paths] 147 | 148 | non_sample_metadata_or_errors = GeotagImagesFromExifToolXML( 149 | self.xml_path, 150 | num_processes=self.num_processes, 151 | ).to_description(non_sample_paths) 152 | 153 | return sample_metadata_or_errors + non_sample_metadata_or_errors 154 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/geotag_images_from_gpx.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | import logging 5 | import sys 6 | import typing as T 7 | from pathlib import Path 8 | 9 | if sys.version_info >= (3, 12): 10 | from typing import override 11 | else: 12 | from typing_extensions import override 13 | 14 | from .. import exceptions, geo, types 15 | from .base import GeotagImagesFromGeneric 16 | from .geotag_images_from_exif import ImageEXIFExtractor 17 | 18 | 19 | LOG = logging.getLogger(__name__) 20 | 21 | 22 | class GeotagImagesFromGPX(GeotagImagesFromGeneric): 23 | def __init__( 24 | self, 25 | points: T.Sequence[geo.Point], 26 | use_gpx_start_time: bool = False, 27 | use_image_start_time: bool = False, 28 | offset_time: float = 0.0, 29 | num_processes: int | None = None, 30 | ): 31 | super().__init__(num_processes=num_processes) 32 | self.points = points 33 | self.use_gpx_start_time = use_gpx_start_time 34 | self.use_image_start_time = use_image_start_time 35 | self.offset_time = offset_time 36 | 37 | def _interpolate_image_metadata_along( 38 | self, 39 | image_metadata: types.ImageMetadata, 40 | sorted_points: T.Sequence[geo.Point], 41 | ) -> types.ImageMetadata: 42 | assert sorted_points, "must have at least one point" 43 | 44 | if image_metadata.time < sorted_points[0].time: 45 | delta = sorted_points[0].time - image_metadata.time 46 | gpx_start_time = types.datetime_to_map_capture_time(sorted_points[0].time) 47 | gpx_end_time = types.datetime_to_map_capture_time(sorted_points[-1].time) 48 | # with the tolerance of 1ms 49 | if 0.001 < delta: 50 | raise exceptions.MapillaryOutsideGPXTrackError( 51 | f"The image date time is {round(delta, 3)} seconds behind the GPX start point", 52 | image_time=types.datetime_to_map_capture_time(image_metadata.time), 53 | gpx_start_time=gpx_start_time, 54 | gpx_end_time=gpx_end_time, 55 | ) 56 | 57 | if sorted_points[-1].time < image_metadata.time: 58 | delta = image_metadata.time - sorted_points[-1].time 59 | gpx_start_time = types.datetime_to_map_capture_time(sorted_points[0].time) 60 | gpx_end_time = types.datetime_to_map_capture_time(sorted_points[-1].time) 61 | # with the tolerance of 1ms 62 | if 0.001 < delta: 63 | raise exceptions.MapillaryOutsideGPXTrackError( 64 | f"The image time is {round(delta, 3)} seconds beyond the GPX end point", 65 | image_time=types.datetime_to_map_capture_time(image_metadata.time), 66 | gpx_start_time=gpx_start_time, 67 | gpx_end_time=gpx_end_time, 68 | ) 69 | 70 | interpolated = geo.interpolate(sorted_points, image_metadata.time) 71 | 72 | return dataclasses.replace( 73 | image_metadata, 74 | lat=interpolated.lat, 75 | lon=interpolated.lon, 76 | alt=interpolated.alt, 77 | angle=interpolated.angle, 78 | time=interpolated.time, 79 | ) 80 | 81 | @override 82 | def _generate_image_extractors( 83 | self, image_paths: T.Sequence[Path] 84 | ) -> T.Sequence[ImageEXIFExtractor]: 85 | return [ 86 | ImageEXIFExtractor(path, skip_lonlat_error=True) for path in image_paths 87 | ] 88 | 89 | @override 90 | def to_description( 91 | self, image_paths: T.Sequence[Path] 92 | ) -> list[types.ImageMetadataOrError]: 93 | final_metadatas: list[types.ImageMetadataOrError] = [] 94 | 95 | image_metadata_or_errors = super().to_description(image_paths) 96 | 97 | image_metadatas, error_metadatas = types.separate_errors( 98 | image_metadata_or_errors 99 | ) 100 | final_metadatas.extend(error_metadatas) 101 | 102 | if not image_metadatas: 103 | assert len(image_paths) == len(final_metadatas) 104 | return final_metadatas 105 | 106 | # Do not use point itself for comparison because point.angle or point.alt could be None 107 | # when you compare nonnull value with None, it will throw 108 | sorted_points = sorted(self.points, key=lambda point: point.time) 109 | sorted_image_metadatas = sorted(image_metadatas, key=lambda m: m.sort_key()) 110 | 111 | if self.use_image_start_time: 112 | # assume the image timestamps are [10010, 10020, 10030, 10040] 113 | # the ordered gpx timestamps are [5, 6, 7, 8, 9] 114 | # and NOTE: they they be used as timedelta instead of absolute timestamps 115 | # after the shifting, the gpx timestamps will be [10015, 10016, 10017, 10018, 10019] 116 | sorted_points = [ 117 | geo.Point( 118 | time=sorted_image_metadatas[0].time + p.time, 119 | lat=p.lat, 120 | lon=p.lon, 121 | alt=p.alt, 122 | angle=p.angle, 123 | ) 124 | for p in sorted_points 125 | ] 126 | 127 | image_time_offset = self.offset_time 128 | 129 | if self.use_gpx_start_time: 130 | if sorted_image_metadatas and sorted_points: 131 | # assume the image timestamps are [1002, 1004, 1008, 1010] 132 | # the ordered gpx timestamps are [1005, 1006, 1007, 1008, 1009] 133 | # then the offset will be 5 - 2 = 3 134 | # after the shifting, the image timestamps will be [1005, 1007, 1011, 1013] 135 | time_delta = sorted_points[0].time - sorted_image_metadatas[0].time 136 | LOG.debug("GPX start time delta: %s", time_delta) 137 | image_time_offset += time_delta 138 | 139 | if image_time_offset: 140 | LOG.debug("Final time offset for interpolation: %s", image_time_offset) 141 | for image_metadata in sorted_image_metadatas: 142 | # TODO: this time modification seems to affect final capture times 143 | image_metadata.time += image_time_offset 144 | 145 | for image_metadata in sorted_image_metadatas: 146 | try: 147 | final_metadatas.append( 148 | self._interpolate_image_metadata_along( 149 | image_metadata, sorted_points 150 | ) 151 | ) 152 | except exceptions.MapillaryOutsideGPXTrackError as ex: 153 | error_metadata = types.describe_error_metadata( 154 | ex, image_metadata.filename, filetype=types.FileType.IMAGE 155 | ) 156 | final_metadatas.append(error_metadata) 157 | 158 | assert len(image_paths) == len(final_metadatas) 159 | 160 | return final_metadatas 161 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/geotag_images_from_gpx_file.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from pathlib import Path 5 | 6 | from . import utils 7 | from .geotag_images_from_gpx import GeotagImagesFromGPX 8 | 9 | 10 | LOG = logging.getLogger(__name__) 11 | 12 | 13 | class GeotagImagesFromGPXFile(GeotagImagesFromGPX): 14 | def __init__( 15 | self, 16 | source_path: Path, 17 | use_gpx_start_time: bool = False, 18 | offset_time: float = 0.0, 19 | num_processes: int | None = None, 20 | ): 21 | try: 22 | tracks = utils.parse_gpx(source_path) 23 | except Exception as ex: 24 | raise RuntimeError( 25 | f"Error parsing GPX {source_path}: {ex.__class__.__name__}: {ex}" 26 | ) 27 | 28 | if 1 < len(tracks): 29 | LOG.warning( 30 | "Found %s tracks in the GPX file %s. Will merge points in all the tracks as a single track for interpolation", 31 | len(tracks), 32 | source_path, 33 | ) 34 | points = sum(tracks, []) 35 | super().__init__( 36 | points, 37 | use_gpx_start_time=use_gpx_start_time, 38 | offset_time=offset_time, 39 | num_processes=num_processes, 40 | ) 41 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/geotag_images_from_nmea_file.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | from pathlib import Path 5 | 6 | import pynmea2 7 | 8 | from .. import geo 9 | from .geotag_images_from_gpx import GeotagImagesFromGPX 10 | 11 | 12 | class GeotagImagesFromNMEAFile(GeotagImagesFromGPX): 13 | def __init__( 14 | self, 15 | source_path: Path, 16 | use_gpx_start_time: bool = False, 17 | offset_time: float = 0.0, 18 | num_processes: int | None = None, 19 | ): 20 | points = get_lat_lon_time_from_nmea(source_path) 21 | super().__init__( 22 | points, 23 | use_gpx_start_time=use_gpx_start_time, 24 | offset_time=offset_time, 25 | num_processes=num_processes, 26 | ) 27 | 28 | 29 | def get_lat_lon_time_from_nmea(nmea_file: Path) -> list[geo.Point]: 30 | with nmea_file.open("r") as f: 31 | lines = f.readlines() 32 | lines = [line.rstrip("\n\r") for line in lines] 33 | 34 | # Get initial date 35 | for line in lines: 36 | if "GPRMC" in line: 37 | data = pynmea2.parse(line) 38 | date = data.datetime.date() 39 | break 40 | 41 | # Parse GPS trace 42 | points = [] 43 | for line in lines: 44 | if "GPRMC" in line: 45 | data = pynmea2.parse(line) 46 | date = data.datetime.date() 47 | 48 | if "$GPGGA" in line: 49 | data = pynmea2.parse(line) 50 | dt = datetime.datetime.combine(date, data.timestamp) 51 | lat, lon, alt = data.latitude, data.longitude, data.altitude 52 | points.append( 53 | geo.Point( 54 | time=geo.as_unix_time(dt), lat=lat, lon=lon, alt=alt, angle=None 55 | ) 56 | ) 57 | 58 | points.sort() 59 | return points 60 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/geotag_images_from_video.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sys 5 | import typing as T 6 | from pathlib import Path 7 | 8 | if sys.version_info >= (3, 12): 9 | from typing import override 10 | else: 11 | from typing_extensions import override 12 | 13 | from .. import types, utils 14 | from .base import GeotagImagesFromGeneric 15 | from .geotag_images_from_gpx import GeotagImagesFromGPX 16 | 17 | 18 | LOG = logging.getLogger(__name__) 19 | 20 | 21 | class GeotagImagesFromVideo(GeotagImagesFromGeneric): 22 | def __init__( 23 | self, 24 | video_metadatas: T.Sequence[types.VideoMetadataOrError], 25 | offset_time: float = 0.0, 26 | num_processes: int | None = None, 27 | ): 28 | super().__init__(num_processes=num_processes) 29 | self.video_metadatas = video_metadatas 30 | self.offset_time = offset_time 31 | 32 | @override 33 | def to_description( 34 | self, image_paths: T.Sequence[Path] 35 | ) -> list[types.ImageMetadataOrError]: 36 | # Will return this list 37 | final_image_metadatas: list[types.ImageMetadataOrError] = [] 38 | 39 | video_metadatas, video_error_metadatas = types.separate_errors( 40 | self.video_metadatas 41 | ) 42 | 43 | for video_error_metadata in video_error_metadatas: 44 | video_path = video_error_metadata.filename 45 | sample_paths = list(utils.filter_video_samples(image_paths, video_path)) 46 | LOG.debug( 47 | "Found %d sample images from video %s with error: %s", 48 | len(sample_paths), 49 | video_path, 50 | video_error_metadata.error, 51 | ) 52 | for sample_path in sample_paths: 53 | image_error_metadata = types.describe_error_metadata( 54 | video_error_metadata.error, 55 | sample_path, 56 | filetype=types.FileType.IMAGE, 57 | ) 58 | final_image_metadatas.append(image_error_metadata) 59 | 60 | for video_metadata in video_metadatas: 61 | video_path = video_metadata.filename 62 | 63 | sample_paths = list(utils.filter_video_samples(image_paths, video_path)) 64 | LOG.debug( 65 | "Found %d sample images from video %s", 66 | len(sample_paths), 67 | video_path, 68 | ) 69 | 70 | geotag = GeotagImagesFromGPX( 71 | video_metadata.points, 72 | use_gpx_start_time=False, 73 | use_image_start_time=True, 74 | offset_time=self.offset_time, 75 | num_processes=self.num_processes, 76 | ) 77 | 78 | image_metadatas = geotag.to_description(image_paths) 79 | 80 | for metadata in image_metadatas: 81 | if isinstance(metadata, types.ImageMetadata): 82 | metadata.MAPDeviceMake = video_metadata.make 83 | metadata.MAPDeviceModel = video_metadata.model 84 | 85 | final_image_metadatas.extend(image_metadatas) 86 | 87 | # NOTE: this method only geotags images that have a corresponding video, 88 | # so the number of image metadata objects returned might be less than 89 | # the number of the input image_paths 90 | assert len(final_image_metadatas) <= len(image_paths) 91 | 92 | return final_image_metadatas 93 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/geotag_videos_from_exiftool.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sys 5 | import typing as T 6 | import xml.etree.ElementTree as ET 7 | from pathlib import Path 8 | 9 | if sys.version_info >= (3, 12): 10 | from typing import override 11 | else: 12 | from typing_extensions import override 13 | 14 | from .. import constants, exceptions, exiftool_read, types 15 | from ..exiftool_runner import ExiftoolRunner 16 | from .base import GeotagVideosFromGeneric 17 | from .utils import index_rdf_description_by_path 18 | from .video_extractors.exiftool import VideoExifToolExtractor 19 | 20 | LOG = logging.getLogger(__name__) 21 | 22 | 23 | class GeotagVideosFromExifToolXML(GeotagVideosFromGeneric): 24 | def __init__( 25 | self, 26 | xml_path: Path, 27 | num_processes: int | None = None, 28 | ): 29 | super().__init__(num_processes=num_processes) 30 | self.xml_path = xml_path 31 | 32 | @classmethod 33 | def build_image_extractors( 34 | cls, 35 | rdf_by_path: dict[str, ET.Element], 36 | video_paths: T.Iterable[Path], 37 | ) -> list[VideoExifToolExtractor | types.ErrorMetadata]: 38 | results: list[VideoExifToolExtractor | types.ErrorMetadata] = [] 39 | 40 | for path in video_paths: 41 | rdf = rdf_by_path.get(exiftool_read.canonical_path(path)) 42 | if rdf is None: 43 | ex = exceptions.MapillaryExifToolXMLNotFoundError( 44 | "Cannot find the video in the ExifTool XML" 45 | ) 46 | results.append( 47 | types.describe_error_metadata( 48 | ex, path, filetype=types.FileType.VIDEO 49 | ) 50 | ) 51 | else: 52 | results.append(VideoExifToolExtractor(path, rdf)) 53 | 54 | return results 55 | 56 | @override 57 | def _generate_video_extractors( 58 | self, video_paths: T.Sequence[Path] 59 | ) -> T.Sequence[VideoExifToolExtractor | types.ErrorMetadata]: 60 | rdf_by_path = index_rdf_description_by_path([self.xml_path]) 61 | return self.build_image_extractors(rdf_by_path, video_paths) 62 | 63 | 64 | class GeotagVideosFromExifToolRunner(GeotagVideosFromGeneric): 65 | @override 66 | def _generate_video_extractors( 67 | self, video_paths: T.Sequence[Path] 68 | ) -> T.Sequence[VideoExifToolExtractor | types.ErrorMetadata]: 69 | runner = ExiftoolRunner(constants.EXIFTOOL_PATH) 70 | 71 | LOG.debug( 72 | "Extracting XML from %d videos with ExifTool command: %s", 73 | len(video_paths), 74 | " ".join(runner._build_args_read_stdin()), 75 | ) 76 | try: 77 | xml = runner.extract_xml(video_paths) 78 | except FileNotFoundError as ex: 79 | raise exceptions.MapillaryExiftoolNotFoundError(ex) from ex 80 | 81 | try: 82 | xml_element = ET.fromstring(xml) 83 | except ET.ParseError as ex: 84 | LOG.warning( 85 | "Failed to parse ExifTool XML: %s", 86 | str(ex), 87 | exc_info=LOG.getEffectiveLevel() <= logging.DEBUG, 88 | ) 89 | rdf_by_path = {} 90 | else: 91 | rdf_by_path = exiftool_read.index_rdf_description_by_path_from_xml_element( 92 | xml_element 93 | ) 94 | 95 | return GeotagVideosFromExifToolXML.build_image_extractors( 96 | rdf_by_path, video_paths 97 | ) 98 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/geotag_videos_from_gpx.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sys 5 | import typing as T 6 | from pathlib import Path 7 | 8 | if sys.version_info >= (3, 12): 9 | from typing import override 10 | else: 11 | from typing_extensions import override 12 | 13 | from . import options 14 | from .base import GeotagVideosFromGeneric 15 | from .video_extractors.gpx import GPXVideoExtractor 16 | 17 | 18 | LOG = logging.getLogger(__name__) 19 | 20 | 21 | class GeotagVideosFromGPX(GeotagVideosFromGeneric): 22 | def __init__( 23 | self, 24 | option: options.SourcePathOption | None = None, 25 | num_processes: int | None = None, 26 | ): 27 | super().__init__(num_processes=num_processes) 28 | if option is None: 29 | option = options.SourcePathOption(pattern="%f.gpx") 30 | self.option = option 31 | 32 | @override 33 | def _generate_video_extractors( 34 | self, video_paths: T.Sequence[Path] 35 | ) -> T.Sequence[GPXVideoExtractor]: 36 | return [ 37 | GPXVideoExtractor(video_path, self.option.resolve(video_path)) 38 | for video_path in video_paths 39 | ] 40 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/geotag_videos_from_video.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import typing as T 5 | from pathlib import Path 6 | 7 | if sys.version_info >= (3, 12): 8 | from typing import override 9 | else: 10 | from typing_extensions import override 11 | 12 | from ..types import FileType 13 | from .base import GeotagVideosFromGeneric 14 | from .video_extractors.native import NativeVideoExtractor 15 | 16 | 17 | class GeotagVideosFromVideo(GeotagVideosFromGeneric): 18 | def __init__( 19 | self, 20 | filetypes: set[FileType] | None = None, 21 | num_processes: int | None = None, 22 | ): 23 | super().__init__(num_processes=num_processes) 24 | self.filetypes = filetypes 25 | 26 | @override 27 | def _generate_video_extractors( 28 | self, video_paths: T.Sequence[Path] 29 | ) -> T.Sequence[NativeVideoExtractor]: 30 | return [ 31 | NativeVideoExtractor(path, filetypes=self.filetypes) for path in video_paths 32 | ] 33 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/image_extractors/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from pathlib import Path 5 | 6 | from ... import types 7 | 8 | 9 | class BaseImageExtractor(abc.ABC): 10 | """ 11 | Extracts metadata from an image file. 12 | """ 13 | 14 | def __init__(self, image_path: Path): 15 | self.image_path = image_path 16 | 17 | def extract(self) -> types.ImageMetadataOrError: 18 | raise NotImplementedError 19 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/image_extractors/exif.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import sys 5 | import typing as T 6 | from pathlib import Path 7 | 8 | if sys.version_info >= (3, 12): 9 | from typing import override 10 | else: 11 | from typing_extensions import override 12 | 13 | from ... import exceptions, exif_read, geo, types, utils 14 | from .base import BaseImageExtractor 15 | 16 | 17 | class ImageEXIFExtractor(BaseImageExtractor): 18 | def __init__(self, image_path: Path, skip_lonlat_error: bool = False): 19 | super().__init__(image_path) 20 | self.skip_lonlat_error = skip_lonlat_error 21 | 22 | @contextlib.contextmanager 23 | def _exif_context(self) -> T.Generator[exif_read.ExifReadABC, None, None]: 24 | with self.image_path.open("rb") as fp: 25 | yield exif_read.ExifRead(fp) 26 | 27 | @override 28 | def extract(self) -> types.ImageMetadata: 29 | with self._exif_context() as exif: 30 | lonlat = exif.extract_lon_lat() 31 | if lonlat is None: 32 | if not self.skip_lonlat_error: 33 | raise exceptions.MapillaryGeoTaggingError( 34 | "Unable to extract GPS Longitude or GPS Latitude from the image" 35 | ) 36 | lonlat = (0.0, 0.0) 37 | lon, lat = lonlat 38 | 39 | capture_time = exif.extract_capture_time() 40 | if capture_time is None: 41 | raise exceptions.MapillaryGeoTaggingError( 42 | "Unable to extract timestamp from the image" 43 | ) 44 | 45 | image_metadata = types.ImageMetadata( 46 | filename=self.image_path, 47 | filesize=utils.get_file_size(self.image_path), 48 | time=geo.as_unix_time(capture_time), 49 | lat=lat, 50 | lon=lon, 51 | alt=exif.extract_altitude(), 52 | angle=exif.extract_direction(), 53 | width=exif.extract_width(), 54 | height=exif.extract_height(), 55 | MAPOrientation=exif.extract_orientation(), 56 | MAPDeviceMake=exif.extract_make(), 57 | MAPDeviceModel=exif.extract_model(), 58 | ) 59 | 60 | return image_metadata 61 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/image_extractors/exiftool.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import xml.etree.ElementTree as ET 5 | from pathlib import Path 6 | 7 | from ... import exiftool_read 8 | from .exif import ImageEXIFExtractor 9 | 10 | 11 | class ImageExifToolExtractor(ImageEXIFExtractor): 12 | def __init__(self, image_path: Path, element: ET.Element): 13 | super().__init__(image_path) 14 | self.element = element 15 | 16 | @contextlib.contextmanager 17 | def _exif_context(self): 18 | yield exiftool_read.ExifToolRead(ET.ElementTree(self.element)) 19 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/options.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | import enum 5 | import json 6 | import typing as T 7 | from pathlib import Path 8 | 9 | import jsonschema 10 | 11 | from .. import types 12 | 13 | 14 | class SourceType(enum.Enum): 15 | NATIVE = "native" 16 | GPX = "gpx" 17 | NMEA = "nmea" 18 | EXIFTOOL_XML = "exiftool_xml" 19 | EXIFTOOL_RUNTIME = "exiftool_runtime" 20 | 21 | # Legacy source types for images 22 | GOPRO = "gopro" 23 | BLACKVUE = "blackvue" 24 | CAMM = "camm" 25 | EXIF = "exif" 26 | 27 | 28 | SOURCE_TYPE_ALIAS: dict[str, SourceType] = { 29 | "blackvue_videos": SourceType.BLACKVUE, 30 | "gopro_videos": SourceType.GOPRO, 31 | "exiftool": SourceType.EXIFTOOL_RUNTIME, 32 | } 33 | 34 | 35 | @dataclasses.dataclass 36 | class SourceOption: 37 | # Type of the source 38 | source: SourceType 39 | 40 | # Filter by these filetypes 41 | filetypes: set[types.FileType] | None = None 42 | 43 | num_processes: int | None = None 44 | 45 | source_path: SourcePathOption | None = None 46 | 47 | interpolation: InterpolationOption | None = None 48 | 49 | @classmethod 50 | def from_dict(cls, data: dict[str, T.Any]) -> SourceOption: 51 | validate_option(data) 52 | 53 | kwargs: dict[str, T.Any] = {} 54 | for k, v in data.items(): 55 | # None values are considered as absent and should be ignored 56 | if v is None: 57 | continue 58 | if k == "source": 59 | kwargs[k] = SourceType(SOURCE_TYPE_ALIAS.get(v, v)) 60 | elif k == "filetypes": 61 | kwargs[k] = {types.FileType(t) for t in v} 62 | elif k == "source_path": 63 | kwargs.setdefault("source_path", SourcePathOption()).source_path = v 64 | elif k == "pattern": 65 | kwargs.setdefault("source_path", SourcePathOption()).pattern = v 66 | elif k == "interpolation_offset_time": 67 | kwargs.setdefault( 68 | "interpolation", InterpolationOption() 69 | ).offset_time = v 70 | elif k == "interpolation_use_gpx_start_time": 71 | kwargs.setdefault( 72 | "interpolation", InterpolationOption() 73 | ).use_gpx_start_time = v 74 | 75 | return cls(**kwargs) 76 | 77 | 78 | @dataclasses.dataclass 79 | class SourcePathOption: 80 | pattern: str | None = None 81 | source_path: Path | None = None 82 | 83 | def __post_init__(self): 84 | if self.source_path is None and self.pattern is None: 85 | raise ValueError("Either pattern or source_path must be provided") 86 | 87 | def resolve(self, path: Path) -> Path: 88 | if self.source_path is not None: 89 | return self.source_path 90 | 91 | assert self.pattern is not None, ( 92 | "either pattern or source_path must be provided" 93 | ) 94 | 95 | # %f: the full video filename (foo.mp4) 96 | # %g: the video filename without extension (foo) 97 | # %e: the video filename extension (.mp4) 98 | replaced = Path( 99 | self.pattern.replace("%f", path.name) 100 | .replace("%g", path.stem) 101 | .replace("%e", path.suffix) 102 | ) 103 | 104 | abs_path = ( 105 | replaced 106 | if replaced.is_absolute() 107 | else Path.joinpath(path.parent.resolve(), replaced) 108 | ).resolve() 109 | 110 | return abs_path 111 | 112 | 113 | @dataclasses.dataclass 114 | class InterpolationOption: 115 | offset_time: float = 0.0 116 | use_gpx_start_time: bool = False 117 | 118 | 119 | SourceOptionSchema = { 120 | "type": "object", 121 | "properties": { 122 | "source": { 123 | "type": "string", 124 | "enum": [s.value for s in SourceType] + list(SOURCE_TYPE_ALIAS.keys()), 125 | }, 126 | "filetypes": { 127 | "type": "array", 128 | "items": { 129 | "type": "string", 130 | "enum": [t.value for t in types.FileType], 131 | }, 132 | }, 133 | "source_path": { 134 | "type": "string", 135 | }, 136 | "pattern": { 137 | "type": "string", 138 | }, 139 | "num_processes": { 140 | "type": "integer", 141 | }, 142 | "interpolation_offset_time": { 143 | "type": "float", 144 | }, 145 | "interpolation_use_gpx_start_time": { 146 | "type": "boolean", 147 | }, 148 | }, 149 | "required": ["source"], 150 | "additionalProperties": False, 151 | } 152 | 153 | 154 | def validate_option(instance): 155 | jsonschema.validate(instance=instance, schema=SourceOptionSchema) 156 | 157 | 158 | if __name__ == "__main__": 159 | # python -m mapillary_tools.geotag.options > schema/geotag_source_option.json 160 | print(json.dumps(SourceOptionSchema, indent=4)) 161 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import typing as T 5 | import xml.etree.ElementTree as ET 6 | from pathlib import Path 7 | 8 | import gpxpy 9 | 10 | from .. import exiftool_read, geo, utils 11 | 12 | Track = T.List[geo.Point] 13 | LOG = logging.getLogger(__name__) 14 | 15 | 16 | def parse_gpx(gpx_file: Path) -> list[Track]: 17 | with gpx_file.open("r") as f: 18 | gpx = gpxpy.parse(f) 19 | 20 | tracks: list[Track] = [] 21 | 22 | for track in gpx.tracks: 23 | for segment in track.segments: 24 | tracks.append([]) 25 | for point in segment.points: 26 | if point.time is not None: 27 | tracks[-1].append( 28 | geo.Point( 29 | time=geo.as_unix_time(point.time), 30 | lat=point.latitude, 31 | lon=point.longitude, 32 | alt=point.elevation, 33 | angle=None, 34 | ) 35 | ) 36 | 37 | return tracks 38 | 39 | 40 | def index_rdf_description_by_path( 41 | xml_paths: T.Sequence[Path], 42 | ) -> dict[str, ET.Element]: 43 | rdf_description_by_path: dict[str, ET.Element] = {} 44 | 45 | for xml_path in utils.find_xml_files(xml_paths): 46 | try: 47 | etree = ET.parse(xml_path) 48 | except ET.ParseError as ex: 49 | verbose = LOG.getEffectiveLevel() <= logging.DEBUG 50 | if verbose: 51 | LOG.warning("Failed to parse %s", xml_path, exc_info=True) 52 | else: 53 | LOG.warning("Failed to parse %s: %s", xml_path, ex) 54 | continue 55 | 56 | rdf_description_by_path.update( 57 | exiftool_read.index_rdf_description_by_path_from_xml_element( 58 | etree.getroot() 59 | ) 60 | ) 61 | 62 | return rdf_description_by_path 63 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/video_extractors/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from pathlib import Path 5 | 6 | from ... import types 7 | 8 | 9 | class BaseVideoExtractor(abc.ABC): 10 | """ 11 | Extracts metadata from a video file. 12 | """ 13 | 14 | def __init__(self, video_path: Path): 15 | self.video_path = video_path 16 | 17 | def extract(self) -> types.VideoMetadataOrError: 18 | raise NotImplementedError 19 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/video_extractors/exiftool.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import typing as T 5 | from pathlib import Path 6 | from xml.etree import ElementTree as ET 7 | 8 | if sys.version_info >= (3, 12): 9 | from typing import override 10 | else: 11 | from typing_extensions import override 12 | 13 | from ... import exceptions, exiftool_read_video, geo, telemetry, types, utils 14 | from ...gpmf import gpmf_gps_filter 15 | from .base import BaseVideoExtractor 16 | 17 | 18 | class VideoExifToolExtractor(BaseVideoExtractor): 19 | def __init__(self, video_path: Path, element: ET.Element): 20 | super().__init__(video_path) 21 | self.element = element 22 | 23 | @override 24 | def extract(self) -> types.VideoMetadataOrError: 25 | exif = exiftool_read_video.ExifToolReadVideo(ET.ElementTree(self.element)) 26 | 27 | make = exif.extract_make() 28 | model = exif.extract_model() 29 | 30 | is_gopro = make is not None and make.upper() in ["GOPRO"] 31 | 32 | points = exif.extract_gps_track() 33 | 34 | # ExifTool has no idea if GPS is not found or found but empty 35 | if is_gopro: 36 | if not points: 37 | raise exceptions.MapillaryGPXEmptyError("Empty GPS data found") 38 | 39 | # ExifTool (since 13.04) converts GPSSpeed for GoPro to km/h, so here we convert it back to m/s 40 | for p in points: 41 | if isinstance(p, telemetry.GPSPoint) and p.ground_speed is not None: 42 | p.ground_speed = p.ground_speed / 3.6 43 | 44 | if isinstance(points[0], telemetry.GPSPoint): 45 | points = T.cast( 46 | T.List[geo.Point], 47 | gpmf_gps_filter.remove_noisy_points( 48 | T.cast(T.List[telemetry.GPSPoint], points) 49 | ), 50 | ) 51 | if not points: 52 | raise exceptions.MapillaryGPSNoiseError("GPS is too noisy") 53 | 54 | if not points: 55 | raise exceptions.MapillaryVideoGPSNotFoundError( 56 | "No GPS data found from the video" 57 | ) 58 | 59 | filetype = types.FileType.GOPRO if is_gopro else types.FileType.VIDEO 60 | 61 | video_metadata = types.VideoMetadata( 62 | self.video_path, 63 | filesize=utils.get_file_size(self.video_path), 64 | filetype=filetype, 65 | points=points, 66 | make=make, 67 | model=model, 68 | ) 69 | 70 | return video_metadata 71 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/video_extractors/gpx.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | import datetime 5 | import logging 6 | import sys 7 | import typing as T 8 | from pathlib import Path 9 | 10 | if sys.version_info >= (3, 12): 11 | from typing import override 12 | else: 13 | from typing_extensions import override 14 | 15 | from ... import geo, telemetry, types 16 | from ..utils import parse_gpx 17 | from .base import BaseVideoExtractor 18 | from .native import NativeVideoExtractor 19 | 20 | 21 | LOG = logging.getLogger(__name__) 22 | 23 | 24 | class GPXVideoExtractor(BaseVideoExtractor): 25 | def __init__(self, video_path: Path, gpx_path: Path): 26 | self.video_path = video_path 27 | self.gpx_path = gpx_path 28 | 29 | @override 30 | def extract(self) -> types.VideoMetadataOrError: 31 | try: 32 | gpx_tracks = parse_gpx(self.gpx_path) 33 | except Exception as ex: 34 | raise RuntimeError( 35 | f"Error parsing GPX {self.gpx_path}: {ex.__class__.__name__}: {ex}" 36 | ) 37 | 38 | if 1 < len(gpx_tracks): 39 | LOG.warning( 40 | "Found %s tracks in the GPX file %s. Will merge points in all the tracks as a single track for interpolation", 41 | len(gpx_tracks), 42 | self.gpx_path, 43 | ) 44 | 45 | gpx_points: T.Sequence[geo.Point] = sum(gpx_tracks, []) 46 | 47 | native_extractor = NativeVideoExtractor(self.video_path) 48 | 49 | video_metadata_or_error = native_extractor.extract() 50 | 51 | if isinstance(video_metadata_or_error, types.ErrorMetadata): 52 | self._rebase_times(gpx_points) 53 | return types.VideoMetadata( 54 | filename=video_metadata_or_error.filename, 55 | filetype=video_metadata_or_error.filetype or types.FileType.VIDEO, 56 | points=gpx_points, 57 | ) 58 | 59 | video_metadata = video_metadata_or_error 60 | 61 | offset = self._synx_gpx_by_first_gps_timestamp( 62 | gpx_points, video_metadata.points 63 | ) 64 | 65 | self._rebase_times(gpx_points, offset=offset) 66 | 67 | return dataclasses.replace(video_metadata_or_error, points=gpx_points) 68 | 69 | @staticmethod 70 | def _rebase_times(points: T.Sequence[geo.Point], offset: float = 0.0): 71 | """ 72 | Make point times start from 0 73 | """ 74 | if points: 75 | first_timestamp = points[0].time 76 | for p in points: 77 | p.time = (p.time - first_timestamp) + offset 78 | return points 79 | 80 | def _synx_gpx_by_first_gps_timestamp( 81 | self, gpx_points: T.Sequence[geo.Point], video_gps_points: T.Sequence[geo.Point] 82 | ) -> float: 83 | offset: float = 0.0 84 | 85 | if not gpx_points: 86 | return offset 87 | 88 | first_gpx_dt = datetime.datetime.fromtimestamp( 89 | gpx_points[0].time, tz=datetime.timezone.utc 90 | ) 91 | LOG.info("First GPX timestamp: %s", first_gpx_dt) 92 | 93 | if not video_gps_points: 94 | LOG.warning( 95 | "Skip GPX synchronization because no GPS found in video %s", 96 | self.video_path, 97 | ) 98 | return offset 99 | 100 | first_gps_point = video_gps_points[0] 101 | if isinstance(first_gps_point, telemetry.GPSPoint): 102 | if first_gps_point.epoch_time is not None: 103 | first_gps_dt = datetime.datetime.fromtimestamp( 104 | first_gps_point.epoch_time, tz=datetime.timezone.utc 105 | ) 106 | LOG.info("First GPS timestamp: %s", first_gps_dt) 107 | offset = gpx_points[0].time - first_gps_point.epoch_time 108 | if offset: 109 | LOG.warning( 110 | "Found offset between GPX %s and video GPS timestamps %s: %s seconds", 111 | first_gpx_dt, 112 | first_gps_dt, 113 | offset, 114 | ) 115 | else: 116 | LOG.info( 117 | "GPX and GPS are perfectly synchronized (all starts from %s)", 118 | first_gpx_dt, 119 | ) 120 | else: 121 | LOG.warning( 122 | "Skip GPX synchronization because no GPS epoch time found in video %s", 123 | self.video_path, 124 | ) 125 | 126 | return offset 127 | -------------------------------------------------------------------------------- /mapillary_tools/geotag/video_extractors/native.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import typing as T 5 | from pathlib import Path 6 | 7 | if sys.version_info >= (3, 12): 8 | from typing import override 9 | else: 10 | from typing_extensions import override 11 | 12 | from ... import blackvue_parser, exceptions, geo, telemetry, types, utils 13 | from ...camm import camm_parser 14 | from ...gpmf import gpmf_gps_filter, gpmf_parser 15 | from .base import BaseVideoExtractor 16 | 17 | 18 | class GoProVideoExtractor(BaseVideoExtractor): 19 | @override 20 | def extract(self) -> types.VideoMetadataOrError: 21 | with self.video_path.open("rb") as fp: 22 | gopro_info = gpmf_parser.extract_gopro_info(fp) 23 | 24 | if gopro_info is None: 25 | raise exceptions.MapillaryVideoGPSNotFoundError( 26 | "No GPS data found from the video" 27 | ) 28 | 29 | gps_points = gopro_info.gps 30 | assert gps_points is not None, "must have GPS data extracted" 31 | if not gps_points: 32 | # Instead of raising an exception, return error metadata to tell the file type 33 | ex: exceptions.MapillaryDescriptionError = ( 34 | exceptions.MapillaryGPXEmptyError("Empty GPS data found") 35 | ) 36 | return types.describe_error_metadata( 37 | ex, self.video_path, filetype=types.FileType.GOPRO 38 | ) 39 | 40 | gps_points = T.cast( 41 | T.List[telemetry.GPSPoint], gpmf_gps_filter.remove_noisy_points(gps_points) 42 | ) 43 | if not gps_points: 44 | # Instead of raising an exception, return error metadata to tell the file type 45 | ex = exceptions.MapillaryGPSNoiseError("GPS is too noisy") 46 | return types.describe_error_metadata( 47 | ex, self.video_path, filetype=types.FileType.GOPRO 48 | ) 49 | 50 | video_metadata = types.VideoMetadata( 51 | filename=self.video_path, 52 | filesize=utils.get_file_size(self.video_path), 53 | filetype=types.FileType.GOPRO, 54 | points=T.cast(T.List[geo.Point], gps_points), 55 | make=gopro_info.make, 56 | model=gopro_info.model, 57 | ) 58 | 59 | return video_metadata 60 | 61 | 62 | class CAMMVideoExtractor(BaseVideoExtractor): 63 | @override 64 | def extract(self) -> types.VideoMetadataOrError: 65 | with self.video_path.open("rb") as fp: 66 | camm_info = camm_parser.extract_camm_info(fp) 67 | 68 | if camm_info is None: 69 | raise exceptions.MapillaryVideoGPSNotFoundError( 70 | "No GPS data found from the video" 71 | ) 72 | 73 | if not camm_info.gps and not camm_info.mini_gps: 74 | # Instead of raising an exception, return error metadata to tell the file type 75 | ex: exceptions.MapillaryDescriptionError = ( 76 | exceptions.MapillaryGPXEmptyError("Empty GPS data found") 77 | ) 78 | return types.describe_error_metadata( 79 | ex, self.video_path, filetype=types.FileType.CAMM 80 | ) 81 | 82 | return types.VideoMetadata( 83 | filename=self.video_path, 84 | filesize=utils.get_file_size(self.video_path), 85 | filetype=types.FileType.CAMM, 86 | points=T.cast(T.List[geo.Point], camm_info.gps or camm_info.mini_gps), 87 | make=camm_info.make, 88 | model=camm_info.model, 89 | ) 90 | 91 | 92 | class BlackVueVideoExtractor(BaseVideoExtractor): 93 | @override 94 | def extract(self) -> types.VideoMetadataOrError: 95 | with self.video_path.open("rb") as fp: 96 | blackvue_info = blackvue_parser.extract_blackvue_info(fp) 97 | 98 | if blackvue_info is None: 99 | raise exceptions.MapillaryVideoGPSNotFoundError( 100 | "No GPS data found from the video" 101 | ) 102 | 103 | if not blackvue_info.gps: 104 | # Instead of raising an exception, return error metadata to tell the file type 105 | ex: exceptions.MapillaryDescriptionError = ( 106 | exceptions.MapillaryGPXEmptyError("Empty GPS data found") 107 | ) 108 | return types.describe_error_metadata( 109 | ex, self.video_path, filetype=types.FileType.BLACKVUE 110 | ) 111 | 112 | video_metadata = types.VideoMetadata( 113 | filename=self.video_path, 114 | filesize=utils.get_file_size(self.video_path), 115 | filetype=types.FileType.BLACKVUE, 116 | points=blackvue_info.gps or [], 117 | make=blackvue_info.make, 118 | model=blackvue_info.model, 119 | ) 120 | 121 | return video_metadata 122 | 123 | 124 | class NativeVideoExtractor(BaseVideoExtractor): 125 | def __init__(self, video_path: Path, filetypes: set[types.FileType] | None = None): 126 | super().__init__(video_path) 127 | self.filetypes = filetypes 128 | 129 | @override 130 | def extract(self) -> types.VideoMetadataOrError: 131 | ft = self.filetypes 132 | extractor: BaseVideoExtractor 133 | 134 | if ft is None or types.FileType.VIDEO in ft or types.FileType.GOPRO in ft: 135 | extractor = GoProVideoExtractor(self.video_path) 136 | try: 137 | return extractor.extract() 138 | except exceptions.MapillaryVideoGPSNotFoundError: 139 | pass 140 | 141 | if ft is None or types.FileType.VIDEO in ft or types.FileType.CAMM in ft: 142 | extractor = CAMMVideoExtractor(self.video_path) 143 | try: 144 | return extractor.extract() 145 | except exceptions.MapillaryVideoGPSNotFoundError: 146 | pass 147 | 148 | if ft is None or types.FileType.VIDEO in ft or types.FileType.BLACKVUE in ft: 149 | extractor = BlackVueVideoExtractor(self.video_path) 150 | try: 151 | return extractor.extract() 152 | except exceptions.MapillaryVideoGPSNotFoundError: 153 | pass 154 | 155 | raise exceptions.MapillaryVideoGPSNotFoundError( 156 | "No GPS data found from the video" 157 | ) 158 | -------------------------------------------------------------------------------- /mapillary_tools/gpmf/gpmf_gps_filter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing as T 3 | 4 | from .. import constants, geo 5 | from ..telemetry import GPSPoint 6 | from . import gps_filter 7 | 8 | """ 9 | This module was originally used for GoPro GPS data (GPMF) filtering, 10 | but it now can be used for any GPS data with fixes, precisions, and ground speeds. 11 | """ 12 | 13 | LOG = logging.getLogger(__name__) 14 | 15 | 16 | def remove_outliers( 17 | sequence: T.Sequence[GPSPoint], 18 | ) -> T.Sequence[GPSPoint]: 19 | distances = [ 20 | geo.gps_distance((left.lat, left.lon), (right.lat, right.lon)) 21 | for left, right in geo.pairwise(sequence) 22 | ] 23 | if len(distances) < 2: 24 | return sequence 25 | 26 | max_distance = gps_filter.upper_whisker(distances) 27 | LOG.debug("max distance: %f", max_distance) 28 | max_distance = max( 29 | # distance between two points hence double 30 | constants.GOPRO_GPS_PRECISION + constants.GOPRO_GPS_PRECISION, 31 | max_distance, 32 | ) 33 | sequences = gps_filter.split_if( 34 | T.cast(T.List[geo.Point], sequence), 35 | gps_filter.distance_gt(max_distance), 36 | ) 37 | LOG.debug( 38 | "Split to %d sequences with max distance %f", len(sequences), max_distance 39 | ) 40 | 41 | ground_speeds = [p.ground_speed for p in sequence if p.ground_speed is not None] 42 | if len(ground_speeds) < 2: 43 | return sequence 44 | 45 | max_speed = gps_filter.upper_whisker(ground_speeds) 46 | merged = gps_filter.dbscan(sequences, gps_filter.speed_le(max_speed)) 47 | LOG.debug( 48 | "Found %d sequences after merging with max speed %f", len(merged), max_speed 49 | ) 50 | 51 | return T.cast( 52 | T.List[GPSPoint], 53 | gps_filter.find_majority(merged.values()), 54 | ) 55 | 56 | 57 | def remove_noisy_points( 58 | sequence: T.Sequence[GPSPoint], 59 | ) -> T.Sequence[GPSPoint]: 60 | num_points = len(sequence) 61 | sequence = [ 62 | p 63 | for p in sequence 64 | # include points **without** GPS fix 65 | if p.fix is None or p.fix.value in constants.GOPRO_GPS_FIXES 66 | ] 67 | if len(sequence) < num_points: 68 | LOG.debug( 69 | "Removed %d points with the GPS fix not in %s", 70 | num_points - len(sequence), 71 | constants.GOPRO_GPS_FIXES, 72 | ) 73 | 74 | num_points = len(sequence) 75 | sequence = [ 76 | p 77 | for p in sequence 78 | # include points **without** precision 79 | if p.precision is None or p.precision <= constants.GOPRO_MAX_DOP100 80 | ] 81 | if len(sequence) < num_points: 82 | LOG.debug( 83 | "Removed %d points with DoP value higher than %d", 84 | num_points - len(sequence), 85 | constants.GOPRO_MAX_DOP100, 86 | ) 87 | 88 | num_points = len(sequence) 89 | sequence = remove_outliers(sequence) 90 | if len(sequence) < num_points: 91 | LOG.debug( 92 | "Removed %d outlier points", 93 | num_points - len(sequence), 94 | ) 95 | 96 | return sequence 97 | -------------------------------------------------------------------------------- /mapillary_tools/gpmf/gps_filter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import statistics 4 | import typing as T 5 | 6 | from .. import geo 7 | 8 | 9 | PointSequence = T.List[geo.Point] 10 | Decider = T.Callable[[geo.Point, geo.Point], bool] 11 | 12 | 13 | def calculate_point_speed(p1: geo.Point, p2: geo.Point) -> float: 14 | """ 15 | Calculate the ground speed between two points (from p1 to p2). 16 | """ 17 | s = geo.gps_distance((p1.lat, p1.lon), (p2.lat, p2.lon)) 18 | t = abs(p2.time - p1.time) 19 | try: 20 | return s / t 21 | except ZeroDivisionError: 22 | return float("inf") if 0 <= s else float("-inf") 23 | 24 | 25 | def upper_whisker(values: T.Sequence[float]) -> float: 26 | """ 27 | Calculate the upper whisker (i.e. Q3 + IRQ * 1.5) of the input values. 28 | Values larger than it are considered as outliers. 29 | See https://en.wikipedia.org/wiki/Interquartile_range 30 | """ 31 | 32 | values = sorted(values) 33 | n = len(values) 34 | if n < 2: 35 | raise statistics.StatisticsError("at least 2 values are required for IQR") 36 | median_idx = n // 2 37 | q1 = statistics.median(values[:median_idx]) 38 | if n % 2 == 1: 39 | # for values [0, 1, 2, 3, 4], q3 will be [3, 4] 40 | q3 = statistics.median(values[median_idx + 1 :]) 41 | else: 42 | # for values [0, 1, 2, 3], q3 will be [2, 3] 43 | q3 = statistics.median(values[median_idx:]) 44 | irq = q3 - q1 45 | return q3 + irq * 1.5 46 | 47 | 48 | def split_if( 49 | points: PointSequence, 50 | split_or_not: Decider, 51 | ) -> T.List[PointSequence]: 52 | if not points: 53 | return [] 54 | 55 | sequences: T.List[PointSequence] = [] 56 | for idx, point in enumerate(points): 57 | if sequences and not split_or_not(points[idx - 1], point): 58 | sequences[-1].append(point) 59 | else: 60 | sequences.append([point]) 61 | assert len(points) == sum(len(g) for g in sequences) 62 | 63 | return sequences 64 | 65 | 66 | def distance_gt( 67 | max_distance: float, 68 | ) -> Decider: 69 | """Return a callable that checks if two points are farther than the given distance.""" 70 | 71 | def _split_or_not(p1, p2): 72 | distance = geo.gps_distance((p1.lat, p1.lon), (p2.lat, p2.lon)) 73 | return distance > max_distance 74 | 75 | return _split_or_not 76 | 77 | 78 | def speed_le(max_speed: float) -> Decider: 79 | """Return a callable that checks if the speed between two points are slower than the given speed.""" 80 | 81 | def _split_or_not(p1, p2): 82 | speed = calculate_point_speed(p1, p2) 83 | return speed <= max_speed 84 | 85 | return _split_or_not 86 | 87 | 88 | def both( 89 | s1: Decider, 90 | s2: Decider, 91 | ) -> Decider: 92 | def _f(p1, p2): 93 | return s1(p1, p2) and s2(p1, p2) 94 | 95 | return _f 96 | 97 | 98 | def dbscan( 99 | sequences: T.Sequence[PointSequence], 100 | merge_or_not: Decider, 101 | ) -> dict[int, PointSequence]: 102 | """ 103 | One-dimension DBSCAN clustering: https://en.wikipedia.org/wiki/DBSCAN 104 | The input is a list of sequences, and it is guaranteed that all sequences are sorted by time. 105 | The function clusters sequences by checking if two sequences can be merged or not. 106 | 107 | - minPoints is always 1 108 | - merge_or_not decides if two points are in the same cluster 109 | """ 110 | 111 | # find which sequences (keys) should be merged to which sequences (values) 112 | mergeto: dict[int, int] = {} 113 | for left in range(len(sequences)): 114 | mergeto.setdefault(left, left) 115 | # find the first sequence to merge with 116 | for right in range(left + 1, len(sequences)): 117 | if right in mergeto: 118 | continue 119 | if merge_or_not(sequences[left][-1], sequences[right][0]): 120 | mergeto[right] = mergeto[left] 121 | break 122 | 123 | # merge 124 | merged: dict[int, PointSequence] = {} 125 | for idx, s in enumerate(sequences): 126 | merged.setdefault(mergeto[idx], []).extend(s) 127 | 128 | return merged 129 | 130 | 131 | def find_majority(sequences: T.Collection[PointSequence]) -> PointSequence: 132 | return sorted(sequences, key=lambda g: len(g), reverse=True)[0] 133 | -------------------------------------------------------------------------------- /mapillary_tools/history.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import logging 5 | import string 6 | import typing as T 7 | from pathlib import Path 8 | 9 | from . import constants, types 10 | 11 | JSONDict = T.Dict[str, T.Union[str, int, float, None]] 12 | 13 | LOG = logging.getLogger(__name__) 14 | 15 | 16 | def _validate_hexdigits(md5sum: str): 17 | try: 18 | assert set(md5sum).issubset(string.hexdigits) 19 | assert 4 <= len(md5sum) 20 | _ = int(md5sum, 16) 21 | except Exception: 22 | raise ValueError(f"Invalid md5sum {md5sum}") 23 | 24 | 25 | def history_desc_path(md5sum: str) -> Path: 26 | _validate_hexdigits(md5sum) 27 | subfolder = md5sum[:2] 28 | assert subfolder, f"Invalid md5sum {md5sum}" 29 | basename = md5sum[2:] 30 | assert basename, f"Invalid md5sum {md5sum}" 31 | return ( 32 | Path(constants.MAPILLARY_UPLOAD_HISTORY_PATH) 33 | .joinpath(subfolder) 34 | .joinpath(f"{basename}.json") 35 | ) 36 | 37 | 38 | def is_uploaded(md5sum: str) -> bool: 39 | if not constants.MAPILLARY_UPLOAD_HISTORY_PATH: 40 | return False 41 | return history_desc_path(md5sum).is_file() 42 | 43 | 44 | def write_history( 45 | md5sum: str, 46 | params: JSONDict, 47 | summary: JSONDict, 48 | metadatas: T.Sequence[types.Metadata] | None = None, 49 | ) -> None: 50 | if not constants.MAPILLARY_UPLOAD_HISTORY_PATH: 51 | return 52 | path = history_desc_path(md5sum) 53 | LOG.debug("Writing upload history: %s", path) 54 | path.resolve().parent.mkdir(parents=True, exist_ok=True) 55 | history: dict[str, T.Any] = { 56 | "params": params, 57 | "summary": summary, 58 | } 59 | if metadatas is not None: 60 | history["descs"] = [types.as_desc(metadata) for metadata in metadatas] 61 | with open(path, "w") as fp: 62 | fp.write(json.dumps(history)) 63 | -------------------------------------------------------------------------------- /mapillary_tools/ipc.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import struct 5 | 6 | 7 | LOG = logging.getLogger(__name__) 8 | NODE_CHANNEL_FD = int(os.getenv("NODE_CHANNEL_FD", -1)) 9 | 10 | 11 | def _write(obj): 12 | # put here to make sure obj is JSON-serializable, and if not, fail early 13 | data = json.dumps(obj, separators=(",", ":")) + os.linesep 14 | 15 | if NODE_CHANNEL_FD == -1: 16 | # do nothing 17 | return 18 | 19 | if os.name == "nt": 20 | buf = data.encode("utf-8") 21 | # On windows, using node v8.11.4, this assertion fails 22 | # without sending the header 23 | # Assertion failed: ipc_frame.header.flags <= (UV_IPC_TCP_SERVER | 24 | # UV_IPC_RAW_DATA | UV_IPC_TCP_CONNECTION), 25 | # file src\win\pipe.c, line 1607 26 | header = struct.pack(" None: 26 | """ 27 | seek to the end of the current stream, and seek to the beginning of the next stream 28 | """ 29 | if self._idx < len(self._streams): 30 | s = self._streams[self._idx] 31 | ssize = s.seek(0, io.SEEK_END) 32 | 33 | # update index 34 | self._idx += 1 35 | 36 | # seek to the beginning of the next stream 37 | if self._idx < len(self._streams): 38 | self._streams[self._idx].seek(0, io.SEEK_SET) 39 | 40 | # update offset 41 | self._begin_offset += ssize 42 | 43 | def read(self, n: int = -1) -> bytes: 44 | acc = [] 45 | 46 | while self._idx < len(self._streams): 47 | data = self._streams[self._idx].read(n) 48 | acc.append(data) 49 | if n == -1: 50 | self._seek_next_stream() 51 | elif len(data) < n: 52 | n = n - len(data) 53 | self._seek_next_stream() 54 | else: 55 | break 56 | 57 | return b"".join(acc) 58 | 59 | def seekable(self) -> bool: 60 | return True 61 | 62 | def writable(self) -> bool: 63 | return False 64 | 65 | def readable(self) -> bool: 66 | return True 67 | 68 | def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: 69 | if whence == io.SEEK_CUR: 70 | if offset < 0: 71 | raise ValueError("negative offset not supported yet") 72 | 73 | while self._idx < len(self._streams): 74 | s = self._streams[self._idx] 75 | co = s.tell() 76 | eo = s.seek(0, io.SEEK_END) 77 | assert co <= eo 78 | if offset <= eo - co: 79 | s.seek(co + offset, io.SEEK_SET) 80 | offset = 0 81 | break 82 | self._seek_next_stream() 83 | offset = offset - (eo - co) 84 | 85 | if 0 < offset: 86 | self._offset_after_seek_end += offset 87 | 88 | elif whence == io.SEEK_SET: 89 | self._idx = 0 90 | self._begin_offset = 0 91 | self._offset_after_seek_end = 0 92 | self._streams[self._idx].seek(0, io.SEEK_SET) 93 | if offset: 94 | self.seek(offset, io.SEEK_CUR) 95 | 96 | elif whence == io.SEEK_END: 97 | self._idx = 0 98 | self._begin_offset = 0 99 | self._offset_after_seek_end = 0 100 | while self._idx < len(self._streams): 101 | self._seek_next_stream() 102 | if offset: 103 | self.seek(offset, io.SEEK_CUR) 104 | 105 | else: 106 | raise IOError("invalid whence") 107 | 108 | return self.tell() 109 | 110 | def tell(self) -> int: 111 | if self._idx < len(self._streams): 112 | rel_offset = self._streams[self._idx].tell() 113 | else: 114 | rel_offset = self._offset_after_seek_end 115 | 116 | return self._begin_offset + rel_offset 117 | 118 | def close(self) -> None: 119 | for b in self._streams: 120 | b.close() 121 | return None 122 | 123 | 124 | class SlicedIO(io.IOBase): 125 | __slots__ = ("_source", "_begin_offset", "_rel_offset", "_size") 126 | 127 | _source: T.BinaryIO 128 | _begin_offset: int 129 | _rel_offset: int 130 | _size: int 131 | 132 | def __init__(self, source: T.BinaryIO, offset: int, size: int) -> None: 133 | assert source.readable(), "source stream must be readable" 134 | assert source.seekable(), "source stream must be seekable" 135 | self._source = source 136 | if offset < 0: 137 | raise ValueError(f"negative offset {offset}") 138 | self._begin_offset = offset 139 | self._rel_offset = 0 140 | self._size = size 141 | 142 | def read(self, n: int = -1) -> bytes: 143 | if self._rel_offset < self._size: 144 | self._source.seek(self._begin_offset + self._rel_offset, io.SEEK_SET) 145 | remaining = self._size - self._rel_offset 146 | max_read = remaining if n == -1 else min(n, remaining) 147 | data = self._source.read(max_read) 148 | self._rel_offset += len(data) 149 | return data 150 | else: 151 | return b"" 152 | 153 | def seekable(self) -> bool: 154 | return True 155 | 156 | def writable(self) -> bool: 157 | return False 158 | 159 | def readable(self) -> bool: 160 | return True 161 | 162 | def tell(self) -> int: 163 | return self._rel_offset 164 | 165 | def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: 166 | if whence == io.SEEK_SET: 167 | new_offset = offset 168 | if new_offset < 0: 169 | raise ValueError(f"negative seek value {new_offset}") 170 | elif whence == io.SEEK_CUR: 171 | new_offset = max(0, self._rel_offset + offset) 172 | elif whence == io.SEEK_END: 173 | new_offset = max(0, self._size + offset) 174 | else: 175 | raise IOError("invalid whence") 176 | self._rel_offset = new_offset 177 | return self._rel_offset 178 | -------------------------------------------------------------------------------- /mapillary_tools/telemetry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | from enum import Enum, unique 5 | 6 | from .geo import Point 7 | 8 | 9 | @unique 10 | class GPSFix(Enum): 11 | NO_FIX = 0 12 | FIX_2D = 2 13 | FIX_3D = 3 14 | 15 | 16 | @dataclasses.dataclass(order=True) 17 | class TimestampedMeasurement: 18 | """Base class for all telemetry measurements. 19 | 20 | All telemetry measurements must have a timestamp in seconds. 21 | This is an abstract base class - do not instantiate directly. 22 | Instead use the concrete subclasses: AccelerationData, GyroscopeData, etc. 23 | """ 24 | 25 | time: float 26 | 27 | 28 | @dataclasses.dataclass 29 | class GPSPoint(TimestampedMeasurement, Point): 30 | epoch_time: float | None 31 | fix: GPSFix | None 32 | precision: float | None 33 | ground_speed: float | None 34 | 35 | 36 | @dataclasses.dataclass 37 | class CAMMGPSPoint(TimestampedMeasurement, Point): 38 | time_gps_epoch: float 39 | gps_fix_type: int 40 | horizontal_accuracy: float 41 | vertical_accuracy: float 42 | velocity_east: float 43 | velocity_north: float 44 | velocity_up: float 45 | speed_accuracy: float 46 | 47 | 48 | @dataclasses.dataclass(order=True) 49 | class GyroscopeData(TimestampedMeasurement): 50 | """Gyroscope signal in radians/seconds around XYZ axes of the camera.""" 51 | 52 | x: float 53 | y: float 54 | z: float 55 | 56 | 57 | @dataclasses.dataclass(order=True) 58 | class AccelerationData(TimestampedMeasurement): 59 | """Accelerometer reading in meters/second^2 along XYZ axes of the camera.""" 60 | 61 | x: float 62 | y: float 63 | z: float 64 | 65 | 66 | @dataclasses.dataclass(order=True) 67 | class MagnetometerData(TimestampedMeasurement): 68 | """Ambient magnetic field.""" 69 | 70 | x: float 71 | y: float 72 | z: float 73 | -------------------------------------------------------------------------------- /mapillary_tools_folder.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | block_cipher = None 5 | 6 | 7 | a = Analysis(["./pyinstaller/main.py"], 8 | pathex=[SPECPATH], 9 | binaries=[], 10 | datas=[], 11 | hiddenimports=[], 12 | hookspath=[], 13 | runtime_hooks=[], 14 | excludes=[], 15 | win_no_prefer_redirects=False, 16 | win_private_assemblies=False, 17 | cipher=block_cipher, 18 | noarchive=False) 19 | pyz = PYZ(a.pure, a.zipped_data, 20 | cipher=block_cipher) 21 | exe = EXE(pyz, 22 | a.scripts, 23 | [], 24 | exclude_binaries=True, 25 | name='mapillary_tools', 26 | debug=False, 27 | bootloader_ignore_signals=False, 28 | strip=False, 29 | upx=True, 30 | console=True ) 31 | coll = COLLECT(exe, 32 | a.binaries, 33 | a.zipfiles, 34 | a.datas, 35 | strip=False, 36 | upx=True, 37 | upx_exclude=[], 38 | name='mapillary_tools_folder') 39 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | [mypy-tqdm.*] 4 | ignore_missing_imports = True 5 | 6 | [mypy-piexif.*] 7 | ignore_missing_imports = True 8 | 9 | [mypy-pynmea2.*] 10 | ignore_missing_imports = True 11 | 12 | [mypy-gpxpy.*] 13 | ignore_missing_imports = True 14 | 15 | [mypy-exifread.*] 16 | ignore_missing_imports = True 17 | 18 | [mypy-construct.*] 19 | ignore_missing_imports = True 20 | 21 | [mypy-jsonschema.*] 22 | ignore_missing_imports = True 23 | 24 | [mypy-PIL.*] 25 | ignore_missing_imports = True 26 | 27 | [mypy-py.*] 28 | ignore_missing_imports = True 29 | -------------------------------------------------------------------------------- /pyinstaller/main.py: -------------------------------------------------------------------------------- 1 | from multiprocessing import freeze_support 2 | 3 | from mapillary_tools.commands.__main__ import main 4 | 5 | if __name__ == "__main__": 6 | # fix multiprocessing spawn: https://github.com/pyinstaller/pyinstaller/issues/4865 7 | freeze_support() 8 | main() 9 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | mypy 3 | ruff 4 | pyinstaller 5 | types-requests 6 | types-appdirs 7 | usort 8 | pyre-check 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs>=1.4.4,<2.0.0 2 | construct>=2.10.0,<3.0.0 3 | exifread==2.3.2 4 | piexif==1.1.3 5 | gpxpy>=1.5.0,<1.6.0 6 | pynmea2>=1.12.0,<2.0.0 7 | requests[socks]>=2.20.0,<3.0.0 8 | tqdm>=4.0,<5.0 9 | typing_extensions 10 | jsonschema~=4.17.3 11 | -------------------------------------------------------------------------------- /script/build_bootloader.ps1: -------------------------------------------------------------------------------- 1 | # build bootloaders 2 | # to fix the virus false detection e.g. https://www.virustotal.com/gui/file/a2e8d8287f53e1691e44352b7fbc93038b36ad677d1faacfc1aa875de92af5a6 3 | python3 -m pip uninstall -y pyinstaller 4 | git clone --depth=1 --branch v5.12.0 https://github.com/pyinstaller/pyinstaller.git pyinstaller_git 5 | cd pyinstaller_git/bootloader # pwd: ./pyinstaller_git/bootloader 6 | python3 ./waf all 7 | git diff 8 | cd .. # pwd: ./pyinstaller_git 9 | # Error: Building wheels requires the 'wheel' package. Please `pip install wheel` then try again. 10 | python3 -m pip install . 11 | cd .. # pwd: ./ 12 | -------------------------------------------------------------------------------- /script/build_linux: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | OS=linux 5 | 6 | # build 7 | mkdir -p dist 8 | rm -rf dist/${OS} 9 | pyinstaller --version 10 | pyinstaller --noconfirm --distpath dist/${OS} mapillary_tools.spec 11 | pyinstaller --noconfirm --distpath dist/${OS} mapillary_tools_folder.spec 12 | 13 | # check 14 | SOURCE=dist/${OS}/mapillary_tools 15 | $SOURCE --version 16 | VERSION=$($SOURCE --version | awk '{print $3}') 17 | ARCH=$(uname -m) 18 | TARGET=dist/releases/mapillary_tools-${VERSION}-${OS}-${ARCH} 19 | pyi-archive_viewer --list "$SOURCE" 20 | 21 | # package 22 | mkdir -p dist/releases 23 | cp "$SOURCE" "$TARGET" 24 | 25 | # sha256 26 | TARGET_BASENAME=$(basename "$TARGET") 27 | # to produce "HASH mapillary_toos" instead of "HASH dist/releases/mapillary_tools" 28 | cd dist/releases 29 | shasum -a256 "$TARGET_BASENAME" | tee "${TARGET_BASENAME}.sha256.txt" 30 | cd ../../ 31 | 32 | # check 33 | FOLDER=dist/${OS}/mapillary_tools_folder 34 | SOURCE=dist/${OS}/mapillary_tools_folder/mapillary_tools 35 | $SOURCE --version 36 | VERSION=$($SOURCE --version | awk '{print $3}') 37 | ARCH=$(uname -m) 38 | TARGET=dist/releases/mapillary_tools-folder-${VERSION}-${OS}-${ARCH} 39 | 40 | # package 41 | mkdir -p dist/releases 42 | cd dist/${OS}/ 43 | zip -r ../../"$TARGET" mapillary_tools_folder 44 | cd ../../ 45 | 46 | # sha256 47 | TARGET_BASENAME=$(basename "$TARGET") 48 | # to produce "HASH mapillary_tools" instead of "HASH dist/releases/mapillary_tools" 49 | cd dist/releases 50 | shasum -a256 "$TARGET_BASENAME" | tee "${TARGET_BASENAME}.sha256.txt" 51 | cd ../../ 52 | 53 | # summary 54 | ls -l dist/releases 55 | -------------------------------------------------------------------------------- /script/build_osx: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | OS=osx 5 | 6 | # build 7 | mkdir -p dist 8 | rm -rf dist/${OS} 9 | pyinstaller --version 10 | pyinstaller --noconfirm --distpath dist/${OS} mapillary_tools.spec 11 | pyinstaller --noconfirm --distpath dist/${OS} mapillary_tools_folder.spec 12 | 13 | # check 14 | SOURCE=dist/${OS}/mapillary_tools.app/Contents/MacOS/mapillary_tools 15 | $SOURCE --version 16 | VERSION=$($SOURCE --version | awk '{print $3}') 17 | ARCH=$(uname -m) 18 | TARGET=dist/releases/mapillary_tools-${VERSION}-${OS}-${ARCH}.zip 19 | pyi-archive_viewer --list "$SOURCE" 20 | 21 | # package 22 | mkdir -p dist/releases 23 | zip -j "$TARGET" "$SOURCE" README_osx_package.txt 24 | 25 | # sha256 26 | TARGET_BASENAME=$(basename "$TARGET") 27 | # to produce "HASH mapillary_toos" instead of "HASH dist/releases/mapillary_tools" 28 | cd dist/releases 29 | shasum -a256 "$TARGET_BASENAME" | tee "${TARGET_BASENAME}.sha256.txt" 30 | cd ../../ 31 | 32 | # check 33 | FOLDER=dist/${OS}/mapillary_tools_folder 34 | SOURCE=dist/${OS}/mapillary_tools_folder/mapillary_tools 35 | $SOURCE --version 36 | VERSION=$($SOURCE --version | awk '{print $3}') 37 | ARCH=$(uname -m) 38 | TARGET=dist/releases/mapillary_tools-folder-${VERSION}-${OS}-${ARCH}.zip 39 | 40 | # package 41 | mkdir -p dist/releases 42 | cd dist/${OS}/ 43 | zip -y -r ../../"$TARGET" mapillary_tools_folder 44 | cd ../../ 45 | 46 | # sha256 47 | TARGET_BASENAME=$(basename "$TARGET") 48 | # to produce "HASH mapillary_tools" instead of "HASH dist/releases/mapillary_tools" 49 | cd dist/releases 50 | shasum -a256 "$TARGET_BASENAME" | tee "${TARGET_BASENAME}.sha256.txt" 51 | cd ../../ 52 | 53 | # summary 54 | ls -l dist/releases 55 | -------------------------------------------------------------------------------- /script/build_win.ps1: -------------------------------------------------------------------------------- 1 | $OS="win" 2 | # this is OS arch 3 | # $ARCH=(wmic OS get OSArchitecture)[2] 4 | $MAXSIZE32=python3 -c "import sys; print(sys.maxsize <= 2**32)" 5 | # -ceq case-sensitive equality https://docs.microsoft.com/en-us/powershell/scripting/learn/deep-dives/everything-about-if 6 | if ($MAXSIZE32 -ceq "True") { 7 | $ARCH="32bit" 8 | } else { 9 | $ARCH="64bit" 10 | } 11 | 12 | # build 13 | mkdir -Force dist 14 | pyinstaller --version 15 | pyinstaller --noconfirm --distpath dist\win mapillary_tools.spec 16 | pyinstaller --noconfirm --distpath dist\win mapillary_tools_folder.spec 17 | 18 | # check 19 | $SOURCE="dist\win\mapillary_tools.exe" 20 | dist\win\mapillary_tools.exe --version 21 | $VERSION_OUTPUT=dist\win\mapillary_tools.exe --version 22 | $VERSION=$VERSION_OUTPUT.split(' ')[2] 23 | $TARGET="dist\releases\mapillary_tools-$VERSION-$OS-$ARCH.exe" 24 | pyi-archive_viewer --list "$SOURCE" 25 | 26 | # package 27 | mkdir -Force dist\releases 28 | Copy-Item "$SOURCE" "$TARGET" 29 | 30 | # sha256 31 | Get-FileHash $TARGET -Algorithm SHA256 | Select-Object Hash > "$TARGET.sha256.txt" 32 | 33 | # check 34 | $FOLDER="dist\win\mapillary_tools_folder" 35 | $SOURCE="dist\win\mapillary_tools_folder\mapillary_tools.exe" 36 | dist\win\mapillary_tools_folder\mapillary_tools.exe --version 37 | $VERSION_OUTPUT=dist\win\mapillary_tools_folder\mapillary_tools.exe --version 38 | $VERSION=$VERSION_OUTPUT.split(' ')[2] 39 | $TARGET="dist\releases\mapillary_tools-folder-$VERSION-$OS-$ARCH.zip" 40 | 41 | # package 42 | mkdir -Force dist\releases 43 | cd dist\win 44 | Compress-Archive -Path mapillary_tools_folder -DestinationPath ..\..\"$TARGET" 45 | cd ..\..\ 46 | 47 | # sha256 48 | Get-FileHash $TARGET -Algorithm SHA256 | Select-Object Hash > "$TARGET.sha256.txt" 49 | 50 | # summary 51 | Get-ChildItem dist\releases 52 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = mapillary_tools 3 | addopts = --doctest-modules -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | from setuptools import setup 5 | 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | 9 | def read_requirements(): 10 | import ssl 11 | 12 | requires = [] 13 | 14 | # Workaround for python3.9 on macOS which is compiled with LibreSSL 15 | # See https://github.com/urllib3/urllib3/issues/3020 16 | if not ssl.OPENSSL_VERSION.startswith("OpenSSL "): 17 | requires.append("urllib3<2.0.0") 18 | 19 | with open("requirements.txt") as fp: 20 | requires.extend([row.strip() for row in fp if row.strip()]) 21 | 22 | return requires 23 | 24 | 25 | about = {} 26 | with open(os.path.join(here, "mapillary_tools", "__init__.py"), "r") as f: 27 | exec(f.read(), about) 28 | 29 | 30 | def readme(): 31 | with open("README.md") as f: 32 | return f.read() 33 | 34 | 35 | setup( 36 | name="mapillary_tools", 37 | version=about["VERSION"], 38 | description="Mapillary Image/Video Import Pipeline", 39 | long_description=readme(), 40 | long_description_content_type="text/markdown", 41 | url="https://github.com/mapillary/mapillary_tools", 42 | author="Mapillary", 43 | license="BSD", 44 | python_requires=">=3.8", 45 | packages=[ 46 | "mapillary_tools", 47 | "mapillary_tools.camm", 48 | "mapillary_tools.commands", 49 | "mapillary_tools.geotag", 50 | "mapillary_tools.geotag.image_extractors", 51 | "mapillary_tools.geotag.video_extractors", 52 | "mapillary_tools.gpmf", 53 | "mapillary_tools.mp4", 54 | ], 55 | entry_points=""" 56 | [console_scripts] 57 | mapillary_tools=mapillary_tools.commands.__main__:main 58 | """, 59 | install_requires=read_requirements(), 60 | ) 61 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/__init__.py -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/cli/__init__.py -------------------------------------------------------------------------------- /tests/cli/blackvue_parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import datetime 5 | import pathlib 6 | import typing as T 7 | 8 | import gpxpy 9 | import gpxpy.gpx 10 | 11 | from mapillary_tools import blackvue_parser, geo, utils 12 | 13 | 14 | def _convert_points_to_gpx_segment( 15 | points: T.Sequence[geo.Point], 16 | ) -> gpxpy.gpx.GPXTrackSegment: 17 | gpx_segment = gpxpy.gpx.GPXTrackSegment() 18 | for point in points: 19 | gpx_segment.points.append( 20 | gpxpy.gpx.GPXTrackPoint( 21 | point.lat, 22 | point.lon, 23 | elevation=point.alt, 24 | time=datetime.datetime.fromtimestamp(point.time, datetime.timezone.utc), 25 | ) 26 | ) 27 | return gpx_segment 28 | 29 | 30 | def _convert_to_track(path: pathlib.Path): 31 | track = gpxpy.gpx.GPXTrack() 32 | track.name = str(path) 33 | 34 | with path.open("rb") as fp: 35 | blackvue_info = blackvue_parser.extract_blackvue_info(fp) 36 | 37 | if blackvue_info is None: 38 | track.description = "Invalid BlackVue video" 39 | return track 40 | 41 | segment = _convert_points_to_gpx_segment(blackvue_info.gps or []) 42 | track.segments.append(segment) 43 | with path.open("rb") as fp: 44 | model = blackvue_parser.extract_camera_model(fp) 45 | track.description = f"Extracted from {model}" 46 | 47 | return track 48 | 49 | 50 | def main(): 51 | parser = argparse.ArgumentParser() 52 | parser.add_argument("blackvue_video_path", nargs="+") 53 | parsed = parser.parse_args() 54 | 55 | gpx = gpxpy.gpx.GPX() 56 | for p in utils.find_videos([pathlib.Path(p) for p in parsed.blackvue_video_path]): 57 | gpx.tracks.append(_convert_to_track(p)) 58 | print(gpx.to_xml()) 59 | 60 | 61 | if __name__ == "__main__": 62 | main() 63 | -------------------------------------------------------------------------------- /tests/cli/camm_parser.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import dataclasses 3 | import datetime 4 | import json 5 | import pathlib 6 | import typing as T 7 | 8 | import gpxpy 9 | import gpxpy.gpx 10 | 11 | from mapillary_tools import geo, utils 12 | from mapillary_tools.camm import camm_parser 13 | 14 | 15 | def _convert_points_to_gpx_segment( 16 | points: T.Sequence[geo.Point], 17 | ) -> gpxpy.gpx.GPXTrackSegment: 18 | gpx_segment = gpxpy.gpx.GPXTrackSegment() 19 | for point in points: 20 | gpx_segment.points.append( 21 | gpxpy.gpx.GPXTrackPoint( 22 | point.lat, 23 | point.lon, 24 | elevation=point.alt, 25 | time=datetime.datetime.fromtimestamp(point.time, datetime.timezone.utc), 26 | ) 27 | ) 28 | return gpx_segment 29 | 30 | 31 | def _convert(path: pathlib.Path): 32 | track = gpxpy.gpx.GPXTrack() 33 | 34 | track.name = str(path) 35 | 36 | with path.open("rb") as fp: 37 | camm_info = camm_parser.extract_camm_info(fp) 38 | 39 | if camm_info is None: 40 | track.description = "Invalid CAMM video" 41 | return track 42 | 43 | points = T.cast(T.List[geo.Point], camm_info.gps or camm_info.mini_gps) 44 | track.segments.append(_convert_points_to_gpx_segment(points)) 45 | 46 | make_model = json.dumps({"make": camm_info.make, "model": camm_info.model}) 47 | track.description = f"Extracted from {make_model}" 48 | 49 | return track 50 | 51 | 52 | def _parse_args(): 53 | parser = argparse.ArgumentParser() 54 | parser.add_argument("--imu", help="Print IMU in JSON") 55 | parser.add_argument("camm_video_path", nargs="+") 56 | return parser.parse_args() 57 | 58 | 59 | def main(): 60 | parsed_args = _parse_args() 61 | 62 | video_paths = utils.find_videos( 63 | [pathlib.Path(p) for p in parsed_args.camm_video_path] 64 | ) 65 | 66 | if parsed_args.imu: 67 | imu_option = parsed_args.imu.split(",") 68 | 69 | for path in video_paths: 70 | with path.open("rb") as fp: 71 | camm_info = camm_parser.extract_camm_info(fp, telemetry_only=True) 72 | 73 | if camm_info: 74 | if "accl" in imu_option: 75 | print( 76 | json.dumps( 77 | [dataclasses.asdict(accl) for accl in camm_info.accl or []] 78 | ) 79 | ) 80 | if "gyro" in imu_option: 81 | print( 82 | json.dumps( 83 | [dataclasses.asdict(gyro) for gyro in camm_info.gyro or []] 84 | ) 85 | ) 86 | if "magn" in imu_option: 87 | print( 88 | json.dumps( 89 | [dataclasses.asdict(magn) for magn in camm_info.magn or []] 90 | ) 91 | ) 92 | else: 93 | gpx = gpxpy.gpx.GPX() 94 | for path in video_paths: 95 | gpx.tracks.append(_convert(path)) 96 | print(gpx.to_xml()) 97 | 98 | 99 | if __name__ == "__main__": 100 | main() 101 | -------------------------------------------------------------------------------- /tests/cli/exif_read.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import pprint 3 | import sys 4 | import xml.etree.ElementTree as et 5 | from pathlib import Path 6 | 7 | from mapillary_tools import utils 8 | from mapillary_tools.exif_read import ExifRead, ExifReadABC 9 | from mapillary_tools.exiftool_read import EXIFTOOL_NAMESPACES, ExifToolRead 10 | 11 | 12 | def extract_and_show_exif(image_path): 13 | print(f"======== ExifRead Output {image_path} ========") 14 | try: 15 | exif = ExifRead(image_path) 16 | except Exception as ex: 17 | print(f"Error: {ex}") 18 | return 19 | not_interested = ["JPEGThumbnail", "Image ImageDescription"] 20 | for tag in not_interested: 21 | if tag in exif.tags: 22 | del exif.tags[tag] 23 | pprint.pprint(exif.tags) 24 | pprint.pprint(as_dict(exif)) 25 | 26 | 27 | def as_dict(exif: ExifReadABC): 28 | if isinstance(exif, (ExifToolRead, ExifRead)): 29 | gps_datetime = exif.extract_gps_datetime() 30 | exif_time = exif.extract_exif_datetime() 31 | else: 32 | gps_datetime = None 33 | exif_time = None 34 | 35 | return { 36 | "altitude": exif.extract_altitude(), 37 | "capture_time": exif.extract_capture_time(), 38 | "direction": exif.extract_direction(), 39 | "exif_time": exif_time, 40 | "gps_time": gps_datetime, 41 | "lon_lat": exif.extract_lon_lat(), 42 | "make": exif.extract_make(), 43 | "model": exif.extract_model(), 44 | "width": exif.extract_width(), 45 | "height": exif.extract_height(), 46 | "orientation": exif.extract_orientation(), 47 | } 48 | 49 | 50 | def _approximate(left, right): 51 | if isinstance(left, float) and isinstance(right, float): 52 | return abs(left - right) < 0.000001 53 | if isinstance(left, tuple) and isinstance(right, tuple): 54 | return all(abs(l - r) < 0.000001 for l, r in zip(left, right)) 55 | return left == right 56 | 57 | 58 | def compare_exif(left: dict, right: dict) -> str: 59 | RED_COLOR = "\x1b[31;20m" 60 | RESET_COLOR = "\x1b[0m" 61 | diff = [] 62 | for key in left: 63 | if key in ["width", "height"]: 64 | continue 65 | if key in ["lon_lat", "altitude", "direction"]: 66 | same = _approximate(left[key], right[key]) 67 | else: 68 | same = left[key] == right[key] 69 | if not same: 70 | diff.append(f"{RED_COLOR}{key}: {left[key]} != {right[key]}{RESET_COLOR}") 71 | return "\n".join(diff) 72 | 73 | 74 | def extract_and_show_from_exiftool(fp, compare: bool = False): 75 | etree = et.parse(fp) 76 | descriptions = etree.findall(".//rdf:Description", namespaces=EXIFTOOL_NAMESPACES) 77 | for description in descriptions: 78 | exif = ExifToolRead(et.ElementTree(description)) 79 | dir = description.findtext("./System:Directory", namespaces=EXIFTOOL_NAMESPACES) 80 | filename = description.findtext( 81 | "./System:FileName", namespaces=EXIFTOOL_NAMESPACES 82 | ) 83 | image_path = Path(dir or "", filename or "") 84 | if compare: 85 | native_exif = ExifRead(image_path) 86 | diff = compare_exif(as_dict(exif), as_dict(native_exif)) 87 | if diff: 88 | print(f"======== {image_path} ========") 89 | 90 | print("ExifTool Outuput:") 91 | pprint.pprint(as_dict(exif)) 92 | print() 93 | 94 | print("ExifRead Output:") 95 | pprint.pprint(as_dict(native_exif)) 96 | print() 97 | 98 | print("DIFF:") 99 | print(diff) 100 | print() 101 | else: 102 | print(f"======== ExifTool Outuput {image_path} ========") 103 | pprint.pprint(as_dict(exif)) 104 | 105 | 106 | def namespace(tag): 107 | for ns, val in EXIFTOOL_NAMESPACES.items(): 108 | if tag.startswith("{" + val + "}"): 109 | return ns + ":" + tag[len(val) + 2 :] 110 | return tag 111 | 112 | 113 | def list_tags(fp): 114 | etree = et.parse(fp) 115 | descriptions = etree.findall(".//rdf:Description", namespaces=EXIFTOOL_NAMESPACES) 116 | tags = set() 117 | for description in descriptions: 118 | for child in description.iter(): 119 | tags.add(child.tag) 120 | for tag in sorted(tags): 121 | print(namespace(tag)) 122 | 123 | 124 | def main(): 125 | parser = argparse.ArgumentParser() 126 | parser.add_argument("path", nargs="*") 127 | parser.add_argument("--compare", action="store_true") 128 | parser.add_argument("--list_keys", action="store_true") 129 | parsed_args = parser.parse_args() 130 | if not parsed_args.path: 131 | if parsed_args.list_keys: 132 | list_tags(sys.stdin) 133 | else: 134 | extract_and_show_from_exiftool(sys.stdin, parsed_args.compare) 135 | else: 136 | for image_path in utils.find_images([Path(p) for p in parsed_args.path]): 137 | extract_and_show_exif(image_path) 138 | 139 | 140 | if __name__ == "__main__": 141 | main() 142 | -------------------------------------------------------------------------------- /tests/cli/exif_write.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from pathlib import Path 4 | 5 | from mapillary_tools.exif_write import ExifEdit 6 | 7 | LOG = logging.getLogger(__name__) 8 | 9 | 10 | if __name__ == "__main__": 11 | LOG.setLevel(logging.DEBUG) 12 | handler = logging.StreamHandler(sys.stderr) 13 | handler.setLevel(logging.DEBUG) 14 | LOG.addHandler(handler) 15 | for image in sys.argv[1:]: 16 | edit = ExifEdit(Path(image)) 17 | edit.dump_image_bytes() 18 | -------------------------------------------------------------------------------- /tests/cli/exiftool_runner.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import logging 5 | import sys 6 | from pathlib import Path 7 | 8 | from mapillary_tools import exiftool_runner, utils 9 | 10 | 11 | LOG = logging.getLogger("mapillary_tools") 12 | 13 | 14 | def configure_logger(logger: logging.Logger, stream=None) -> None: 15 | formatter = logging.Formatter("%(asctime)s - %(levelname)-7s - %(message)s") 16 | handler = logging.StreamHandler(stream) 17 | handler.setFormatter(formatter) 18 | logger.addHandler(handler) 19 | 20 | 21 | def parse_args(): 22 | parser = argparse.ArgumentParser() 23 | parser.add_argument("path", nargs="+", help="Paths to files or directories") 24 | return parser.parse_args() 25 | 26 | 27 | def main(): 28 | configure_logger(LOG, sys.stdout) 29 | LOG.setLevel(logging.INFO) 30 | 31 | parsed = parse_args() 32 | 33 | video_paths = utils.find_videos([Path(p) for p in parsed.path]) 34 | image_paths = utils.find_images([Path(p) for p in parsed.path]) 35 | 36 | LOG.info( 37 | "Found %d video files and %d image files", len(video_paths), len(image_paths) 38 | ) 39 | 40 | runner = exiftool_runner.ExiftoolRunner("exiftool") 41 | xml = runner.extract_xml(image_paths + video_paths) 42 | 43 | print(xml) 44 | 45 | 46 | if __name__ == "__main__": 47 | main() 48 | -------------------------------------------------------------------------------- /tests/cli/gps_filter.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import datetime 3 | import json 4 | import sys 5 | import typing as T 6 | 7 | import gpxpy 8 | 9 | from mapillary_tools import constants, geo, telemetry 10 | from mapillary_tools.gpmf import gps_filter 11 | 12 | from .gpmf_parser import _convert_points_to_gpx_track_segment 13 | 14 | 15 | def _parse_args(): 16 | parser = argparse.ArgumentParser() 17 | parser.add_argument( 18 | "--max_dop", 19 | type=float, 20 | help='Filter points by its position dilution, see https://en.wikipedia.org/wiki/Dilution_of_precision_(navigation). Set it "inf" to disable it. [default: %(default)s]', 21 | default=constants.GOPRO_MAX_DOP100, 22 | ) 23 | parser.add_argument( 24 | "--gps_fix", 25 | help="Filter points by GPS fix types (0=none, 2=2D, 3=3D). Multiple values are separate by commas, e.g. 2,3. Set 0,2,3 to disable it. [default: %(default)s]", 26 | default=",".join(map(str, constants.GOPRO_GPS_FIXES)), 27 | ) 28 | parser.add_argument( 29 | "--gps_precision", 30 | type=float, 31 | help="Filter outlier points by GPS precision. Set 0 to disable it. [default: %(default)s]", 32 | default=constants.GOPRO_GPS_PRECISION, 33 | ) 34 | return parser.parse_args() 35 | 36 | 37 | def _gpx_track_segment_to_points( 38 | segment: gpxpy.gpx.GPXTrackSegment, 39 | ) -> T.List[telemetry.GPSPoint]: 40 | gps_fix_map = { 41 | "none": telemetry.GPSFix.NO_FIX, 42 | "2d": telemetry.GPSFix.FIX_2D, 43 | "3d": telemetry.GPSFix.FIX_3D, 44 | } 45 | points = [] 46 | for p in segment.points: 47 | if p.comment: 48 | try: 49 | comment_json = json.loads(p.comment) 50 | except json.JSONDecodeError: 51 | comment_json = None 52 | else: 53 | comment_json = None 54 | 55 | if comment_json is not None: 56 | ground_speed = comment_json.get("ground_speed") 57 | else: 58 | ground_speed = None 59 | 60 | point = telemetry.GPSPoint( 61 | time=geo.as_unix_time(T.cast(datetime.datetime, p.time)), 62 | lat=p.latitude, 63 | lon=p.longitude, 64 | alt=p.elevation, 65 | angle=None, 66 | epoch_time=None, 67 | fix=( 68 | gps_fix_map[p.type_of_gpx_fix] 69 | if p.type_of_gpx_fix is not None 70 | else None 71 | ), 72 | precision=p.position_dilution, 73 | ground_speed=ground_speed, 74 | ) 75 | points.append(point) 76 | return points 77 | 78 | 79 | def _filter_noise( 80 | points: T.Sequence[telemetry.GPSPoint], 81 | gps_fix: T.Set[int], 82 | max_dop: float, 83 | ) -> T.List[telemetry.GPSPoint]: 84 | return [ 85 | p 86 | for p in points 87 | if (p.fix is None or p.fix.value in gps_fix) 88 | and (p.precision is None or p.precision <= max_dop) 89 | ] 90 | 91 | 92 | def _filter_outliers( 93 | points: T.List[telemetry.GPSPoint], 94 | gps_precision: float, 95 | ) -> T.List[telemetry.GPSPoint]: 96 | if gps_precision == 0: 97 | return points 98 | 99 | distances = [ 100 | geo.gps_distance((left.lat, left.lon), (right.lat, right.lon)) 101 | for left, right in geo.pairwise(points) 102 | ] 103 | if len(distances) < 2: 104 | return points 105 | 106 | max_distance = gps_filter.upper_whisker(distances) 107 | max_distance = max(gps_precision + gps_precision, max_distance) 108 | 109 | subseqs = gps_filter.split_if( 110 | T.cast(T.List[geo.Point], points), 111 | gps_filter.distance_gt(max_distance), 112 | ) 113 | 114 | ground_speeds = [ 115 | point.ground_speed for point in points if point.ground_speed is not None 116 | ] 117 | if len(ground_speeds) < 2: 118 | return points 119 | 120 | max_speed = gps_filter.upper_whisker(ground_speeds) 121 | merged = gps_filter.dbscan(subseqs, gps_filter.speed_le(max_speed)) 122 | 123 | return T.cast( 124 | T.List[telemetry.GPSPoint], 125 | gps_filter.find_majority(merged.values()), 126 | ) 127 | 128 | 129 | def main(): 130 | parsed_args = _parse_args() 131 | gps_fix = set(int(x) for x in parsed_args.gps_fix.split(",")) 132 | 133 | gpx = gpxpy.parse(sys.stdin) 134 | for track in gpx.tracks: 135 | new_segs = [] 136 | for seg in track.segments: 137 | points = _gpx_track_segment_to_points(seg) 138 | points = _filter_noise(points, gps_fix, parsed_args.max_dop) 139 | points = _filter_outliers(points, parsed_args.gps_precision) 140 | new_seg = _convert_points_to_gpx_track_segment(points) 141 | new_segs.append(new_seg) 142 | track.segments = new_segs 143 | print(gpx.to_xml()) 144 | 145 | 146 | if __name__ == "__main__": 147 | main() 148 | -------------------------------------------------------------------------------- /tests/cli/process_sequence_properties.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | from mapillary_tools import process_sequence_properties 5 | 6 | 7 | def main(): 8 | descs = json.load(sys.stdin) 9 | processed_descs = process_sequence_properties.process_sequence_properties(descs) 10 | print(json.dumps(processed_descs)) 11 | 12 | 13 | if __name__ == "__main__": 14 | main() 15 | -------------------------------------------------------------------------------- /tests/cli/simple_mp4_builder.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | 4 | from mapillary_tools.camm import camm_builder 5 | 6 | from mapillary_tools.geotag import geotag_videos_from_video 7 | from mapillary_tools.mp4 import simple_mp4_builder as builder 8 | 9 | 10 | def _parse_args(): 11 | parser = argparse.ArgumentParser() 12 | parser.add_argument("source_mp4_path", help="where to read the MP4") 13 | parser.add_argument("target_mp4_path", help="where to write the transformed MP4") 14 | return parser.parse_args() 15 | 16 | 17 | def main(): 18 | parsed_args = _parse_args() 19 | video_metadatas = geotag_videos_from_video.GeotagVideosFromVideo( 20 | [Path(parsed_args.source_mp4_path)] 21 | ).to_description() 22 | generator = camm_builder.camm_sample_generator2(video_metadatas[0]) 23 | with open(parsed_args.source_mp4_path, "rb") as src_fp: 24 | with open(parsed_args.target_mp4_path, "wb") as tar_fp: 25 | reader = builder.transform_mp4( 26 | src_fp, 27 | generator, 28 | ) 29 | while True: 30 | data = reader.read(1024 * 1024 * 64) 31 | if not data: 32 | break 33 | tar_fp.write(data) 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /tests/cli/upload_api_v4.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import io 3 | import logging 4 | import sys 5 | import typing as T 6 | 7 | import requests 8 | import tqdm 9 | from mapillary_tools import api_v4, authenticate 10 | 11 | from mapillary_tools.upload_api_v4 import FakeUploadService, UploadService 12 | 13 | 14 | LOG = logging.getLogger("mapillary_tools") 15 | 16 | 17 | def configure_logger(logger: logging.Logger, stream=None) -> None: 18 | formatter = logging.Formatter("%(asctime)s - %(levelname)-7s - %(message)s") 19 | handler = logging.StreamHandler(stream) 20 | handler.setFormatter(formatter) 21 | logger.addHandler(handler) 22 | 23 | 24 | def _file_stats(fp: T.IO[bytes]) -> int: 25 | fp.seek(0, io.SEEK_END) 26 | return fp.tell() 27 | 28 | 29 | def _parse_args(): 30 | parser = argparse.ArgumentParser() 31 | parser.add_argument( 32 | "--verbose", 33 | help="show verbose", 34 | action="store_true", 35 | default=False, 36 | required=False, 37 | ) 38 | parser.add_argument("--user_name") 39 | parser.add_argument( 40 | "--chunk_size", 41 | type=float, 42 | default=2, 43 | help="chunk size in megabytes", 44 | ) 45 | parser.add_argument("--dry_run", action="store_true", default=False) 46 | parser.add_argument("filename") 47 | parser.add_argument("session_key") 48 | return parser.parse_args() 49 | 50 | 51 | def main(): 52 | parsed = _parse_args() 53 | 54 | log_level = logging.DEBUG if parsed.verbose else logging.INFO 55 | configure_logger(LOG, sys.stderr) 56 | LOG.setLevel(log_level) 57 | 58 | with open(parsed.filename, "rb") as fp: 59 | entity_size = _file_stats(fp) 60 | 61 | user_items = authenticate.fetch_user_items(parsed.user_name) 62 | 63 | session_key = parsed.session_key 64 | chunk_size = int(parsed.chunk_size * 1024 * 1024) 65 | user_access_token = user_items.get("user_upload_token", "") 66 | 67 | if parsed.dry_run: 68 | service = FakeUploadService(user_access_token, session_key) 69 | else: 70 | service = UploadService(user_access_token, session_key) 71 | 72 | try: 73 | initial_offset = service.fetch_offset() 74 | except requests.HTTPError as ex: 75 | raise RuntimeError(api_v4.readable_http_error(ex)) 76 | 77 | LOG.info("Session key: %s", session_key) 78 | LOG.info("Initial offset: %s", initial_offset) 79 | LOG.info("Entity size: %d", entity_size) 80 | LOG.info("Chunk size: %s MB", chunk_size / (1024 * 1024)) 81 | 82 | def _update_pbar(chunks, pbar): 83 | for chunk in chunks: 84 | yield chunk 85 | pbar.update(len(chunk)) 86 | 87 | with open(parsed.filename, "rb") as fp: 88 | fp.seek(initial_offset, io.SEEK_SET) 89 | 90 | shifted_chunks = service.chunkize_byte_stream(fp, chunk_size) 91 | 92 | with tqdm.tqdm( 93 | total=entity_size, 94 | initial=initial_offset, 95 | unit="B", 96 | unit_scale=True, 97 | unit_divisor=1024, 98 | disable=LOG.getEffectiveLevel() <= logging.DEBUG, 99 | ) as pbar: 100 | try: 101 | file_handle = service.upload_shifted_chunks( 102 | _update_pbar(shifted_chunks, pbar), initial_offset 103 | ) 104 | except requests.HTTPError as ex: 105 | raise RuntimeError(api_v4.readable_http_error(ex)) 106 | except KeyboardInterrupt: 107 | file_handle = None 108 | LOG.warning("Upload interrupted") 109 | 110 | try: 111 | final_offset = service.fetch_offset() 112 | except requests.HTTPError as ex: 113 | raise RuntimeError(api_v4.readable_http_error(ex)) 114 | 115 | LOG.info("Final offset: %s", final_offset) 116 | LOG.info("Entity size: %d", entity_size) 117 | LOG.info("File handle: %s", file_handle) 118 | 119 | 120 | if __name__ == "__main__": 121 | main() 122 | -------------------------------------------------------------------------------- /tests/data/adobe_coords/adobe_coords.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/data/adobe_coords/adobe_coords.jpg -------------------------------------------------------------------------------- /tests/data/gopro_data/README: -------------------------------------------------------------------------------- 1 | GoPro videos are downloaded from https://github.com/gopro/gpmf-parser with MIT license 2 | -------------------------------------------------------------------------------- /tests/data/gopro_data/hero8.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/data/gopro_data/hero8.mp4 -------------------------------------------------------------------------------- /tests/data/gopro_data/max-360mode.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/data/gopro_data/max-360mode.mp4 -------------------------------------------------------------------------------- /tests/data/images/DSC00001.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/data/images/DSC00001.JPG -------------------------------------------------------------------------------- /tests/data/images/DSC00497.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/data/images/DSC00497.JPG -------------------------------------------------------------------------------- /tests/data/images/README: -------------------------------------------------------------------------------- 1 | sample-5s.mp4 is downloaded from https://samplelib.com/sample-mp4.html with no license restrictions 2 | 3 | hero8.mp4 is downloaded from https://github.com/gopro/gpmf-parser with MIT license 4 | -------------------------------------------------------------------------------- /tests/data/images/V0370574.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/data/images/V0370574.JPG -------------------------------------------------------------------------------- /tests/data/videos/README: -------------------------------------------------------------------------------- 1 | sample-5s.mp4 is downloaded from https://samplelib.com/sample-mp4.html with no license restrictions 2 | -------------------------------------------------------------------------------- /tests/data/videos/sample-5s.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/data/videos/sample-5s.mp4 -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_gopro.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import os 3 | import subprocess 4 | import typing as T 5 | from pathlib import Path 6 | 7 | import py.path 8 | import pytest 9 | 10 | from .fixtures import ( 11 | EXECUTABLE, 12 | IS_FFMPEG_INSTALLED, 13 | run_exiftool_and_generate_geotag_args, 14 | setup_config, 15 | setup_upload, 16 | verify_descs, 17 | ) 18 | 19 | 20 | IMPORT_PATH = "tests/data/gopro_data" 21 | TEST_ENVS = { 22 | "MAPILLARY_TOOLS_GOPRO_GPS_FIXES": "0,2,3", 23 | "MAPILLARY_TOOLS_GOPRO_MAX_DOP100": "100000", 24 | "MAPILLARY_TOOLS_GOPRO_GPS_PRECISION": "10000000", 25 | "MAPILLARY_TOOLS_MAX_AVG_SPEED": "200000", # km/h 26 | } 27 | EXPECTED_DESCS: T.List[T.Any] = [ 28 | { 29 | "MAPAltitude": 9540.24, 30 | "MAPCaptureTime": "2019_11_18_15_41_12_354", 31 | "MAPCompassHeading": { 32 | "TrueHeading": 123.93587938690177, 33 | "MagneticHeading": 123.93587938690177, 34 | }, 35 | "MAPLatitude": 42.0266244, 36 | "MAPLongitude": -129.2943386, 37 | "MAPDeviceMake": "GoPro", 38 | "MAPDeviceModel": "HERO8 Black", 39 | "filename": "hero8.mp4/hero8_NA_000001.jpg", 40 | }, 41 | { 42 | "MAPAltitude": 7112.573717404068, 43 | "MAPCaptureTime": "2019_11_18_15_41_14_354", 44 | "MAPCompassHeading": { 45 | "TrueHeading": 140.8665026186285, 46 | "MagneticHeading": 140.8665026186285, 47 | }, 48 | "MAPLatitude": 35.33318621742755, 49 | "MAPLongitude": -126.85929159704702, 50 | "MAPDeviceMake": "GoPro", 51 | "MAPDeviceModel": "HERO8 Black", 52 | "filename": "hero8.mp4/hero8_NA_000002.jpg", 53 | }, 54 | { 55 | "MAPAltitude": 7463.642846094319, 56 | "MAPCaptureTime": "2019_11_18_15_41_16_354", 57 | "MAPCompassHeading": { 58 | "TrueHeading": 138.44255851085705, 59 | "MagneticHeading": 138.44255851085705, 60 | }, 61 | "MAPLatitude": 36.32681619054138, 62 | "MAPLongitude": -127.18475264566939, 63 | "MAPDeviceMake": "GoPro", 64 | "MAPDeviceModel": "HERO8 Black", 65 | "filename": "hero8.mp4/hero8_NA_000003.jpg", 66 | }, 67 | { 68 | "MAPAltitude": 6909.8168472111465, 69 | "MAPCaptureTime": "2019_11_18_15_41_18_354", 70 | "MAPCompassHeading": { 71 | "TrueHeading": 142.23462669862568, 72 | "MagneticHeading": 142.23462669862568, 73 | }, 74 | "MAPLatitude": 34.7537270390268, 75 | "MAPLongitude": -126.65905680405231, 76 | "MAPDeviceMake": "GoPro", 77 | "MAPDeviceModel": "HERO8 Black", 78 | "filename": "hero8.mp4/hero8_NA_000004.jpg", 79 | }, 80 | { 81 | "MAPAltitude": 7212.594480737465, 82 | "MAPCaptureTime": "2019_11_18_15_41_20_354", 83 | "MAPCompassHeading": { 84 | "TrueHeading": 164.70819093235514, 85 | "MagneticHeading": 164.70819093235514, 86 | }, 87 | "MAPLatitude": 35.61583820322709, 88 | "MAPLongitude": -126.93688762007304, 89 | "MAPDeviceMake": "GoPro", 90 | "MAPDeviceModel": "HERO8 Black", 91 | "filename": "hero8.mp4/hero8_NA_000005.jpg", 92 | }, 93 | { 94 | "MAPAltitude": 7274.361994963208, 95 | "MAPCaptureTime": "2019_11_18_15_41_22_354", 96 | "MAPCompassHeading": { 97 | "TrueHeading": 139.71549328876722, 98 | "MagneticHeading": 139.71549328876722, 99 | }, 100 | "MAPLatitude": 35.79255093264954, 101 | "MAPLongitude": -126.98833423074615, 102 | "MAPDeviceMake": "GoPro", 103 | "MAPDeviceModel": "HERO8 Black", 104 | "filename": "hero8.mp4/hero8_NA_000006.jpg", 105 | }, 106 | ] 107 | 108 | 109 | @pytest.fixture 110 | def setup_data(tmpdir: py.path.local): 111 | data_path = tmpdir.mkdir("data") 112 | source = py.path.local(IMPORT_PATH) 113 | source.copy(data_path) 114 | yield data_path 115 | if tmpdir.check(): 116 | tmpdir.remove(ignore_errors=True) 117 | 118 | 119 | @pytest.mark.usefixtures("setup_config") 120 | @pytest.mark.usefixtures("setup_upload") 121 | def test_process_gopro_hero8( 122 | setup_data: py.path.local, 123 | use_exiftool: bool = False, 124 | ): 125 | if not IS_FFMPEG_INSTALLED: 126 | pytest.skip("skip because ffmpeg not installed") 127 | video_path = setup_data.join("hero8.mp4") 128 | if use_exiftool: 129 | args = f"{EXECUTABLE} --verbose video_process --video_sample_interval=2 --video_sample_distance=-1 {str(video_path)}" 130 | args = run_exiftool_and_generate_geotag_args(setup_data, args) 131 | else: 132 | args = f"{EXECUTABLE} --verbose video_process --video_sample_interval=2 --video_sample_distance=-1 --geotag_source=gopro_videos {str(video_path)}" 133 | env = os.environ.copy() 134 | env.update(TEST_ENVS) 135 | x = subprocess.run(args, shell=True, env=env) 136 | assert x.returncode == 0, x.stderr 137 | sample_dir = setup_data.join("mapillary_sampled_video_frames") 138 | desc_path = sample_dir.join("mapillary_image_description.json") 139 | expected_descs = copy.deepcopy(EXPECTED_DESCS) 140 | for expected_desc in expected_descs: 141 | expected_desc["filename"] = str(sample_dir.join(expected_desc["filename"])) 142 | 143 | verify_descs(expected_descs, Path(desc_path)) 144 | 145 | 146 | @pytest.mark.usefixtures("setup_config") 147 | @pytest.mark.usefixtures("setup_upload") 148 | def test_process_gopro_hero8_with_exiftool(setup_data: py.path.local): 149 | return test_process_gopro_hero8(setup_data, use_exiftool=True) 150 | 151 | 152 | @pytest.mark.usefixtures("setup_config") 153 | @pytest.mark.usefixtures("setup_upload") 154 | def test_process_gopro_hero8_with_exiftool_multiple_videos_with_the_same_name( 155 | setup_data: py.path.local, 156 | ): 157 | video_path = setup_data.join("hero8.mp4") 158 | for idx in range(11): 159 | video_dir = setup_data.mkdir(f"video_dir_{idx}") 160 | video_path.copy(video_dir.join(video_path.basename)) 161 | return test_process_gopro_hero8(setup_data, use_exiftool=True) 162 | -------------------------------------------------------------------------------- /tests/integration/test_history.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | import py.path 4 | import pytest 5 | 6 | from .fixtures import EXECUTABLE, setup_config, setup_data, setup_upload, USERNAME 7 | 8 | UPLOAD_FLAGS = f"--dry_run --user_name={USERNAME}" 9 | 10 | 11 | @pytest.mark.usefixtures("setup_config") 12 | def test_upload_images( 13 | setup_data: py.path.local, 14 | setup_upload: py.path.local, 15 | ): 16 | assert len(setup_upload.listdir()) == 0 17 | 18 | x = subprocess.run( 19 | f"{EXECUTABLE} process_and_upload --file_types=image {UPLOAD_FLAGS} {str(setup_data)}", 20 | shell=True, 21 | ) 22 | assert x.returncode == 0, x.stderr 23 | assert 0 < len(setup_upload.listdir()), "should be uploaded for the first time" 24 | for upload in setup_upload.listdir(): 25 | upload.remove() 26 | 27 | x = subprocess.run( 28 | f"{EXECUTABLE} process_and_upload --file_types=image {UPLOAD_FLAGS} {str(setup_data)}", 29 | shell=True, 30 | ) 31 | assert x.returncode == 0, x.stderr 32 | assert len(setup_upload.listdir()) == 0, ( 33 | "should NOT upload because it is uploaded already" 34 | ) 35 | 36 | 37 | @pytest.mark.usefixtures("setup_config") 38 | def test_upload_gopro( 39 | setup_data: py.path.local, 40 | setup_upload: py.path.local, 41 | ): 42 | assert len(setup_upload.listdir()) == 0 43 | video_dir = setup_data.join("gopro_data") 44 | 45 | x = subprocess.run( 46 | f"{EXECUTABLE} process_and_upload --skip_process_errors {UPLOAD_FLAGS} {str(video_dir)}", 47 | shell=True, 48 | ) 49 | assert x.returncode == 0, x.stderr 50 | assert len(setup_upload.listdir()) == 1, ( 51 | f"should be uploaded for the first time but got {setup_upload.listdir()}" 52 | ) 53 | for upload in setup_upload.listdir(): 54 | upload.remove() 55 | assert len(setup_upload.listdir()) == 0 56 | 57 | x = subprocess.run( 58 | f"{EXECUTABLE} process_and_upload --skip_process_errors {UPLOAD_FLAGS} {str(video_dir)}", 59 | shell=True, 60 | ) 61 | assert x.returncode == 0, x.stderr 62 | assert len(setup_upload.listdir()) == 0, ( 63 | "should NOT upload because it is uploaded already" 64 | ) 65 | -------------------------------------------------------------------------------- /tests/integration/test_upload.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import os 4 | import subprocess 5 | from pathlib import Path 6 | 7 | import py.path 8 | import pytest 9 | 10 | from .fixtures import ( 11 | EXECUTABLE, 12 | setup_config, 13 | setup_data, 14 | setup_upload, 15 | USERNAME, 16 | validate_and_extract_zip, 17 | ) 18 | 19 | 20 | PROCESS_FLAGS = "" 21 | UPLOAD_FLAGS = f"--dry_run --user_name={USERNAME}" 22 | 23 | 24 | def file_md5sum(path) -> str: 25 | with open(path, "rb") as fp: 26 | md5 = hashlib.md5() 27 | while True: 28 | buf = fp.read(1024 * 1024 * 32) 29 | if not buf: 30 | break 31 | md5.update(buf) 32 | return md5.hexdigest() 33 | 34 | 35 | @pytest.mark.usefixtures("setup_config") 36 | def test_upload_image_dir( 37 | setup_data: py.path.local, 38 | setup_upload: py.path.local, 39 | ): 40 | x = subprocess.run( 41 | f"{EXECUTABLE} process --file_types=image {PROCESS_FLAGS} {setup_data}", 42 | shell=True, 43 | ) 44 | assert x.returncode == 0, x.stderr 45 | x = subprocess.run( 46 | f"{EXECUTABLE} process_and_upload {UPLOAD_FLAGS} --file_types=image {setup_data}", 47 | shell=True, 48 | ) 49 | for file in setup_upload.listdir(): 50 | validate_and_extract_zip(Path(file)) 51 | assert x.returncode == 0, x.stderr 52 | 53 | 54 | @pytest.mark.usefixtures("setup_config") 55 | def test_upload_image_dir_twice( 56 | setup_data: py.path.local, 57 | setup_upload: py.path.local, 58 | ): 59 | x = subprocess.run( 60 | f"{EXECUTABLE} process --skip_process_errors {PROCESS_FLAGS} {setup_data}", 61 | shell=True, 62 | ) 63 | assert x.returncode == 0, x.stderr 64 | desc_path = setup_data.join("mapillary_image_description.json") 65 | 66 | md5sum_map = {} 67 | 68 | # first upload 69 | x = subprocess.run( 70 | f"{EXECUTABLE} process_and_upload {UPLOAD_FLAGS} --file_types=image {setup_data}", 71 | shell=True, 72 | ) 73 | assert x.returncode == 0, x.stderr 74 | for file in setup_upload.listdir(): 75 | validate_and_extract_zip(Path(file)) 76 | md5sum_map[os.path.basename(file)] = file_md5sum(file) 77 | 78 | # expect the second upload to not produce new uploads 79 | x = subprocess.run( 80 | f"{EXECUTABLE} process_and_upload {UPLOAD_FLAGS} --desc_path={desc_path} --file_types=image {setup_data} {setup_data} {setup_data}/images/DSC00001.JPG", 81 | shell=True, 82 | ) 83 | assert x.returncode == 0, x.stderr 84 | for file in setup_upload.listdir(): 85 | validate_and_extract_zip(Path(file)) 86 | new_md5sum = file_md5sum(file) 87 | assert md5sum_map[os.path.basename(file)] == new_md5sum 88 | assert len(md5sum_map) == len(setup_upload.listdir()) 89 | 90 | 91 | @pytest.mark.usefixtures("setup_config") 92 | def test_upload_wrong_descs( 93 | setup_data: py.path.local, 94 | setup_upload: py.path.local, 95 | ): 96 | x = subprocess.run( 97 | f"{EXECUTABLE} process --skip_process_errors {PROCESS_FLAGS} {setup_data}", 98 | shell=True, 99 | ) 100 | assert x.returncode == 0, x.stderr 101 | desc_path = setup_data.join("mapillary_image_description.json") 102 | with open(desc_path, "r") as fp: 103 | descs = json.load(fp) 104 | descs.append( 105 | { 106 | "filename": str(setup_data.join("not_found")), 107 | "filetype": "image", 108 | "MAPLatitude": 1, 109 | "MAPLongitude": 1, 110 | "MAPCaptureTime": "1970_01_01_00_00_02_000", 111 | "MAPCompassHeading": {"TrueHeading": 17.0, "MagneticHeading": 17.0}, 112 | }, 113 | ) 114 | with open(desc_path, "w") as fp: 115 | fp.write(json.dumps(descs)) 116 | 117 | x = subprocess.run( 118 | f"{EXECUTABLE} upload {UPLOAD_FLAGS} --desc_path={desc_path} {setup_data} {setup_data} {setup_data}/images/DSC00001.JPG", 119 | shell=True, 120 | ) 121 | assert x.returncode == 4, x.stderr 122 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/data/corrupt_exif.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/unit/data/corrupt_exif.jpg -------------------------------------------------------------------------------- /tests/unit/data/corrupt_exif_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/unit/data/corrupt_exif_2.jpg -------------------------------------------------------------------------------- /tests/unit/data/empty_exif.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/unit/data/empty_exif.jpg -------------------------------------------------------------------------------- /tests/unit/data/fixed_exif.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/unit/data/fixed_exif.jpg -------------------------------------------------------------------------------- /tests/unit/data/fixed_exif_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/unit/data/fixed_exif_2.jpg -------------------------------------------------------------------------------- /tests/unit/data/mock_sample_video/videos/hello.mp4: -------------------------------------------------------------------------------- 1 | { 2 | "streams": [ 3 | { 4 | "index": 0, 5 | "codec_name": "hevc", 6 | "codec_long_name": "H.265 / HEVC (High Efficiency Video Coding)", 7 | "profile": "Main", 8 | "codec_type": "video", 9 | "codec_tag_string": "hvc1", 10 | "codec_tag": "0x31637668", 11 | "width": 3840, 12 | "height": 2160, 13 | "coded_width": 3840, 14 | "coded_height": 2160, 15 | "closed_captions": 0, 16 | "has_b_frames": 0, 17 | "pix_fmt": "yuvj420p", 18 | "level": 153, 19 | "color_range": "pc", 20 | "color_space": "bt709", 21 | "color_transfer": "bt709", 22 | "color_primaries": "bt709", 23 | "chroma_location": "left", 24 | "refs": 1, 25 | "r_frame_rate": "30/1", 26 | "avg_frame_rate": "183000/6101", 27 | "time_base": "1/1000", 28 | "start_pts": 0, 29 | "start_time": "0.000000", 30 | "duration_ts": 60977, 31 | "duration": "60.977000", 32 | "bit_rate": "25170626", 33 | "nb_frames": "1830", 34 | "disposition": { 35 | "default": 1, 36 | "dub": 0, 37 | "original": 0, 38 | "comment": 0, 39 | "lyrics": 0, 40 | "karaoke": 0, 41 | "forced": 0, 42 | "hearing_impaired": 0, 43 | "visual_impaired": 0, 44 | "clean_effects": 0, 45 | "attached_pic": 0, 46 | "timed_thumbnails": 0 47 | }, 48 | "tags": { 49 | "creation_time": "2021-08-10T14:38:06.000000Z", 50 | "language": "eng", 51 | "handler_name": "PittaSoft Video Media Handler", 52 | "vendor_id": "[0][0][0][0]" 53 | } 54 | }, 55 | { 56 | "index": 1, 57 | "codec_name": "aac", 58 | "codec_long_name": "AAC (Advanced Audio Coding)", 59 | "profile": "LC", 60 | "codec_type": "audio", 61 | "codec_tag_string": "mp4a", 62 | "codec_tag": "0x6134706d", 63 | "sample_fmt": "fltp", 64 | "sample_rate": "16000", 65 | "channels": 2, 66 | "channel_layout": "stereo", 67 | "bits_per_sample": 0, 68 | "r_frame_rate": "0/0", 69 | "avg_frame_rate": "0/0", 70 | "time_base": "1/1000", 71 | "start_pts": 0, 72 | "start_time": "0.000000", 73 | "duration_ts": 60992, 74 | "duration": "60.992000", 75 | "bit_rate": "47168", 76 | "nb_frames": "954", 77 | "disposition": { 78 | "default": 1, 79 | "dub": 0, 80 | "original": 0, 81 | "comment": 0, 82 | "lyrics": 0, 83 | "karaoke": 0, 84 | "forced": 0, 85 | "hearing_impaired": 0, 86 | "visual_impaired": 0, 87 | "clean_effects": 0, 88 | "attached_pic": 0, 89 | "timed_thumbnails": 0 90 | }, 91 | "tags": { 92 | "creation_time": "2021-08-10T14:38:06.000000Z", 93 | "language": "eng", 94 | "handler_name": "PittaSoft Sound Media Handler", 95 | "vendor_id": "[0][0][0][0]" 96 | } 97 | } 98 | ], 99 | "format": { 100 | "filename": "/Users/hello/world/BlackVueDashcam/20210810_103706_NF.mp4", 101 | "nb_streams": 2, 102 | "nb_programs": 0, 103 | "format_name": "mov,mp4,m4a,3gp,3g2,mj2", 104 | "format_long_name": "QuickTime / MOV", 105 | "start_time": "0.000000", 106 | "duration": "60.977000", 107 | "size": "197020628", 108 | "bit_rate": "25848517", 109 | "probe_score": 100, 110 | "tags": { 111 | "creation_time": "2021-08-10T14:38:06.000000Z", 112 | "major_brand": "mp42", 113 | "minor_version": "1", 114 | "compatible_brands": "mp42mp42" 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/unit/data/test_exif.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapillary/mapillary_tools/ebad6bf40da944bc36196d9d3a2acb13701c51de/tests/unit/data/test_exif.jpg -------------------------------------------------------------------------------- /tests/unit/generate_test_image.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import io 3 | import os 4 | 5 | import piexif 6 | 7 | 8 | def parse_args(): 9 | parser = argparse.ArgumentParser( 10 | description="Generate a test image by transplanting exif" 11 | ) 12 | parser.add_argument("input_image", help="path to imput image containing exif") 13 | parser.add_argument("output_image", help="path to generated output image") 14 | 15 | return parser.parse_args() 16 | 17 | 18 | if __name__ == "__main__": 19 | args = parse_args() 20 | 21 | with open(args.input_image, "rb") as fin: 22 | input_string = fin.read() 23 | 24 | empty_image = os.path.join( 25 | os.path.abspath(os.path.dirname(__file__)), "data/empty_exif.jpg" 26 | ) 27 | 28 | with open(empty_image, "rb") as f: 29 | image_string = f.read() 30 | 31 | output_bytes = io.BytesIO() 32 | piexif.transplant(input_string, image_string, output_bytes) 33 | 34 | with open(args.output_image, "wb") as fout: 35 | fout.write(output_bytes.read()) 36 | -------------------------------------------------------------------------------- /tests/unit/test_blackvue_parser.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | import mapillary_tools.geo as geo 4 | from mapillary_tools import blackvue_parser 5 | from mapillary_tools.mp4 import construct_mp4_parser as cparser 6 | 7 | 8 | def test_parse_points(): 9 | gps_data = b""" 10 | # GGA 11 | [1623057130221]$GPGGA,201205.00,3853.16949,N,07659.54604,W,2,10,0.82,7.7,M,-34.7,M,,0000*6F 12 | 13 | # GGA 14 | [1623057129253]$GPGGA,201204.00,3853.16945,N,07659.54371,W,2,10,0.99,10.2,M,-34.7,M,,0000*5C 15 | 16 | [1623057129253]$GPGSA,A,3,19,02,06,12,17,09,05,20,04,25,,,1.83,0.99,1.54*0C 17 | 18 | [1623057129253]$GPGSV,3,1,12,02,67,331,39,04,08,040,21,05,28,214,30,06,53,047,31*71 19 | 20 | [1623057129253]$GPGSV,3,2,12,09,23,071,28,12,48,268,41,17,17,124,26,19,38,117,35*78 21 | 22 | [1623057129253]$GPGSV,3,3,12,20,23,221,35,25,26,307,39,46,20,244,35,51,35,223,40*72 23 | 24 | [1623057129253]$GPGLL,3853.16945,N,07659.54371,W,201204.00,A,D*70 25 | 26 | [1623057129253]$GPRMC,201205.00,A,3853.16949,N,07659.54604,W,5.849,284.43,070621,,,D*76 27 | 28 | [1623057129253]$GPVTG,284.43,T,,M,5.849,N,10.833,K,D*08[1623057130221] 29 | 30 | # GGA 31 | [1623057130221]$GPGGA,201205.00,3853.16949,N,07659.54604,W,2,10,0.82,7.7,M,-34.7,M,,0000*6F 32 | 33 | # invalid line 34 | [1623057130221]$GPGGA,**&^%$%$&(&(*(&&(^^*^*^^*&^&*)))) 35 | 36 | # invalid line 37 | [1623057130221]$GPGGA,\x00\x00\x1c\xff 38 | 39 | [1623057130221]$GPGSA,A,3,19,02,06,12,17,09,05,20,04,25,,,1.65,0.82,1.43*08 40 | """ 41 | 42 | box = {"type": b"free", "data": [{"type": b"gps ", "data": gps_data}]} 43 | data = cparser.Box32ConstructBuilder({b"free": {}}).Box.build(box) 44 | info = blackvue_parser.extract_blackvue_info(io.BytesIO(data)) 45 | assert info is not None 46 | assert [ 47 | geo.Point( 48 | time=0.0, lat=38.8861575, lon=-76.99239516666667, alt=10.2, angle=None 49 | ), 50 | geo.Point( 51 | time=0.968, lat=38.88615816666667, lon=-76.992434, alt=7.7, angle=None 52 | ), 53 | geo.Point( 54 | time=0.968, lat=38.88615816666667, lon=-76.992434, alt=7.7, angle=None 55 | ), 56 | ] == list(info.gps or []) 57 | -------------------------------------------------------------------------------- /tests/unit/test_config.py: -------------------------------------------------------------------------------- 1 | import typing as T 2 | 3 | import py.path 4 | 5 | from mapillary_tools import config 6 | 7 | 8 | def test_config_list_all_users(tmpdir: py.path.local): 9 | c = tmpdir.join("empty_config.ini") 10 | x = config.list_all_users(config_path=str(c)) 11 | assert not x 12 | 13 | config.update_config( 14 | "hello", 15 | T.cast( 16 | T.Any, 17 | { 18 | "ThisIsOption": "1", 19 | }, 20 | ), 21 | config_path=str(c), 22 | ) 23 | 24 | x = config.list_all_users(config_path=str(c)) 25 | assert len(x) == 1 26 | assert x["hello"] == {"ThisIsOption": "1"} 27 | 28 | 29 | def test_update_config(tmpdir: py.path.local): 30 | c = tmpdir.join("empty_config.ini") 31 | config.update_config( 32 | "world", T.cast(T.Any, {"ThisIsOption": "hello"}), config_path=str(c) 33 | ) 34 | x = config.load_user("world", config_path=str(c)) 35 | assert x == {"ThisIsOption": "hello"} 36 | 37 | config.update_config( 38 | "world", T.cast(T.Any, {"ThisIsOption": "world2"}), config_path=str(c) 39 | ) 40 | x = config.load_user("world", config_path=str(c)) 41 | assert x == {"ThisIsOption": "world2"} 42 | 43 | 44 | def test_load_user(tmpdir: py.path.local): 45 | c = tmpdir.join("empty_config.ini") 46 | config.update_config( 47 | "world", T.cast(T.Any, {"ThisIsOption": "hello"}), config_path=str(c) 48 | ) 49 | x = config.load_user("hello", config_path=str(c)) 50 | assert x is None 51 | x = config.load_user("world", config_path=str(c)) 52 | assert x == {"ThisIsOption": "hello"} 53 | 54 | 55 | def test_remove(tmpdir: py.path.local): 56 | c = tmpdir.join("empty_config.ini") 57 | config.update_config( 58 | "world", T.cast(T.Any, {"ThisIsOption": "hello"}), config_path=str(c) 59 | ) 60 | config.remove_config("world", config_path=str(c)) 61 | u = config.load_user("world", config_path=str(c)) 62 | assert u is None 63 | x = config.list_all_users(config_path=str(c)) 64 | assert not x 65 | -------------------------------------------------------------------------------- /tests/unit/test_description.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from mapillary_tools.exceptions import MapillaryMetadataValidationError 4 | from mapillary_tools.types import ( 5 | ImageVideoDescriptionFileSchema, 6 | validate_and_fail_desc, 7 | validate_image_desc, 8 | ) 9 | 10 | 11 | def test_validate_descs_ok(): 12 | descs = [ 13 | { 14 | "MAPLatitude": 1, 15 | "MAPLongitude": 2, 16 | "MAPCaptureTime": "9020_01_02_11_12_13_1", 17 | "filename": "foo", 18 | "filetype": "image", 19 | }, 20 | { 21 | "MAPLatitude": -90, 22 | "MAPLongitude": 180, 23 | "MAPCaptureTime": "1020_01_02_11_33_13_123", 24 | "filename": "foo", 25 | "filetype": "image", 26 | }, 27 | { 28 | "MAPLatitude": 90, 29 | "MAPLongitude": -180, 30 | "MAPCaptureTime": "3020_01_02_11_12_13_000123", 31 | "filename": "foo", 32 | "filetype": "image", 33 | }, 34 | ] 35 | for desc in descs: 36 | validate_image_desc(desc) 37 | 38 | 39 | def test_validate_descs_not_ok(): 40 | descs = [ 41 | { 42 | "MAPLatitude": 1, 43 | "MAPLongitude": 2, 44 | "filename": "foo", 45 | "filetype": "image", 46 | }, 47 | { 48 | "MAPLatitude": -90.1, 49 | "MAPLongitude": -1, 50 | "MAPCaptureTime": "1020_01_02_11_33_13_123", 51 | "filename": "foo", 52 | "filetype": "image", 53 | }, 54 | { 55 | "MAPLatitude": 1, 56 | "MAPLongitude": -180.2, 57 | "MAPCaptureTime": "3020_01_02_11_12_13_000", 58 | "filename": "foo", 59 | "filetype": "image", 60 | }, 61 | { 62 | "MAPLatitude": -90, 63 | "MAPLongitude": 180, 64 | "MAPCaptureTime": "2000_12_00_10_20_10_000", 65 | "filename": "foo", 66 | "filetype": "image", 67 | }, 68 | ] 69 | errors = 0 70 | for desc in descs: 71 | try: 72 | validate_image_desc(desc) 73 | except MapillaryMetadataValidationError: 74 | errors += 1 75 | assert errors == len(descs) 76 | 77 | validated = [validate_and_fail_desc(desc) for desc in descs] 78 | actual_errors = [ 79 | desc 80 | for desc in validated 81 | if desc["error"]["type"] == "MapillaryMetadataValidationError" 82 | ] 83 | assert { 84 | "'MAPCaptureTime' is a required property", 85 | "-90.1 is less than the minimum of -90", 86 | "-180.2 is less than the minimum of -180", 87 | "time data '2000_12_00_10_20_10_000' does not match format '%Y_%m_%d_%H_%M_%S_%f'", 88 | } == set(e["error"]["message"] for e in actual_errors) 89 | 90 | 91 | def test_validate_image_description_schema(): 92 | with open("./schema/image_description_schema.json") as fp: 93 | schema = json.load(fp) 94 | assert json.dumps(schema, sort_keys=True) == json.dumps( 95 | ImageVideoDescriptionFileSchema, sort_keys=True 96 | ) 97 | -------------------------------------------------------------------------------- /tests/unit/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from mapillary_tools import exceptions, types 5 | 6 | 7 | def test_all(): 8 | all_excs = [ 9 | getattr(exceptions, ex) 10 | for ex in dir(exceptions) 11 | if ex.startswith("Mapillary") and ex.endswith("Error") 12 | ] 13 | 14 | all_desc_excs = [ 15 | exc for exc in all_excs if issubclass(exc, exceptions.MapillaryDescriptionError) 16 | ] 17 | 18 | for exc in all_desc_excs: 19 | if exc is exceptions.MapillaryOutsideGPXTrackError: 20 | e = exc("hello", "world", "hey", "aa") 21 | elif exc is exceptions.MapillaryDuplicationError: 22 | e = exc("hello", {}, 1, float("inf")) 23 | else: 24 | e = exc("hello") 25 | # should not raise 26 | json.dumps( 27 | types._describe_error_desc(e, Path("test.jpg"), types.FileType.IMAGE) 28 | ) 29 | -------------------------------------------------------------------------------- /tests/unit/test_ffmpeg.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import subprocess 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from mapillary_tools import ffmpeg 9 | 10 | 11 | def _ffmpeg_installed(): 12 | ffmpeg_path = os.getenv("MAPILLARY_TOOLS_FFMPEG_PATH", "ffmpeg") 13 | ffprobe_path = os.getenv("MAPILLARY_TOOLS_FFPROBE_PATH", "ffprobe") 14 | try: 15 | subprocess.run( 16 | [ffmpeg_path, "-version"], stderr=subprocess.PIPE, stdout=subprocess.PIPE 17 | ) 18 | # In Windows, ffmpeg is installed but ffprobe is not? 19 | subprocess.run( 20 | [ffprobe_path, "-version"], stderr=subprocess.PIPE, stdout=subprocess.PIPE 21 | ) 22 | except FileNotFoundError: 23 | return False 24 | return True 25 | 26 | 27 | IS_FFMPEG_INSTALLED = _ffmpeg_installed() 28 | 29 | 30 | def test_ffmpeg_not_exists(): 31 | if not IS_FFMPEG_INSTALLED: 32 | pytest.skip("ffmpeg not installed") 33 | 34 | ff = ffmpeg.FFMPEG() 35 | try: 36 | ff.extract_frames(Path("not_exist_a"), Path("not_exist_b"), sample_interval=2) 37 | except ffmpeg.FFmpegCalledProcessError as ex: 38 | assert "STDERR:" not in str(ex) 39 | else: 40 | assert False, "FFmpegCalledProcessError not raised" 41 | 42 | ff = ffmpeg.FFMPEG(stderr=subprocess.PIPE) 43 | try: 44 | ff.extract_frames(Path("not_exist_a"), Path("not_exist_b"), sample_interval=2) 45 | except ffmpeg.FFmpegCalledProcessError as ex: 46 | assert "STDERR:" in str(ex) 47 | else: 48 | assert False, "FFmpegCalledProcessError not raised" 49 | 50 | 51 | def test_ffprobe_not_exists(): 52 | if not IS_FFMPEG_INSTALLED: 53 | pytest.skip("ffmpeg not installed") 54 | 55 | ff = ffmpeg.FFMPEG() 56 | try: 57 | x = ff.probe_format_and_streams(Path("not_exist_a")) 58 | except ffmpeg.FFmpegCalledProcessError as ex: 59 | # exc from linux 60 | assert "STDERR:" not in str(ex) 61 | except RuntimeError as ex: 62 | # exc from macos 63 | assert "Empty JSON ffprobe output with STDERR: None" == str(ex) 64 | else: 65 | assert False, "RuntimeError not raised" 66 | 67 | ff = ffmpeg.FFMPEG(stderr=subprocess.PIPE) 68 | try: 69 | x = ff.probe_format_and_streams(Path("not_exist_a")) 70 | except ffmpeg.FFmpegCalledProcessError as ex: 71 | # exc from linux 72 | assert "STDERR:" in str(ex) 73 | except RuntimeError as ex: 74 | # exc from macos 75 | assert ( 76 | "Empty JSON ffprobe output with STDERR: b'not_exist_a: No such file or directory" 77 | in str(ex) 78 | ) 79 | else: 80 | assert False, "RuntimeError not raised" 81 | 82 | 83 | def test_probe(): 84 | def test_creation_time(expected, probe_creation_time, probe_duration): 85 | probe = ffmpeg.Probe( 86 | { 87 | "streams": [ 88 | { 89 | "index": 0, 90 | "codec_type": "video", 91 | "codec_tag_string": "avc1", 92 | "width": 2880, 93 | "height": 1620, 94 | "coded_width": 2880, 95 | "coded_height": 1620, 96 | "duration": probe_duration, 97 | "tags": { 98 | "creation_time": probe_creation_time, 99 | "language": "und", 100 | "handler_name": "Core Media Video", 101 | "vendor_id": "[0][0][0][0]", 102 | "encoder": "H.264", 103 | }, 104 | } 105 | ] 106 | } 107 | ) 108 | creation_time = probe.probe_video_start_time() 109 | assert expected == creation_time 110 | 111 | test_creation_time( 112 | datetime.datetime(2023, 3, 7, 1, 35, 29, 190123, tzinfo=datetime.timezone.utc), 113 | "2023-03-07T01:35:34.123456Z", 114 | "4.933333", 115 | ) 116 | test_creation_time( 117 | datetime.datetime(2023, 3, 7, 1, 35, 29, 66667, tzinfo=datetime.timezone.utc), 118 | "2023-03-07T01:35:34.000000Z", 119 | "4.933333", 120 | ) 121 | test_creation_time( 122 | datetime.datetime(2023, 3, 7, 1, 35, 29, 66667), 123 | "2023-03-07 01:35:34", 124 | "4.933333", 125 | ) 126 | -------------------------------------------------------------------------------- /tests/unit/test_gpmf_parser.py: -------------------------------------------------------------------------------- 1 | from mapillary_tools.gpmf import gpmf_parser 2 | 3 | 4 | def test_simple(): 5 | x = gpmf_parser.KLV.parse(b"DEMO\x02\x01\x00\x01\xff\x00\x00\x00") 6 | x = gpmf_parser.GPMFSampleData.parse( 7 | b"DEM1\x01\x01\x00\x01\xff\x00\x00\x00DEM2\x03\x00\x00\x01" 8 | ) 9 | -------------------------------------------------------------------------------- /tests/unit/test_gps_filter.py: -------------------------------------------------------------------------------- 1 | import mapillary_tools.geo as geo 2 | import mapillary_tools.gpmf.gps_filter as gps_filter 3 | 4 | 5 | def test_upper_whisker(): 6 | assert ( 7 | gps_filter.upper_whisker( 8 | [7, 7, 31, 31, 47, 75, 87, 115, 116, 119, 119, 155, 177] 9 | ) 10 | == 251 11 | ) 12 | assert gps_filter.upper_whisker([1, 2]) == 3.5 13 | assert gps_filter.upper_whisker([1, 2, 3]) == 3 + 1.5 * (3 - 1) 14 | 15 | 16 | def test_dbscan(): 17 | def _true_decider(p1, p2): 18 | return True 19 | 20 | assert gps_filter.dbscan([], _true_decider) == {} 21 | 22 | assert gps_filter.dbscan( 23 | [[geo.Point(time=1, lat=1, lon=1, angle=None, alt=None)]], _true_decider 24 | ) == {0: [geo.Point(time=1, lat=1, lon=1, angle=None, alt=None)]} 25 | 26 | assert gps_filter.dbscan( 27 | [ 28 | [geo.Point(time=1, lat=1, lon=1, angle=None, alt=None)], 29 | [geo.Point(time=2, lat=1, lon=1, angle=None, alt=None)], 30 | ], 31 | _true_decider, 32 | ) == { 33 | 0: [ 34 | geo.Point(time=1, lat=1, lon=1, angle=None, alt=None), 35 | geo.Point(time=2, lat=1, lon=1, angle=None, alt=None), 36 | ] 37 | } 38 | 39 | assert gps_filter.dbscan( 40 | [ 41 | [geo.Point(time=1, lat=1, lon=1, angle=None, alt=None)], 42 | [geo.Point(time=2, lat=1, lon=1, angle=None, alt=None)], 43 | ], 44 | gps_filter.speed_le(1000), 45 | ) == { 46 | 0: [ 47 | geo.Point(time=1, lat=1, lon=1, angle=None, alt=None), 48 | geo.Point(time=2, lat=1, lon=1, angle=None, alt=None), 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /tests/unit/test_io_utils.py: -------------------------------------------------------------------------------- 1 | import io 2 | import random 3 | 4 | from mapillary_tools.mp4.io_utils import ChainedIO, SlicedIO 5 | 6 | 7 | def test_chained(): 8 | data = b"helloworldworldfoobarworld" 9 | c = io.BytesIO(data) 10 | s = ChainedIO( 11 | [ 12 | io.BytesIO(b"hello"), 13 | ChainedIO([io.BytesIO(b"world")]), 14 | ChainedIO( 15 | [ 16 | ChainedIO([io.BytesIO(b""), io.BytesIO(b"")]), 17 | io.BytesIO(b"world"), 18 | io.BytesIO(b"foo"), 19 | ChainedIO([io.BytesIO(b"")]), 20 | ] 21 | ), 22 | ChainedIO([io.BytesIO(b"")]), 23 | ChainedIO([io.BytesIO(b"bar")]), 24 | ChainedIO( 25 | [ 26 | SlicedIO(io.BytesIO(data), 5, 5), 27 | ChainedIO([io.BytesIO(b"")]), 28 | ] 29 | ), 30 | ] 31 | ) 32 | 33 | assert s.seek(0) == 0 34 | assert c.seek(0) == 0 35 | assert s.read() == c.read() 36 | 37 | assert s.seek(2, io.SEEK_CUR) == len(data) + 2 38 | assert c.seek(2, io.SEEK_CUR) == len(data) + 2 39 | assert s.read() == c.read() 40 | 41 | assert s.seek(6) == 6 42 | assert c.seek(6) == 6 43 | assert s.read() == c.read() 44 | 45 | assert s.seek(2, io.SEEK_END) == len(data) + 2 46 | assert c.seek(2, io.SEEK_END) == len(data) + 2 47 | assert s.read() == c.read() 48 | 49 | assert s.seek(0) == 0 50 | assert c.seek(0) == 0 51 | assert s.read(1) == b"h" 52 | assert s.read(1000) == data[1:] 53 | assert s.read() == b"" 54 | assert s.read(1) == b"" 55 | 56 | assert s.seek(0, io.SEEK_END) == len(data) 57 | assert c.seek(0, io.SEEK_END) == len(data) 58 | 59 | c.seek(0) 60 | s.seek(0) 61 | for _ in range(10000): 62 | whence = random.choice([io.SEEK_SET, io.SEEK_CUR, io.SEEK_END]) 63 | offset = random.randint(0, 30) 64 | assert s.tell() == c.tell() 65 | thrown_x = None 66 | try: 67 | x = s.seek(offset, whence) 68 | except ValueError as ex: 69 | thrown_x = ex 70 | thrown_y = None 71 | try: 72 | y = c.seek(offset, whence) 73 | except ValueError as ex: 74 | thrown_y = ex 75 | assert (thrown_x is not None and thrown_y is not None) or ( 76 | thrown_x is None and thrown_y is None 77 | ), (thrown_x, thrown_y, whence, offset) 78 | if not thrown_x: 79 | assert x == y, ( 80 | f"whence={whence} offset={offset} x={x} y={y} {s.tell()} {c.tell()}" 81 | ) 82 | 83 | n = random.randint(-1, 20) 84 | assert s.read(n) == c.read(n), f"n={n}" 85 | assert s.tell() == c.tell() 86 | 87 | 88 | def test_sliced(): 89 | s = io.BytesIO(b"helloworldfoo") 90 | sliced = SlicedIO(s, 5, 5) 91 | c = io.BytesIO(b"world") 92 | 93 | for _ in range(10000): 94 | whence = random.choice([io.SEEK_SET, io.SEEK_CUR, io.SEEK_END]) 95 | offset = random.randint(-10, 10) 96 | thrown_x = None 97 | try: 98 | x = sliced.seek(offset, whence) 99 | except ValueError as ex: 100 | thrown_x = ex 101 | thrown_y = None 102 | try: 103 | y = c.seek(offset, whence) 104 | except ValueError as ex: 105 | thrown_y = ex 106 | assert (thrown_x is not None and thrown_y is not None) or ( 107 | thrown_x is None and thrown_y is None 108 | ), (thrown_x, thrown_y, whence, offset) 109 | if not thrown_x: 110 | assert x == y 111 | 112 | n = random.randint(-1, 20) 113 | assert sliced.read(n) == c.read(n) 114 | assert sliced.tell() == c.tell() 115 | 116 | 117 | def test_truncate(): 118 | c = io.BytesIO(b"helloworld") 119 | c.truncate(3) 120 | assert c.read() == b"hel" 121 | s = SlicedIO(c, 1, 5) 122 | assert s.read() == b"el" 123 | assert s.read() == b"" 124 | -------------------------------------------------------------------------------- /tests/unit/test_mp4_sample_parser.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from mapillary_tools.mp4 import mp4_sample_parser 4 | 5 | 6 | def test_movie_box_parser(): 7 | moov_parser = mp4_sample_parser.MovieBoxParser.parse_file( 8 | Path("tests/data/videos/sample-5s.mp4") 9 | ) 10 | assert 2 == len(list(moov_parser.extract_tracks())) 11 | video_track = moov_parser.extract_track_at(0) 12 | assert video_track.is_video_track() 13 | aac_track = moov_parser.extract_track_at(1) 14 | assert not aac_track.is_video_track() 15 | samples = list(video_track.extract_samples()) 16 | raw_samples = list(video_track.extract_raw_samples()) 17 | assert 171 == len(samples) 18 | assert len(samples) == len(raw_samples) 19 | assert { 20 | "version": 0, 21 | "flags": 3, 22 | "creation_time": 0, 23 | "modification_time": 0, 24 | "track_ID": 1, 25 | "duration": 5700, 26 | "layer": 0, 27 | "alternate_group": 0, 28 | "volume": 0, 29 | # "matrix": [65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824], 30 | "width": 125829120, 31 | "height": 70778880, 32 | } == { 33 | k: v 34 | for k, v in video_track.extract_tkhd_boxdata().items() 35 | if k 36 | in [ 37 | "version", 38 | "flags", 39 | "creation_time", 40 | "modification_time", 41 | "track_ID", 42 | "duration", 43 | "layer", 44 | "alternate_group", 45 | "volume", 46 | "width", 47 | "height", 48 | ] 49 | } 50 | assert isinstance(video_track.extract_tkhd_boxdata(), dict) 51 | for sample, raw_sample in zip(samples, raw_samples): 52 | assert sample.raw_sample.offset == raw_sample.offset 53 | assert sample.raw_sample.is_sync == raw_sample.is_sync 54 | assert sample.raw_sample.size == raw_sample.size 55 | -------------------------------------------------------------------------------- /tests/unit/test_sample_video.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import os 4 | import shutil 5 | import typing as T 6 | from pathlib import Path 7 | 8 | import py.path 9 | import pytest 10 | 11 | from mapillary_tools import exif_read, ffmpeg, sample_video, types 12 | 13 | _PWD = Path(os.path.dirname(os.path.abspath(__file__))) 14 | 15 | 16 | class MOCK_FFMPEG(ffmpeg.FFMPEG): 17 | def extract_frames( 18 | self, 19 | video_path: Path, 20 | sample_path: Path, 21 | video_sample_interval: float, 22 | stream_idx: T.Optional[int] = None, 23 | ): 24 | probe = self.probe_format_and_streams(video_path) 25 | video_streams = [ 26 | s for s in probe.get("streams", []) if s.get("codec_type") == "video" 27 | ] 28 | duration = float(video_streams[0]["duration"]) 29 | video_basename_no_ext, _ = os.path.splitext(os.path.basename(video_path)) 30 | frame_path_prefix = os.path.join(sample_path, video_basename_no_ext) 31 | src = os.path.join(_PWD, "data/test_exif.jpg") 32 | for idx in range(0, int(duration / video_sample_interval)): 33 | if stream_idx is None: 34 | sample = f"{frame_path_prefix}_NA_{idx + 1:06d}.jpg" 35 | else: 36 | sample = f"{frame_path_prefix}_{stream_idx}_{idx + 1:06d}.jpg" 37 | shutil.copyfile(src, sample) 38 | 39 | def probe_format_and_streams(self, video_path: Path) -> ffmpeg.ProbeOutput: 40 | with open(video_path) as fp: 41 | return json.load(fp) 42 | 43 | 44 | @pytest.fixture 45 | def setup_mock(monkeypatch): 46 | monkeypatch.setattr(ffmpeg, "FFMPEG", MOCK_FFMPEG) 47 | 48 | 49 | def _validate_interval(samples: T.Sequence[Path], video_start_time): 50 | assert len(samples), "expect samples but got none" 51 | for idx, sample in enumerate(sorted(samples)): 52 | assert sample.name == f"hello_NA_{idx + 1:06d}.jpg" 53 | exif = exif_read.ExifRead(sample) 54 | expected_dt = video_start_time + datetime.timedelta(seconds=2 * idx) 55 | assert exif.extract_capture_time() == expected_dt 56 | 57 | 58 | def test_sample_video(tmpdir: py.path.local, setup_mock): 59 | root = _PWD.joinpath("data/mock_sample_video") 60 | video_dir = root.joinpath("videos") 61 | sample_dir = tmpdir.mkdir("sampled_video_frames") 62 | sample_video.sample_video( 63 | video_dir, 64 | Path(sample_dir), 65 | video_sample_distance=-1, 66 | video_sample_interval=2, 67 | rerun=True, 68 | ) 69 | samples = sample_dir.join("hello.mp4").listdir() 70 | video_start_time = types.map_capture_time_to_datetime("2021_08_10_14_37_05_023") 71 | _validate_interval([Path(s) for s in samples], video_start_time) 72 | 73 | 74 | def test_sample_single_video(tmpdir: py.path.local, setup_mock): 75 | root = _PWD.joinpath("data/mock_sample_video") 76 | video_path = root.joinpath("videos", "hello.mp4") 77 | sample_dir = tmpdir.mkdir("sampled_video_frames") 78 | sample_video.sample_video( 79 | video_path, 80 | Path(sample_dir), 81 | video_sample_distance=-1, 82 | video_sample_interval=2, 83 | rerun=True, 84 | ) 85 | samples = sample_dir.join("hello.mp4").listdir() 86 | video_start_time = types.map_capture_time_to_datetime("2021_08_10_14_37_05_023") 87 | _validate_interval([Path(s) for s in samples], video_start_time) 88 | 89 | 90 | def test_sample_video_with_start_time(tmpdir: py.path.local, setup_mock): 91 | root = _PWD.joinpath("data/mock_sample_video") 92 | video_dir = root.joinpath("videos") 93 | sample_dir = tmpdir.mkdir("sampled_video_frames") 94 | video_start_time_str = "2020_08_10_14_37_05_023" 95 | video_start_time = types.map_capture_time_to_datetime(video_start_time_str) 96 | sample_video.sample_video( 97 | video_dir, 98 | Path(sample_dir), 99 | video_start_time=video_start_time_str, 100 | video_sample_distance=-1, 101 | video_sample_interval=2, 102 | rerun=True, 103 | ) 104 | samples = sample_dir.join("hello.mp4").listdir() 105 | _validate_interval([Path(s) for s in samples], video_start_time) 106 | -------------------------------------------------------------------------------- /tests/unit/test_types.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from pathlib import Path 3 | 4 | from mapillary_tools import geo, types 5 | 6 | 7 | def test_desc(): 8 | metadatas = [ 9 | types.ImageMetadata( 10 | filename=Path("foo").resolve(), 11 | md5sum="1233", 12 | lat=1, 13 | lon=2, 14 | alt=3, 15 | angle=4, 16 | time=5, 17 | MAPMetaTags={"foo": "bar", "baz": 1.2}, 18 | MAPSequenceUUID="MAPSequenceUUID", 19 | MAPDeviceMake="MAPDeviceMake", 20 | MAPDeviceModel="MAPDeviceModel", 21 | MAPGPSAccuracyMeters=23, 22 | MAPCameraUUID="MAPCameraUUID", 23 | MAPFilename="MAPFilename", 24 | MAPOrientation=1, 25 | # width and height are not seralized yet so they have to be None to pass the conversion 26 | width=None, 27 | height=None, 28 | ), 29 | types.ImageMetadata( 30 | filename=Path("foo").resolve(), 31 | md5sum=None, 32 | lat=1, 33 | lon=2, 34 | alt=3, 35 | angle=4, 36 | time=5, 37 | MAPMetaTags={"foo": "bar", "baz": 1.2}, 38 | MAPOrientation=1, 39 | ), 40 | ] 41 | for metadata in metadatas: 42 | desc = types.as_desc(metadata) 43 | types.validate_image_desc(desc) 44 | actual = types.from_desc(desc) 45 | assert metadata == actual 46 | 47 | 48 | def test_desc_video(): 49 | ds = [ 50 | types.VideoMetadata( 51 | filename=Path("foo/bar.mp4").resolve(), 52 | md5sum="123", 53 | filetype=types.FileType.CAMM, 54 | points=[geo.Point(time=123, lat=1.331, lon=2.33, alt=3.123, angle=123)], 55 | make="hello", 56 | model="world", 57 | ), 58 | types.VideoMetadata( 59 | filename=Path("foo/bar.mp4").resolve(), 60 | md5sum=None, 61 | filetype=types.FileType.CAMM, 62 | points=[geo.Point(time=123, lat=1.331, lon=2.33, alt=3.123, angle=123)], 63 | ), 64 | types.VideoMetadata( 65 | filename=Path("foo/bar.mp4").resolve(), 66 | md5sum="456", 67 | filetype=types.FileType.CAMM, 68 | points=[], 69 | ), 70 | ] 71 | for metadata in ds: 72 | desc = types._as_video_desc(metadata) 73 | types.validate_video_desc(desc) 74 | actual = types._from_video_desc(desc) 75 | assert metadata == actual 76 | 77 | 78 | def test_datetimes(): 79 | ct = types.datetime_to_map_capture_time(0) 80 | assert ct == "1970_01_01_00_00_00_000" 81 | ct = types.datetime_to_map_capture_time(0.123456) 82 | assert ct == "1970_01_01_00_00_00_123" 83 | ct = types.datetime_to_map_capture_time(0.000456) 84 | assert ct == "1970_01_01_00_00_00_000" 85 | dt = types.map_capture_time_to_datetime(ct) 86 | assert dt == datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc) 87 | x = datetime.datetime.fromisoformat("2020-01-01T00:00:12.123567+08:00") 88 | assert "2019_12_31_16_00_12_123" == types.datetime_to_map_capture_time(x) 89 | assert ( 90 | abs( 91 | geo.as_unix_time( 92 | types.map_capture_time_to_datetime( 93 | types.datetime_to_map_capture_time(x) 94 | ) 95 | ) 96 | - geo.as_unix_time(x) 97 | ) 98 | < 0.001 99 | ) 100 | x = datetime.datetime.now() 101 | assert ( 102 | abs( 103 | geo.as_unix_time( 104 | types.map_capture_time_to_datetime( 105 | types.datetime_to_map_capture_time(x) 106 | ) 107 | ) 108 | - geo.as_unix_time(x) 109 | ) 110 | < 0.001 111 | ) 112 | x = x.astimezone() 113 | assert ( 114 | abs( 115 | geo.as_unix_time( 116 | types.map_capture_time_to_datetime( 117 | types.datetime_to_map_capture_time(x) 118 | ) 119 | ) 120 | - geo.as_unix_time(x) 121 | ) 122 | < 0.001 123 | ) 124 | -------------------------------------------------------------------------------- /tests/unit/test_upload_api_v4.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | import py 4 | 5 | from mapillary_tools import upload_api_v4 6 | 7 | from ..integration.fixtures import setup_upload 8 | 9 | 10 | def test_upload(setup_upload: py.path.local): 11 | upload_service = upload_api_v4.FakeUploadService( 12 | user_access_token="TEST", 13 | session_key="FOOBAR.txt", 14 | cluster_filetype=upload_api_v4.ClusterFileType.ZIP, 15 | ) 16 | upload_service._error_ratio = 0 17 | content = b"double_foobar" 18 | cluster_id = upload_service.upload_byte_stream(io.BytesIO(content), chunk_size=1) 19 | assert isinstance(cluster_id, str), cluster_id 20 | assert (setup_upload.join("FOOBAR.txt").read_binary()) == content 21 | 22 | # reupload should not affect the file 23 | upload_service.upload_byte_stream(io.BytesIO(content), chunk_size=1) 24 | assert (setup_upload.join("FOOBAR.txt").read_binary()) == content 25 | 26 | 27 | def test_upload_big_chunksize(setup_upload: py.path.local): 28 | upload_service = upload_api_v4.FakeUploadService( 29 | user_access_token="TEST", 30 | session_key="FOOBAR.txt", 31 | cluster_filetype=upload_api_v4.ClusterFileType.ZIP, 32 | ) 33 | upload_service._error_ratio = 0 34 | content = b"double_foobar" 35 | cluster_id = upload_service.upload_byte_stream(io.BytesIO(content), chunk_size=1000) 36 | assert isinstance(cluster_id, str), cluster_id 37 | assert (setup_upload.join("FOOBAR.txt").read_binary()) == content 38 | 39 | # reupload should not affect the file 40 | upload_service.upload_byte_stream(io.BytesIO(content), chunk_size=1000) 41 | assert (setup_upload.join("FOOBAR.txt").read_binary()) == content 42 | 43 | 44 | def test_upload_chunks(setup_upload: py.path.local): 45 | upload_service = upload_api_v4.FakeUploadService( 46 | user_access_token="TEST", 47 | session_key="FOOBAR2.txt", 48 | cluster_filetype=upload_api_v4.ClusterFileType.ZIP, 49 | ) 50 | upload_service._error_ratio = 0 51 | 52 | def _gen_chunks(): 53 | yield b"foo" 54 | yield b"" 55 | yield b"bar" 56 | yield b"" 57 | 58 | cluster_id = upload_service.upload_chunks(_gen_chunks()) 59 | 60 | assert isinstance(cluster_id, str), cluster_id 61 | assert (setup_upload.join("FOOBAR2.txt").read_binary()) == b"foobar" 62 | 63 | # reupload should not affect the file 64 | upload_service.upload_chunks(_gen_chunks()) 65 | assert (setup_upload.join("FOOBAR2.txt").read_binary()) == b"foobar" 66 | -------------------------------------------------------------------------------- /tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | import py.path 5 | 6 | from mapillary_tools import utils 7 | 8 | 9 | def test_filter(): 10 | images = [ 11 | Path("foo/bar/hello.mp4/hello_123.jpg"), 12 | Path("/hello.mp4/hello_123.jpg"), 13 | Path("foo/bar/hello/hello_123.jpg"), 14 | Path("/hello.mp4/hell_123.jpg"), 15 | ] 16 | r = list(utils.filter_video_samples(images, Path("hello.mp4"))) 17 | assert r == [ 18 | Path("foo/bar/hello.mp4/hello_123.jpg"), 19 | Path("/hello.mp4/hello_123.jpg"), 20 | ] 21 | 22 | 23 | def test_find_all_image_samples(): 24 | image_samples_by_video_path = utils.find_all_image_samples( 25 | [ 26 | # hello.mp4 27 | Path("foo/hello.mp4/hello_123.jpg"), 28 | Path("foo/hello.mp4/hello_.jpg"), 29 | # world.mp4 30 | Path("world.mp4/world_123.jpg"), 31 | # NOT image samples 32 | Path("world.mp4/hello_123.jpg"), 33 | Path("world.mp4/world.mp4_123.jpg"), 34 | Path("hello/hello_123.jpg"), 35 | ], 36 | [ 37 | Path("foo/hello.mp4"), 38 | Path("hello.mp4"), 39 | Path("foo/world.mp4"), 40 | Path("world.mp4"), 41 | Path("."), 42 | ], 43 | ) 44 | assert { 45 | Path("hello.mp4"): [ 46 | Path("foo/hello.mp4/hello_123.jpg"), 47 | Path("foo/hello.mp4/hello_.jpg"), 48 | ], 49 | Path("world.mp4"): [Path("world.mp4/world_123.jpg")], 50 | } == image_samples_by_video_path 51 | 52 | 53 | def test_deduplicates(): 54 | pwd = Path(".").resolve() 55 | # TODO: life too short to test for windows 56 | if not sys.platform.startswith("win"): 57 | x = utils.deduplicate_paths( 58 | [ 59 | Path("./foo/hello.jpg"), 60 | Path("./foo/bar/../hello.jpg"), 61 | Path(f"{pwd}/foo/bar/../hello.jpg"), 62 | ] 63 | ) 64 | assert [Path("./foo/hello.jpg")] == list(x) 65 | 66 | 67 | def test_filter_all(tmpdir: py.path.local): 68 | tmpdir.mkdir("foo") 69 | tmpdir.join("foo").join("hello.jpg").open("wb").close() 70 | tmpdir.join("foo").join("hello.jpe").open("wb").close() 71 | tmpdir.join("foo").join("world.TIFF").open("wb").close() 72 | tmpdir.join("foo").join("world.ZIP").open("wb").close() 73 | tmpdir.join("foo").join("world.mp4").open("wb").close() 74 | tmpdir.join("foo").join("world.MP4").open("wb").close() 75 | tmpdir.join("foo").join("world.ts").open("wb").close() 76 | tmpdir.join("foo").join(".jpg").open("wb").close() 77 | tmpdir.join("foo").join(".png").open("wb").close() 78 | tmpdir.join("foo").join(".zip").open("wb").close() 79 | tmpdir.join("foo").join(".MP4").open("wb").close() 80 | tmpdir.join("foo").mkdir(".git") 81 | # TODO: life too short to test for windows 82 | if not sys.platform.startswith("win"): 83 | assert {"foo/world.TIFF", "foo/hello.jpg", "foo/hello.jpe"} == set( 84 | str(p.relative_to(tmpdir)) 85 | for p in utils.find_images( 86 | [ 87 | Path(tmpdir), 88 | Path(tmpdir.join("foo").join("world.TIFF")), 89 | Path(tmpdir.join("foo").join(".foo")), 90 | Path(tmpdir.join("foo").join("../foo")), 91 | ] 92 | ) 93 | ) 94 | assert {"foo/world.zip", "foo/world.ZIP"} == set( 95 | str(p.relative_to(tmpdir)) 96 | for p in utils.find_zipfiles( 97 | [ 98 | Path(tmpdir), 99 | Path(tmpdir.join("foo").join("world.zip")), 100 | Path(tmpdir.join("foo").join(".foo")), 101 | Path(tmpdir.join("foo").join("../foo")), 102 | ] 103 | ) 104 | ) 105 | actual = set( 106 | str(p.relative_to(tmpdir)) 107 | for p in utils.find_videos( 108 | [ 109 | Path(tmpdir), 110 | Path(tmpdir.join("foo").join("world.mp4")), 111 | Path(tmpdir.join("foo").join("world.ts")), 112 | Path(tmpdir.join("foo").join(".foo")), 113 | Path(tmpdir.join("foo").join("../foo")), 114 | ] 115 | ) 116 | ) 117 | # some platform filenames are case sensitive? 118 | assert ( 119 | {"foo/world.MP4", "foo/world.ts"} == actual 120 | or {"foo/world.mp4", "foo/world.MP4", "foo/world.ts"} == actual 121 | or {"foo/world.mp4", "foo/world.ts"} == actual 122 | ) 123 | --------------------------------------------------------------------------------