├── .gitignore ├── .travis.yml ├── CHANGES.txt ├── CODE_OF_CONDUCT.txt ├── LICENSE ├── README.rst ├── mbtiles ├── __init__.py ├── cf.py ├── compat.py ├── mp.py ├── scripts │ ├── __init__.py │ └── cli.py └── worker.py ├── requirements-dev.txt ├── setup.py └── tests ├── conftest.py ├── data ├── RGB.byte.tif ├── RGBA.byte.tif ├── rgb-193f513.vrt ├── rgb-fa48952.vrt ├── rgba_cutline.geojson └── rgba_points.geojson ├── test_cli.py └── test_mod.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | 56 | # OS X 57 | .DS_Store 58 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | env: 4 | global: 5 | - PIP_WHEEL_DIR=$HOME/.cache/pip 6 | - PIP_FIND_LINKS=$HOME/.cache/pip 7 | python: 8 | - "2.7.15" 9 | - "3.6" 10 | - "3.7" 11 | cache: 12 | directories: 13 | - $HOME/.cache/pip/ 14 | install: 15 | - pip install -U pip 16 | - pip install -r requirements-dev.txt 17 | - pip install -e .[test] 18 | script: 19 | - python -m pytest -vvv --cov mbtiles --cov-report term-missing 20 | after_success: 21 | - coveralls 22 | 23 | deploy: 24 | on: 25 | tags: true 26 | provider: pypi 27 | distributions: "sdist bdist_wheel" 28 | user: __token__ 29 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | 1.6.0 (2021-07-28) 5 | ------------------ 6 | 7 | There have been no changes since 1.6a2. 8 | 9 | 1.6a2 (2021-07-23) 10 | ------------------ 11 | 12 | - Turn on latent support for 8-bit input and output. 13 | - Rely on GDAL's warper to create the alpha band for RGB input when --rgba is 14 | used. 15 | 16 | 1.6a1 (2021-07-20) 17 | ------------------ 18 | 19 | - Add an option to include empty tiles in the output mbtiles dataset. By 20 | default, we continue to exclude them. 21 | - Add an alpha channel for RGB input when the --rgba option is used (#76). 22 | 23 | 1.5.1 (2021-02-02) 24 | ------------------ 25 | 26 | - Add --co (creation) options for fine control over quality of tiles using any 27 | of a format's valid GDAL creation options (#73). 28 | - Add support for WebP tiles (#72). 29 | 30 | 1.5.0 (2020-10-30) 31 | ------------------ 32 | 33 | There have been no changes since 1.5b5. 34 | 35 | 1.5b5 (2020-10-29) 36 | ------------------ 37 | 38 | - Improve estimation of number of tiles. 39 | 40 | 1.5b4 (2020-10-29) 41 | ------------------ 42 | 43 | - Consolidate --append/--overwrite into one option and make appending the 44 | default. 45 | 46 | 1.5b3 (2020-10-28) 47 | ------------------ 48 | 49 | - Add a --covers option, taking a quadkey, which limits the output to tiles 50 | that cover the quadkey's tile (#66). 51 | 52 | 1.5b2 (2020-10-16) 53 | ------------------ 54 | 55 | - Add --oo (open) options and --wo (warp) options like those of gdalwarp. These 56 | allow control over, for example, overview level of TMS datasets and the 57 | number of threads used internally by GDAL's warper. 58 | - Add a --cutline option that takes an optional path to a GeoJSON 59 | FeatureCollection (#62). No pixels outside the cutline shape(s) will be 60 | exported and no tiles outside the cutline will be generated. 61 | 62 | 1.5b1 (2020-10-12) 63 | ------------------ 64 | 65 | - Support appending to or updating existing mbtiles files (#59). 66 | - Add an optional progress bar based on tqdm. 67 | - Add concurrent.futures and multiprocessing implementations of process_tiles() 68 | (#54). 69 | 70 | 1.4.2 (2019-03-07) 71 | ------------------ 72 | 73 | - Missing support for RGBA input and output (PNG only) has been added (#26). 74 | Using the options --format PNG --rgba with rio-mbtiles will create RGBA PNG 75 | tiles from the first 4 bands of the input dataset. 76 | - Output tile size has been made an command option. The default remains 77 | 256 x 256 (#44). 78 | 79 | 1.4.1 (2018-10-17) 80 | ------------------ 81 | 82 | - Write out empty tiles at the lowest zoom levels when the reverse transform 83 | fails during our attempt to skip empty tiles entirely (#41). 84 | - Avoid modifying rasterio windows in place as they have been made immutable 85 | in new versions of Rasterio (#43). 86 | 87 | 1.4.0 (2018-08-06) 88 | ------------------ 89 | 90 | - Empty tiles are skipped (#34). 91 | - Require rasterio~=1.0 and the Rasterio's dataset overwriting option. Resolves 92 | issue #39. 93 | - Rasterio's resampling options are enabled on the command line (#31). 94 | - Require Python version >= 2.7.10. Mbtiles data will not be properly written 95 | with older versions of Python. See commit 57fba73 and 96 | https://bugs.python.org/issue23349. 97 | 98 | 1.3.0 (2016-10-05) 99 | ------------------ 100 | 101 | - Add --src-nodata and --dst-nodata options, with the same semantics as in 102 | rio-warp (#15). 103 | - Require numpy>=1.10 and rasterio>=1.0a2 104 | 105 | 1.3a1 (2016-09-27) 106 | ------------------ 107 | - Require rasterio>=1.0a1 108 | 109 | 1.2.0 (2015-08-03) 110 | ------------------ 111 | - Register rio-mbtiles to rasterio.rio_plugins (#7). 112 | - Use Rasterio's release-test-4 (0.25) wheels to speed up Travis builds (#11). 113 | 114 | - 1.1.0 (2015-05-11) 115 | ------------------ 116 | - Rename module from rio_mbtiles to mbtiles (#2). Command remains the same. 117 | 118 | 1.0.1 (2015-05-09) 119 | ------------------ 120 | - Remove adaptive chunking and commit tiles as soon as possible to keep 121 | program memory well bounded. 122 | 123 | 1.0.0 (2015-05-08) 124 | ------------------ 125 | - Initial release. Exports version 1.1 MBTiles with automatic reprojection 126 | and parallel processing. Requires Rasterio >= 0.23. 127 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.txt: -------------------------------------------------------------------------------- 1 | Contributor Code of Conduct 2 | --------------------------- 3 | 4 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 5 | 6 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 7 | 8 | Examples of unacceptable behavior by participants include: 9 | 10 | * The use of sexualized language or imagery 11 | * Personal attacks 12 | * Trolling or insulting/derogatory comments 13 | * Public or private harassment 14 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 15 | * Other unethical or unprofessional conduct. 16 | 17 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 18 | 19 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 20 | 21 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 22 | 23 | This Code of Conduct is adapted from the `Contributor Covenant`_, version 1.2.0, available at http://contributor-covenant.org/version/1/2/0/ 24 | 25 | .. _Contributor Covenant: http://contributor-covenant.org 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Mapbox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | rio-mbtiles 2 | =========== 3 | 4 | .. image:: https://travis-ci.org/mapbox/rio-mbtiles.svg 5 | :target: https://travis-ci.org/mapbox/rio-mbtiles 6 | 7 | A plugin for the 8 | `Rasterio CLI `__ 9 | that exports a raster dataset to the MBTiles (version 1.3) format. Features 10 | include automatic reprojection and concurrent tile generation. 11 | 12 | Usage 13 | ----- 14 | 15 | .. code-block:: console 16 | 17 | Usage: rio mbtiles [OPTIONS] INPUT [OUTPUT] 18 | 19 | Export a dataset to MBTiles (version 1.3) in a SQLite file. 20 | 21 | The input dataset may have any coordinate reference system. It must have 22 | at least three bands, which will be become the red, blue, and green bands 23 | of the output image tiles. 24 | 25 | An optional fourth alpha band may be copied to the output tiles by using 26 | the --rgba option in combination with the PNG or WEBP formats. This option 27 | requires that the input dataset has at least 4 bands. 28 | 29 | The default quality for JPEG and WEBP output (possible range: 10-100) is 30 | 75. This value can be changed with the use of the QUALITY creation option, 31 | e.g. `--co QUALITY=90`. The default zlib compression level for PNG output 32 | (possible range: 1-9) is 6. This value can be changed like `--co 33 | ZLEVEL=8`. Lossless WEBP can be chosen with `--co LOSSLESS=TRUE`. 34 | 35 | If no zoom levels are specified, the defaults are the zoom levels nearest 36 | to the one at which one tile may contain the entire source dataset. 37 | 38 | If a title or description for the output file are not provided, they will 39 | be taken from the input dataset's filename. 40 | 41 | This command is suited for small to medium (~1 GB) sized sources. 42 | 43 | Python package: rio-mbtiles (https://github.com/mapbox/rio-mbtiles). 44 | 45 | Options: 46 | -o, --output PATH Path to output file (optional alternative to 47 | a positional arg). 48 | 49 | --append / --overwrite Append tiles to an existing file or 50 | overwrite. 51 | 52 | --title TEXT MBTiles dataset title. 53 | --description TEXT MBTiles dataset description. 54 | --overlay Export as an overlay (the default). 55 | --baselayer Export as a base layer. 56 | -f, --format [JPEG|PNG|WEBP] Tile image format. 57 | --tile-size INTEGER Width and height of individual square tiles 58 | to create. [default: 256] 59 | 60 | --zoom-levels MIN..MAX A min...max range of export zoom levels. The 61 | default zoom level is the one at which the 62 | dataset is contained within a single tile. 63 | 64 | --image-dump PATH A directory into which image tiles will be 65 | optionally dumped. 66 | 67 | -j INTEGER Number of workers (default: number of 68 | computer's processors). 69 | 70 | --src-nodata FLOAT Manually override source nodata 71 | --dst-nodata FLOAT Manually override destination nodata 72 | --resampling [nearest|bilinear|cubic|cubic_spline|lanczos|average|mode|gauss|max|min|med|q1|q3|rms] 73 | Resampling method to use. [default: 74 | nearest] 75 | 76 | --version Show the version and exit. 77 | --rgba Select RGBA output. For PNG or WEBP only. 78 | --implementation [cf|mp] Concurrency implementation. Use 79 | concurrent.futures (cf) or multiprocessing 80 | (mp). 81 | 82 | -#, --progress-bar Display progress bar. 83 | --covers TEXT Restrict mbtiles output to cover a quadkey 84 | --cutline PATH Path to a GeoJSON FeatureCollection to be 85 | used as a cutline. Only source pixels within 86 | the cutline features will be exported. 87 | 88 | --oo NAME=VALUE Format driver-specific options to be used 89 | when accessing the input dataset. See the 90 | GDAL format driver documentation for more 91 | information. 92 | 93 | --co, --profile NAME=VALUE Driver specific creation options. See the 94 | documentation for the selected output driver 95 | for more information. 96 | 97 | --wo NAME=VALUE See the GDAL warp options documentation for 98 | more information. 99 | 100 | --exclude-empty-tiles / --include-empty-tiles 101 | Whether to exclude or include empty tiles 102 | from the output. 103 | 104 | --help Show this message and exit. 105 | 106 | Performance 107 | ----------- 108 | 109 | The rio-mbtiles command is suited for small to medium (~1 GB) raster sources. 110 | On a MacBook Air, the 1:10M scale Natural Earth raster 111 | (a 21,600 x 10,800 pixel, 700 MB TIFF) exports to MBTiles (levels 1 through 5) 112 | in 45 seconds. 113 | 114 | .. code-block:: console 115 | 116 | $ time GDAL_CACHEMAX=256 rio mbtiles NE1_HR_LC.tif \ 117 | > -o ne.mbtiles --zoom-levels 1..5 -j 4 118 | 119 | real 0m44.925s 120 | user 1m20.152s 121 | sys 0m22.428s 122 | 123 | Installation 124 | ------------ 125 | 126 | ``pip install rio-mbtiles`` 127 | -------------------------------------------------------------------------------- /mbtiles/__init__.py: -------------------------------------------------------------------------------- 1 | """rio-mbtiles package""" 2 | 3 | import sys 4 | import warnings 5 | 6 | __version__ = "1.6.0" 7 | 8 | if sys.version_info < (3, 7): 9 | warnings.warn( 10 | "Support for Python versions < 3.7 will be dropped in rio-mbtiles version 2.0", 11 | FutureWarning, 12 | stacklevel=2, 13 | ) 14 | -------------------------------------------------------------------------------- /mbtiles/cf.py: -------------------------------------------------------------------------------- 1 | """concurrent.futures implementation""" 2 | 3 | import concurrent.futures 4 | from itertools import islice 5 | import logging 6 | 7 | from mbtiles.worker import init_worker, process_tile 8 | 9 | BATCH_SIZE = 100 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | def process_tiles( 15 | tiles, 16 | init_mbtiles, 17 | insert_results, 18 | commit_mbtiles, 19 | num_workers=None, 20 | inputfile=None, 21 | base_kwds=None, 22 | resampling=None, 23 | img_ext=None, 24 | image_dump=None, 25 | progress_bar=None, 26 | open_options=None, 27 | warp_options=None, 28 | creation_options=None, 29 | exclude_empty_tiles=True, 30 | ): 31 | """Warp imagery into tiles and commit to mbtiles database. 32 | """ 33 | with concurrent.futures.ProcessPoolExecutor( 34 | max_workers=num_workers, 35 | initializer=init_worker, 36 | initargs=( 37 | inputfile, 38 | base_kwds, 39 | resampling, 40 | open_options, 41 | warp_options, 42 | creation_options, 43 | exclude_empty_tiles, 44 | ), 45 | ) as executor: 46 | group = islice(tiles, BATCH_SIZE) 47 | futures = {executor.submit(process_tile, tile) for tile in group} 48 | 49 | init_mbtiles() 50 | 51 | count = 0 52 | while futures: 53 | done, futures = concurrent.futures.wait( 54 | futures, return_when=concurrent.futures.FIRST_COMPLETED 55 | ) 56 | 57 | group = islice(tiles, len(done)) 58 | for tile in group: 59 | futures.add(executor.submit(process_tile, tile)) 60 | 61 | for future in done: 62 | tile, contents = future.result() 63 | insert_results(tile, contents, img_ext=img_ext, image_dump=image_dump) 64 | 65 | count += len(done) 66 | if count > BATCH_SIZE: 67 | commit_mbtiles() 68 | count = 0 69 | 70 | if progress_bar is not None: 71 | if progress_bar.n + len(done) < progress_bar.total: 72 | progress_bar.update(len(done)) 73 | -------------------------------------------------------------------------------- /mbtiles/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import warnings 3 | 4 | if sys.version_info < (3,): 5 | from itertools import izip_longest as zip_longest 6 | else: 7 | from itertools import zip_longest 8 | -------------------------------------------------------------------------------- /mbtiles/mp.py: -------------------------------------------------------------------------------- 1 | """multiprocessing Pool implementation""" 2 | 3 | from multiprocessing import Pool 4 | import warnings 5 | 6 | from mbtiles.compat import zip_longest 7 | from mbtiles.worker import init_worker, process_tile 8 | 9 | BATCH_SIZE = 100 10 | 11 | warnings.warn( 12 | "The multiprocessing.Pool implementation will be removed in rio-mbtiles 2.0.0.", 13 | FutureWarning, 14 | stacklevel=2, 15 | ) 16 | 17 | 18 | def process_tiles( 19 | tiles, 20 | init_mbtiles, 21 | insert_results, 22 | commit_mbtiles, 23 | num_workers=None, 24 | inputfile=None, 25 | base_kwds=None, 26 | resampling=None, 27 | img_ext=None, 28 | image_dump=None, 29 | progress_bar=None, 30 | open_options=None, 31 | warp_options=None, 32 | creation_options=None, 33 | exclude_empty_tiles=True, 34 | ): 35 | """Warp raster into tiles and commit tiles to mbtiles database. 36 | """ 37 | pool = Pool( 38 | num_workers, 39 | init_worker, 40 | ( 41 | inputfile, 42 | base_kwds, 43 | resampling, 44 | open_options, 45 | warp_options, 46 | creation_options, 47 | exclude_empty_tiles, 48 | ), 49 | 100 * BATCH_SIZE, 50 | ) 51 | 52 | def grouper(iterable, n, fillvalue=None): 53 | "Collect data into fixed-length chunks or blocks" 54 | # grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx" 55 | args = [iter(iterable)] * n 56 | return zip_longest(*args, fillvalue=fillvalue) 57 | 58 | init_mbtiles() 59 | 60 | for group in grouper(pool.imap_unordered(process_tile, tiles), BATCH_SIZE): 61 | for group_n, item in enumerate(group, start=1): 62 | if item is None: 63 | break 64 | tile, contents = item 65 | insert_results(tile, contents, img_ext=img_ext, image_dump=image_dump) 66 | 67 | commit_mbtiles() 68 | 69 | if progress_bar is not None: 70 | if progress_bar.n + group_n < progress_bar.total: 71 | progress_bar.update(group_n) 72 | 73 | pool.close() 74 | -------------------------------------------------------------------------------- /mbtiles/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/rio-mbtiles/f557854ac42f832e8436ac09582a20691bae90df/mbtiles/scripts/__init__.py -------------------------------------------------------------------------------- /mbtiles/scripts/cli.py: -------------------------------------------------------------------------------- 1 | """mbtiles CLI""" 2 | 3 | import functools 4 | import logging 5 | import math 6 | import os 7 | import sqlite3 8 | import sys 9 | 10 | import click 11 | from cligj.features import iter_features 12 | import mercantile 13 | import rasterio 14 | from rasterio.enums import Resampling 15 | from rasterio.rio.options import creation_options, output_opt, _cb_key_val 16 | from rasterio.warp import transform, transform_geom 17 | import shapely.affinity 18 | from shapely.geometry import mapping, shape 19 | from shapely.ops import unary_union 20 | import shapely.wkt 21 | import supermercado.burntiles 22 | from tqdm import tqdm 23 | 24 | from mbtiles import __version__ as mbtiles_version 25 | 26 | 27 | DEFAULT_NUM_WORKERS = None 28 | RESAMPLING_METHODS = [method.name for method in Resampling] 29 | TILES_CRS = "EPSG:3857" 30 | 31 | log = logging.getLogger(__name__) 32 | 33 | 34 | def resolve_inout( 35 | input=None, output=None, files=None, overwrite=False, append=False, num_inputs=None 36 | ): 37 | """Resolves inputs and outputs from standard args and options. 38 | 39 | Parameters 40 | ---------- 41 | input : str 42 | A single input filename, optional. 43 | output : str 44 | A single output filename, optional. 45 | files : str 46 | A sequence of filenames in which the last is the output filename. 47 | overwrite : bool 48 | Whether to force overwriting the output file. 49 | append : bool 50 | Whether to append to the output file. 51 | num_inputs : int 52 | Raise exceptions if the number of resolved input files is higher 53 | or lower than this number. 54 | 55 | Returns 56 | ------- 57 | tuple (str, list of str) 58 | The resolved output filename and input filenames as a tuple of 59 | length 2. 60 | 61 | If provided, the output file may be overwritten. An output 62 | file extracted from files will not be overwritten unless 63 | overwrite is True. 64 | 65 | Raises 66 | ------ 67 | click.BadParameter 68 | 69 | """ 70 | resolved_output = output or (files[-1] if files else None) 71 | resolved_inputs = ( 72 | [input] 73 | if input 74 | else [] + list(files[: -1 if not output else None]) 75 | if files 76 | else [] 77 | ) 78 | 79 | if num_inputs is not None: 80 | if len(resolved_inputs) < num_inputs: 81 | raise click.BadParameter("Insufficient inputs") 82 | elif len(resolved_inputs) > num_inputs: 83 | raise click.BadParameter("Too many inputs") 84 | 85 | return resolved_output, resolved_inputs 86 | 87 | 88 | def extract_features(ctx, param, value): 89 | if value is not None: 90 | with click.open_file(value, encoding="utf-8") as src: 91 | return list(iter_features(iter(src))) 92 | else: 93 | return None 94 | 95 | 96 | @click.command(short_help="Export a dataset to MBTiles.") 97 | @click.argument( 98 | "files", 99 | nargs=-1, 100 | type=click.Path(resolve_path=True), 101 | required=True, 102 | metavar="INPUT [OUTPUT]", 103 | ) 104 | @output_opt 105 | @click.option( 106 | "--append/--overwrite", 107 | default=True, 108 | is_flag=True, 109 | help="Append tiles to an existing file or overwrite.", 110 | ) 111 | @click.option("--title", help="MBTiles dataset title.") 112 | @click.option("--description", help="MBTiles dataset description.") 113 | @click.option( 114 | "--overlay", 115 | "layer_type", 116 | flag_value="overlay", 117 | default=True, 118 | help="Export as an overlay (the default).", 119 | ) 120 | @click.option( 121 | "--baselayer", "layer_type", flag_value="baselayer", help="Export as a base layer." 122 | ) 123 | @click.option( 124 | "-f", 125 | "--format", 126 | "img_format", 127 | type=click.Choice(["JPEG", "PNG", "WEBP"]), 128 | default="JPEG", 129 | help="Tile image format.", 130 | ) 131 | @click.option( 132 | "--tile-size", 133 | default=256, 134 | show_default=True, 135 | type=int, 136 | help="Width and height of individual square tiles to create.", 137 | ) 138 | @click.option( 139 | "--zoom-levels", 140 | default=None, 141 | metavar="MIN..MAX", 142 | help="A min...max range of export zoom levels. " 143 | "The default zoom level " 144 | "is the one at which the dataset is contained within " 145 | "a single tile.", 146 | ) 147 | @click.option( 148 | "--image-dump", 149 | metavar="PATH", 150 | help="A directory into which image tiles will be optionally " "dumped.", 151 | ) 152 | @click.option( 153 | "-j", 154 | "num_workers", 155 | type=int, 156 | default=DEFAULT_NUM_WORKERS, 157 | help="Number of workers (default: number of computer's processors).", 158 | ) 159 | @click.option( 160 | "--src-nodata", 161 | default=None, 162 | show_default=True, 163 | type=float, 164 | help="Manually override source nodata", 165 | ) 166 | @click.option( 167 | "--dst-nodata", 168 | default=None, 169 | show_default=True, 170 | type=float, 171 | help="Manually override destination nodata", 172 | ) 173 | @click.option( 174 | "--resampling", 175 | type=click.Choice(RESAMPLING_METHODS), 176 | default="nearest", 177 | show_default=True, 178 | help="Resampling method to use.", 179 | ) 180 | @click.version_option(version=mbtiles_version, message="%(version)s") 181 | @click.option( 182 | "--rgba", default=False, is_flag=True, help="Select RGBA output. For PNG or WEBP only." 183 | ) 184 | @click.option( 185 | "--implementation", 186 | "implementation", 187 | type=click.Choice(["cf", "mp"]), 188 | default=None, 189 | help="Concurrency implementation. Use concurrent.futures (cf) or multiprocessing (mp).", 190 | ) 191 | @click.option( 192 | "--progress-bar", "-#", default=False, is_flag=True, help="Display progress bar." 193 | ) 194 | @click.option("--covers", help="Restrict mbtiles output to cover a quadkey") 195 | @click.option( 196 | "--cutline", 197 | type=click.Path(exists=True), 198 | callback=extract_features, 199 | default=None, 200 | help="Path to a GeoJSON FeatureCollection to be used as a cutline. Only source pixels within the cutline features will be exported.", 201 | ) 202 | @click.option( 203 | "--oo", 204 | "open_options", 205 | metavar="NAME=VALUE", 206 | multiple=True, 207 | callback=_cb_key_val, 208 | help="Format driver-specific options to be used when accessing the input dataset. See the GDAL format driver documentation for more information.", 209 | ) 210 | @creation_options 211 | @click.option( 212 | "--wo", 213 | "warp_options", 214 | metavar="NAME=VALUE", 215 | multiple=True, 216 | callback=_cb_key_val, 217 | help="See the GDAL warp options documentation for more information.", 218 | ) 219 | @click.option( 220 | "--exclude-empty-tiles/--include-empty-tiles", 221 | default=True, 222 | is_flag=True, 223 | help="Whether to exclude or include empty tiles from the output.", 224 | ) 225 | @click.pass_context 226 | def mbtiles( 227 | ctx, 228 | files, 229 | output, 230 | append, 231 | title, 232 | description, 233 | layer_type, 234 | img_format, 235 | tile_size, 236 | zoom_levels, 237 | image_dump, 238 | num_workers, 239 | src_nodata, 240 | dst_nodata, 241 | resampling, 242 | rgba, 243 | implementation, 244 | progress_bar, 245 | covers, 246 | cutline, 247 | open_options, 248 | creation_options, 249 | warp_options, 250 | exclude_empty_tiles, 251 | ): 252 | """Export a dataset to MBTiles (version 1.3) in a SQLite file. 253 | 254 | The input dataset may have any coordinate reference system. It must 255 | have at least three bands, which will be become the red, blue, and 256 | green bands of the output image tiles. 257 | 258 | An optional fourth alpha band may be copied to the output tiles by 259 | using the --rgba option in combination with the PNG or WEBP formats. 260 | This option requires that the input dataset has at least 4 bands. 261 | 262 | The default quality for JPEG and WEBP output (possible range: 263 | 10-100) is 75. This value can be changed with the use of the QUALITY 264 | creation option, e.g. `--co QUALITY=90`. The default zlib 265 | compression level for PNG output (possible range: 1-9) is 6. This 266 | value can be changed like `--co ZLEVEL=8`. Lossless WEBP can be 267 | chosen with `--co LOSSLESS=TRUE`. 268 | 269 | If no zoom levels are specified, the defaults are the zoom levels 270 | nearest to the one at which one tile may contain the entire source 271 | dataset. 272 | 273 | If a title or description for the output file are not provided, 274 | they will be taken from the input dataset's filename. 275 | 276 | This command is suited for small to medium (~1 GB) sized sources. 277 | 278 | Python package: rio-mbtiles (https://github.com/mapbox/rio-mbtiles). 279 | 280 | """ 281 | log = logging.getLogger(__name__) 282 | 283 | output, files = resolve_inout( 284 | files=files, output=output, overwrite=not (append), append=append, num_inputs=1, 285 | ) 286 | inputfile = files[0] 287 | 288 | if implementation == "cf" and sys.version_info < (3, 7): 289 | raise click.BadParameter( 290 | "concurrent.futures implementation requires python>=3.7" 291 | ) 292 | elif implementation == "cf": 293 | from mbtiles.cf import process_tiles 294 | elif implementation == "mp": 295 | from mbtiles.mp import process_tiles 296 | elif sys.version_info >= (3, 7): 297 | from mbtiles.cf import process_tiles 298 | else: 299 | from mbtiles.mp import process_tiles 300 | 301 | with ctx.obj["env"]: 302 | 303 | # Read metadata from the source dataset. 304 | with rasterio.open(inputfile, **open_options) as src: 305 | 306 | if dst_nodata is not None and ( 307 | src_nodata is None and src.profile.get("nodata") is None 308 | ): 309 | raise click.BadParameter( 310 | "--src-nodata must be provided because " "dst-nodata is not None." 311 | ) 312 | base_kwds = {"dst_nodata": dst_nodata, "src_nodata": src_nodata} 313 | 314 | if src_nodata is not None: 315 | base_kwds.update(nodata=src_nodata) 316 | 317 | if dst_nodata is not None: 318 | base_kwds.update(nodata=dst_nodata) 319 | 320 | # Name and description. 321 | title = title or os.path.basename(src.name) 322 | description = description or src.name 323 | 324 | # Compute the geographic bounding box of the dataset. 325 | (west, east), (south, north) = transform( 326 | src.crs, "EPSG:4326", src.bounds[::2], src.bounds[1::2] 327 | ) 328 | 329 | # cutlines must be transformed from CRS84 to src pixel/line 330 | # coordinates and then formatted as WKT. 331 | if cutline is not None: 332 | geoms = [shape(f["geometry"]) for f in cutline] 333 | union = unary_union(geoms) 334 | if union.geom_type not in ("MultiPolygon", "Polygon"): 335 | raise click.ClickException("Unexpected cutline geometry type") 336 | west, south, east, north = union.bounds 337 | cutline_src = shape( 338 | transform_geom("OGC:CRS84", src.crs, mapping(union)) 339 | ) 340 | invtransform = ~src.transform 341 | shapely_matrix = ( 342 | invtransform.a, 343 | invtransform.b, 344 | invtransform.d, 345 | invtransform.e, 346 | invtransform.xoff, 347 | invtransform.yoff, 348 | ) 349 | cutline_rev = shapely.affinity.affine_transform( 350 | cutline_src, shapely_matrix 351 | ) 352 | warp_options["cutline"] = shapely.wkt.dumps(cutline_rev) 353 | 354 | if covers is not None: 355 | covers_tile = mercantile.quadkey_to_tile(covers) 356 | west, south, east, north = mercantile.bounds(covers_tile) 357 | 358 | # Resolve the minimum and maximum zoom levels for export. 359 | if zoom_levels: 360 | minzoom, maxzoom = map(int, zoom_levels.split("..")) 361 | else: 362 | zw = int(round(math.log(360.0 / (east - west), 2.0))) 363 | zh = int(round(math.log(170.1022 / (north - south), 2.0))) 364 | minzoom = min(zw, zh) 365 | maxzoom = max(zw, zh) 366 | 367 | log.debug("Zoom range: %d..%d", minzoom, maxzoom) 368 | 369 | if rgba: 370 | if img_format == "JPEG": 371 | raise click.BadParameter( 372 | "RGBA output is not possible with JPEG format." 373 | ) 374 | else: 375 | count = 4 376 | else: 377 | count = src.count 378 | 379 | # Parameters for creation of tile images. 380 | base_kwds.update( 381 | { 382 | "driver": img_format.upper(), 383 | "dtype": "uint8", 384 | "nodata": 0, 385 | "height": tile_size, 386 | "width": tile_size, 387 | "count": count, 388 | "crs": TILES_CRS, 389 | } 390 | ) 391 | 392 | img_ext = "jpg" if img_format.lower() == "jpeg" else img_format.lower() 393 | 394 | # Constrain bounds. 395 | EPS = 1.0e-10 396 | west = max(-180 + EPS, west) 397 | south = max(-85.051129, south) 398 | east = min(180 - EPS, east) 399 | north = min(85.051129, north) 400 | 401 | if progress_bar: 402 | # Estimate total number of tiles. 403 | west_merc, south_merc = mercantile.xy(west, south) 404 | east_merc, north_merc = mercantile.xy(east, north) 405 | raster_area = (east_merc - west_merc) * (north_merc - south_merc) 406 | 407 | est_num_tiles = 0 408 | zoom = minzoom 409 | 410 | ( 411 | minz_west_merc, 412 | minz_south_merc, 413 | minz_east_merc, 414 | minz_north_merc, 415 | ) = mercantile.xy_bounds(mercantile.tile(0, 0, zoom)) 416 | minzoom_tile_area = (minz_east_merc - minz_west_merc) * ( 417 | minz_north_merc - minz_south_merc 418 | ) 419 | ratio = min_ratio = raster_area / minzoom_tile_area 420 | 421 | # If given a cutline, we use its mercator area and the 422 | # supermercado module to help estimate the number of output 423 | # tiles. 424 | if cutline: 425 | geoms = [shape(f["geometry"]) for f in cutline] 426 | union = unary_union(geoms) 427 | cutline_mercator = transform_geom( 428 | "OGC:CRS84", "EPSG:3857", mapping(union) 429 | ) 430 | min_ratio *= shape(cutline_mercator).area / raster_area 431 | ratio = min_ratio 432 | estimator = functools.partial(supermercado.burntiles.burn, cutline) 433 | else: 434 | estimator = functools.partial( 435 | mercantile.tiles, west, south, east, north 436 | ) 437 | 438 | est_num_tiles = len(list(estimator(zoom))) 439 | ratio *= 4.0 440 | 441 | while zoom < maxzoom and ratio < 16: 442 | zoom += 1 443 | est_num_tiles += len(list(estimator(zoom))) 444 | ratio *= 4.0 445 | else: 446 | zoom += 1 447 | 448 | est_num_tiles += int( 449 | sum( 450 | math.ceil(math.pow(4.0, z - minzoom) * min_ratio) 451 | for z in range(zoom, maxzoom + 1) 452 | ) 453 | ) 454 | 455 | pbar = tqdm(total=est_num_tiles) 456 | 457 | else: 458 | pbar = None 459 | 460 | # Initialize the sqlite db. 461 | output_exists = os.path.exists(output) 462 | if append: 463 | appending = output_exists 464 | elif output_exists: 465 | appending = False 466 | log.info("Overwrite mode chosen, unlinking output file.") 467 | os.unlink(output) 468 | 469 | # workaround for bug here: https://bugs.python.org/issue27126 470 | sqlite3.connect(":memory:").close() 471 | conn = sqlite3.connect(output) 472 | 473 | def init_mbtiles(): 474 | """Note: this closes over other local variables of the command function.""" 475 | cur = conn.cursor() 476 | 477 | if appending: 478 | cur.execute("SELECT * FROM metadata WHERE name = 'bounds';") 479 | ( 480 | _, 481 | bounds, 482 | ) = cur.fetchone() 483 | 484 | prev_west, prev_south, prev_east, prev_north = map( 485 | float, bounds.split(",") 486 | ) 487 | new_west = min(west, prev_west) 488 | new_south = min(south, prev_south) 489 | new_east = max(east, prev_east) 490 | new_north = max(north, prev_north) 491 | 492 | cur.execute( 493 | "UPDATE metadata SET value = ? WHERE name = 'bounds';", 494 | ("%f,%f,%f,%f" % (new_west, new_south, new_east, new_north),), 495 | ) 496 | else: 497 | cur.execute( 498 | "CREATE TABLE IF NOT EXISTS tiles " 499 | "(zoom_level integer, tile_column integer, " 500 | "tile_row integer, tile_data blob);" 501 | ) 502 | cur.execute( 503 | "CREATE UNIQUE INDEX idx_zcr ON tiles (zoom_level, tile_column, tile_row);" 504 | ) 505 | cur.execute( 506 | "CREATE TABLE IF NOT EXISTS metadata (name text, value text);" 507 | ) 508 | 509 | cur.execute( 510 | "INSERT INTO metadata (name, value) VALUES (?, ?);", ("name", title) 511 | ) 512 | cur.execute( 513 | "INSERT INTO metadata (name, value) VALUES (?, ?);", 514 | ("type", layer_type), 515 | ) 516 | cur.execute( 517 | "INSERT INTO metadata (name, value) VALUES (?, ?);", 518 | ("version", "1.1"), 519 | ) 520 | cur.execute( 521 | "INSERT INTO metadata (name, value) VALUES (?, ?);", 522 | ("description", description), 523 | ) 524 | cur.execute( 525 | "INSERT INTO metadata (name, value) VALUES (?, ?);", 526 | ("format", img_ext), 527 | ) 528 | cur.execute( 529 | "INSERT INTO metadata (name, value) VALUES ('bounds', ?);", 530 | ("%f,%f,%f,%f" % (west, south, east, north),), 531 | ) 532 | conn.commit() 533 | 534 | def insert_results(tile, contents, img_ext=None, image_dump=None): 535 | """Also a closure.""" 536 | cursor = conn.cursor() 537 | if contents is None: 538 | log.info("Tile %r is empty and will be skipped", tile) 539 | return 540 | 541 | # MBTiles have a different origin than Mercantile/tilebelt. 542 | tiley = int(math.pow(2, tile.z)) - tile.y - 1 543 | 544 | # Optional image dump. 545 | if image_dump: 546 | img_name = "{}-{}-{}.{}".format(tile.x, tiley, tile.z, img_ext) 547 | img_path = os.path.join(image_dump, img_name) 548 | with open(img_path, "wb") as img: 549 | img.write(contents) 550 | 551 | # Insert tile into db. 552 | log.info("Inserting tile: tile=%r", tile) 553 | 554 | cursor.execute( 555 | "INSERT OR REPLACE INTO tiles " 556 | "(zoom_level, tile_column, tile_row, tile_data) " 557 | "VALUES (?, ?, ?, ?);", 558 | (tile.z, tile.x, tiley, sqlite3.Binary(contents)), 559 | ) 560 | 561 | def commit_mbtiles(): 562 | conn.commit() 563 | 564 | if cutline: 565 | 566 | def gen_tiles(): 567 | for zk in range(minzoom, maxzoom + 1): 568 | for arr in supermercado.burntiles.burn(cutline, zk): 569 | # Supermercado's numpy scalars must be cast to 570 | # ints. Python's sqlite module does not do this 571 | # for us. 572 | yield mercantile.Tile(*(int(v) for v in arr)) 573 | 574 | tiles = gen_tiles() 575 | else: 576 | tiles = mercantile.tiles( 577 | west, south, east, north, range(minzoom, maxzoom + 1) 578 | ) 579 | 580 | with conn: 581 | process_tiles( 582 | tiles, 583 | init_mbtiles, 584 | insert_results, 585 | commit_mbtiles, 586 | num_workers=num_workers, 587 | inputfile=inputfile, 588 | base_kwds=base_kwds, 589 | resampling=resampling, 590 | img_ext=img_ext, 591 | image_dump=image_dump, 592 | progress_bar=pbar, 593 | open_options=open_options, 594 | creation_options=creation_options, 595 | warp_options=warp_options, 596 | exclude_empty_tiles=exclude_empty_tiles, 597 | ) 598 | 599 | if pbar is not None: 600 | pbar.update(pbar.total - pbar.n) 601 | -------------------------------------------------------------------------------- /mbtiles/worker.py: -------------------------------------------------------------------------------- 1 | """rio-mbtiles processing worker""" 2 | 3 | import logging 4 | import warnings 5 | 6 | from rasterio.enums import Resampling 7 | from rasterio.io import MemoryFile 8 | from rasterio.transform import from_bounds as transform_from_bounds 9 | from rasterio.warp import reproject, transform_bounds 10 | from rasterio.windows import Window 11 | from rasterio.windows import from_bounds as window_from_bounds 12 | import mercantile 13 | import rasterio 14 | 15 | TILES_CRS = "EPSG:3857" 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | def init_worker( 21 | path, 22 | profile, 23 | resampling_method, 24 | open_opts=None, 25 | warp_opts=None, 26 | creation_opts=None, 27 | exclude_empties=True, 28 | ): 29 | global base_kwds, filename, resampling, open_options, warp_options, creation_options, exclude_empty_tiles 30 | resampling = Resampling[resampling_method] 31 | base_kwds = profile.copy() 32 | filename = path 33 | open_options = open_opts.copy() if open_opts is not None else {} 34 | warp_options = warp_opts.copy() if warp_opts is not None else {} 35 | creation_options = creation_opts.copy() if creation_opts is not None else {} 36 | exclude_empty_tiles = exclude_empties 37 | 38 | 39 | def process_tile(tile): 40 | """Process a single MBTiles tile 41 | 42 | Parameters 43 | ---------- 44 | tile : mercantile.Tile 45 | warp_options : Mapping 46 | GDAL warp options as keyword arguments. 47 | 48 | Returns 49 | ------- 50 | 51 | tile : mercantile.Tile 52 | The input tile. 53 | bytes : bytearray 54 | Image bytes corresponding to the tile. 55 | 56 | """ 57 | global base_kwds, resampling, filename, open_options, warp_options, creation_options, exclude_empty_tiles 58 | 59 | with rasterio.open(filename, **open_options) as src: 60 | 61 | # Get the bounds of the tile. 62 | ulx, uly = mercantile.xy(*mercantile.ul(tile.x, tile.y, tile.z)) 63 | lrx, lry = mercantile.xy(*mercantile.ul(tile.x + 1, tile.y + 1, tile.z)) 64 | 65 | kwds = base_kwds.copy() 66 | kwds.update(**creation_options) 67 | kwds["transform"] = transform_from_bounds( 68 | ulx, lry, lrx, uly, kwds["width"], kwds["height"] 69 | ) 70 | src_nodata = kwds.pop("src_nodata", None) 71 | dst_nodata = kwds.pop("dst_nodata", None) 72 | 73 | src_alpha = None 74 | dst_alpha = None 75 | bindexes = None 76 | 77 | if kwds["count"] == 4: 78 | bindexes = [1, 2, 3] 79 | dst_alpha = 4 80 | 81 | if src.count == 4: 82 | src_alpha = 4 83 | else: 84 | kwds["count"] = 4 85 | else: 86 | bindexes = list(range(1, kwds["count"] + 1)) 87 | 88 | warnings.simplefilter("ignore") 89 | 90 | log.info("Reprojecting tile: tile=%r", tile) 91 | 92 | with MemoryFile() as memfile: 93 | 94 | with memfile.open(**kwds) as tmp: 95 | 96 | # determine window of source raster corresponding to the tile 97 | # image, with small buffer at edges 98 | try: 99 | west, south, east, north = transform_bounds( 100 | TILES_CRS, src.crs, ulx, lry, lrx, uly 101 | ) 102 | tile_window = window_from_bounds( 103 | west, south, east, north, transform=src.transform 104 | ) 105 | adjusted_tile_window = Window( 106 | tile_window.col_off - 1, 107 | tile_window.row_off - 1, 108 | tile_window.width + 2, 109 | tile_window.height + 2, 110 | ) 111 | tile_window = adjusted_tile_window.round_offsets().round_shape() 112 | 113 | # if no data in window, skip processing the tile 114 | if ( 115 | exclude_empty_tiles 116 | and not src.read_masks(1, window=tile_window).any() 117 | ): 118 | return tile, None 119 | 120 | except ValueError: 121 | log.info( 122 | "Tile %r will not be skipped, even if empty. This is harmless.", 123 | tile, 124 | ) 125 | 126 | num_threads = int(warp_options.pop("num_threads", 2)) 127 | 128 | reproject( 129 | rasterio.band(src, bindexes), 130 | rasterio.band(tmp, bindexes), 131 | src_nodata=src_nodata, 132 | dst_nodata=dst_nodata, 133 | src_alpha=src_alpha, 134 | dst_alpha=dst_alpha, 135 | num_threads=num_threads, 136 | resampling=resampling, 137 | **warp_options 138 | ) 139 | 140 | return tile, memfile.read() 141 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | affine==2.2.1 2 | cligj==0.5.0 3 | coveralls==1.5.1 4 | enum34==1.1.6 5 | mercantile==1.1.6 6 | mock; python_version < "3.3" 7 | numpy==1.15.2 8 | pytest<3.9 9 | pytest-cov==2.5.1 10 | coverage==4.5.1 11 | rasterio~=1.0 12 | snuggs==1.4.2 13 | tqdm==4.48.2 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from codecs import open as codecs_open 2 | from setuptools import setup, find_packages 3 | 4 | 5 | # Parse the version from the mbtiles module. 6 | with open('mbtiles/__init__.py') as f: 7 | for line in f: 8 | if line.find("__version__") >= 0: 9 | version = line.split("=")[1].strip() 10 | version = version.strip('"') 11 | version = version.strip("'") 12 | break 13 | 14 | # Get the long description from the relevant file 15 | with codecs_open('README.rst', encoding='utf-8') as f: 16 | long_description = f.read() 17 | 18 | 19 | setup( 20 | name="rio-mbtiles", 21 | version=version, 22 | description=u"A Rasterio plugin command that exports MBTiles", 23 | long_description=long_description, 24 | classifiers=[], 25 | keywords="", 26 | author=u"Sean Gillies", 27 | author_email="sean@mapbox.com", 28 | url="https://github.com/mapbox/rio-mbtiles", 29 | license="MIT", 30 | packages=find_packages(exclude=["ez_setup", "examples", "tests"]), 31 | include_package_data=True, 32 | zip_safe=False, 33 | python_requires=">=2.7.10", 34 | install_requires=[ 35 | "click", 36 | "cligj>=0.5", 37 | "mercantile", 38 | "numpy>=1.10", 39 | "rasterio~=1.0", 40 | "shapely~=1.7.0", 41 | "supermercado", 42 | "tqdm~=4.0", 43 | ], 44 | extras_require={"test": ["coveralls", "pytest", "pytest-cov"]}, 45 | entry_points=""" 46 | [rasterio.rio_plugins] 47 | mbtiles=mbtiles.scripts.cli:mbtiles 48 | """ 49 | ) 50 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import operator 3 | import os 4 | import shutil 5 | import sys 6 | 7 | import py 8 | import pytest 9 | import rasterio 10 | 11 | if sys.version_info > (3,): 12 | reduce = functools.reduce 13 | from unittest import mock 14 | else: 15 | import mock 16 | 17 | test_files = [ 18 | os.path.join(os.path.dirname(__file__), p) 19 | for p in [ 20 | "data/RGB.byte.tif", 21 | "data/RGBA.byte.tif", 22 | "data/rgb-193f513.vrt", 23 | "data/rgb-fa48952.vrt", 24 | ] 25 | ] 26 | 27 | 28 | def pytest_cmdline_main(config): 29 | # Bail if the test raster data is not present. Test data is not 30 | # distributed with sdists since 0.12. 31 | if reduce(operator.and_, map(os.path.exists, test_files)): 32 | print("Test data present.") 33 | else: 34 | print("Test data not present. See download directions in tests/README.txt") 35 | sys.exit(1) 36 | 37 | 38 | @pytest.fixture(scope="function") 39 | def data(tmpdir): 40 | """A temporary directory containing a copy of the files in data.""" 41 | datadir = tmpdir.ensure("tests/data", dir=True) 42 | for filename in test_files: 43 | shutil.copy(filename, str(datadir)) 44 | return datadir 45 | 46 | 47 | @pytest.fixture(scope="function") 48 | def empty_data(tmpdir): 49 | """A temporary directory containing a folder with an empty data file.""" 50 | filename = test_files[0] 51 | out_filename = os.path.join(str(tmpdir), "empty.tif") 52 | with rasterio.open(filename, "r") as src: 53 | with rasterio.open(out_filename, "w", **src.meta) as dst: 54 | pass 55 | return out_filename 56 | 57 | 58 | @pytest.fixture() 59 | def rgba_cutline_path(): 60 | """Path to a GeoJSON rhombus within the extents of RGBA.byte.tif""" 61 | return os.path.join(os.path.dirname(__file__), "data/rgba_cutline.geojson") 62 | 63 | 64 | @pytest.fixture() 65 | def rgba_points_path(): 66 | """Path to a pair of GeoJSON points. This is not a valid cutline.""" 67 | return os.path.join(os.path.dirname(__file__), "data/rgba_points.geojson") 68 | -------------------------------------------------------------------------------- /tests/data/RGB.byte.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/rio-mbtiles/f557854ac42f832e8436ac09582a20691bae90df/tests/data/RGB.byte.tif -------------------------------------------------------------------------------- /tests/data/RGBA.byte.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/rio-mbtiles/f557854ac42f832e8436ac09582a20691bae90df/tests/data/RGBA.byte.tif -------------------------------------------------------------------------------- /tests/data/rgb-193f513.vrt: -------------------------------------------------------------------------------- 1 | 2 | PROJCS["UTM Zone 18, Northern Hemisphere",GEOGCS["Unknown datum based upon the WGS 84 ellipsoid",DATUM["Not_specified_based_on_WGS_84_spheroid",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",-75],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]]] 3 | -1.0618653799999999e+06, 3.0003792667509481e+02, 0.0000000000000000e+00, 3.5412780899999999e+06, 0.0000000000000000e+00, -3.0004178272980499e+02 4 | 5 | 0 6 | Red 7 | 8 | RGB.byte.tif 9 | 1 10 | 11 | 12 | 13 | 0 14 | 15 | 16 | 17 | 0 18 | Green 19 | 20 | RGB.byte.tif 21 | 2 22 | 23 | 24 | 25 | 0 26 | 27 | 28 | 29 | 0 30 | Blue 31 | 32 | RGB.byte.tif 33 | 3 34 | 35 | 36 | 37 | 0 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/data/rgb-fa48952.vrt: -------------------------------------------------------------------------------- 1 | 2 | PROJCS["UTM Zone 18, Northern Hemisphere",GEOGCS["Unknown datum based upon the WGS 84 ellipsoid",DATUM["Not_specified_based_on_WGS_84_spheroid",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",-75],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]]] 3 | 1.1256674000000001e+05, 3.0003792667509481e+02, 0.0000000000000000e+00, 3.5598010899999999e+06, 0.0000000000000000e+00, -3.0004178272980499e+02 4 | 5 | 0 6 | Red 7 | 8 | RGB.byte.tif 9 | 1 10 | 11 | 12 | 13 | 0 14 | 15 | 16 | 17 | 0 18 | Green 19 | 20 | RGB.byte.tif 21 | 2 22 | 23 | 24 | 25 | 0 26 | 27 | 28 | 29 | 0 30 | Blue 31 | 32 | RGB.byte.tif 33 | 3 34 | 35 | 36 | 37 | 0 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/data/rgba_cutline.geojson: -------------------------------------------------------------------------------- 1 | {"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[-76.47171020507812,37.2198034112712],[-76.47891998291016,37.196834728499866],[-76.46072387695312,37.16770367048253],[-76.40974044799805,37.161000570006095],[-76.39875411987305,37.15224460472995],[-76.34931564331053,37.1284341983056],[-76.31103515625,37.139382442337094],[-76.30794525146484,37.16291580223116],[-76.32253646850586,37.197108206316365],[-76.37094497680664,37.23798199321937],[-76.44441604614258,37.22827818273987],[-76.47171020507812,37.2198034112712]]]}},{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[-78.585205078125,24.67946552658519],[-78.22265625,24.410889551000935],[-77.69119262695312,24.69942955501979],[-77.9754638671875,24.992281691278635],[-78.585205078125,24.67946552658519]]]}}]} -------------------------------------------------------------------------------- /tests/data/rgba_points.geojson: -------------------------------------------------------------------------------- 1 | {"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[-77.82028198242188,24.720637830132038]}},{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[-77.607421875,24.165549146828848]}}]} -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | import sys 4 | import warnings 5 | 6 | import click 7 | from click.testing import CliRunner 8 | import pytest 9 | import rasterio 10 | from rasterio.rio.main import main_group 11 | 12 | import mbtiles.scripts.cli 13 | 14 | from conftest import mock 15 | 16 | 17 | def test_cli_help(): 18 | runner = CliRunner() 19 | result = runner.invoke(main_group, ["mbtiles", "--help"]) 20 | assert result.exit_code == 0 21 | assert "Export a dataset to MBTiles (version 1.3)" in result.output 22 | 23 | 24 | @pytest.mark.skipif("sys.version_info >= (3, 7)", reason="Test requires Python < 3.7") 25 | def test_dst_impl_validation(): 26 | """c.f. implementation requires Python >= 3.7""" 27 | runner = CliRunner() 28 | result = runner.invoke( 29 | main_group, ["mbtiles", "--implementation", "cf", "in.tif", "out.mbtiles"] 30 | ) 31 | assert result.exit_code == 2 32 | 33 | 34 | @mock.patch("mbtiles.scripts.cli.rasterio") 35 | def test_dst_nodata_validation(rio): 36 | """--dst-nodata requires source nodata in some form""" 37 | rio.open.return_value.__enter__.return_value.profile.get.return_value = None 38 | runner = CliRunner() 39 | result = runner.invoke( 40 | main_group, ["mbtiles", "--dst-nodata", "0", "in.tif", "out.mbtiles"] 41 | ) 42 | assert result.exit_code == 2 43 | 44 | 45 | @pytest.mark.parametrize("filename", ["RGB.byte.tif", "RGBA.byte.tif"]) 46 | def test_export_metadata(tmpdir, data, filename): 47 | inputfile = str(data.join(filename)) 48 | outputfile = str(tmpdir.join("export.mbtiles")) 49 | runner = CliRunner() 50 | result = runner.invoke(main_group, ["mbtiles", inputfile, outputfile]) 51 | assert result.exit_code == 0 52 | conn = sqlite3.connect(outputfile) 53 | cur = conn.cursor() 54 | cur.execute("select * from metadata where name == 'name'") 55 | assert cur.fetchone()[1] == filename 56 | 57 | 58 | def test_export_overwrite(tmpdir, data): 59 | """Overwrites existing file""" 60 | inputfile = str(data.join("RGB.byte.tif")) 61 | output = tmpdir.join("export.mbtiles") 62 | output.write("lolwut") 63 | outputfile = str(output) 64 | runner = CliRunner() 65 | result = runner.invoke( 66 | main_group, ["mbtiles", "--overwrite", inputfile, outputfile] 67 | ) 68 | assert result.exit_code == 0 69 | conn = sqlite3.connect(outputfile) 70 | cur = conn.cursor() 71 | cur.execute("select * from metadata where name == 'name'") 72 | assert cur.fetchone()[1] == "RGB.byte.tif" 73 | 74 | 75 | def test_export_metadata_output_opt(tmpdir, data): 76 | inputfile = str(data.join("RGB.byte.tif")) 77 | outputfile = str(tmpdir.join("export.mbtiles")) 78 | runner = CliRunner() 79 | result = runner.invoke(main_group, ["mbtiles", inputfile, "-o", outputfile]) 80 | assert result.exit_code == 0 81 | conn = sqlite3.connect(outputfile) 82 | cur = conn.cursor() 83 | cur.execute("select * from metadata where name == 'name'") 84 | assert cur.fetchone()[1] == "RGB.byte.tif" 85 | 86 | 87 | def test_export_tiles(tmpdir, data): 88 | inputfile = str(data.join("RGB.byte.tif")) 89 | outputfile = str(tmpdir.join("export.mbtiles")) 90 | runner = CliRunner() 91 | result = runner.invoke(main_group, ["mbtiles", inputfile, outputfile]) 92 | assert result.exit_code == 0 93 | conn = sqlite3.connect(outputfile) 94 | cur = conn.cursor() 95 | cur.execute("select * from tiles") 96 | assert len(cur.fetchall()) == 6 97 | 98 | 99 | def test_export_zoom(tmpdir, data): 100 | inputfile = str(data.join("RGB.byte.tif")) 101 | outputfile = str(tmpdir.join("export.mbtiles")) 102 | runner = CliRunner() 103 | result = runner.invoke( 104 | main_group, ["mbtiles", inputfile, outputfile, "--zoom-levels", "6..7"] 105 | ) 106 | assert result.exit_code == 0 107 | conn = sqlite3.connect(outputfile) 108 | cur = conn.cursor() 109 | cur.execute("select * from tiles") 110 | assert len(cur.fetchall()) == 6 111 | 112 | 113 | def test_export_jobs(tmpdir, data): 114 | inputfile = str(data.join("RGB.byte.tif")) 115 | outputfile = str(tmpdir.join("export.mbtiles")) 116 | runner = CliRunner() 117 | result = runner.invoke(main_group, ["mbtiles", inputfile, outputfile, "-j", "4"]) 118 | assert result.exit_code == 0 119 | conn = sqlite3.connect(outputfile) 120 | cur = conn.cursor() 121 | cur.execute("select * from tiles") 122 | assert len(cur.fetchall()) == 6 123 | 124 | 125 | def test_export_src_nodata(tmpdir, data): 126 | inputfile = str(data.join("RGB.byte.tif")) 127 | outputfile = str(tmpdir.join("export.mbtiles")) 128 | runner = CliRunner() 129 | result = runner.invoke( 130 | main_group, 131 | ["mbtiles", inputfile, outputfile, "--src-nodata", "0", "--dst-nodata", "0"], 132 | ) 133 | assert result.exit_code == 0 134 | conn = sqlite3.connect(outputfile) 135 | cur = conn.cursor() 136 | cur.execute("select * from tiles") 137 | assert len(cur.fetchall()) == 6 138 | 139 | 140 | def test_export_dump(tmpdir, data): 141 | inputfile = str(data.join("RGB.byte.tif")) 142 | outputfile = str(tmpdir.join("export.mbtiles")) 143 | dumpdir = tmpdir.ensure("dump", dir=True) 144 | runner = CliRunner() 145 | result = runner.invoke( 146 | main_group, ["mbtiles", inputfile, outputfile, "--image-dump", str(dumpdir)] 147 | ) 148 | assert result.exit_code == 0 149 | assert len(os.listdir(str(dumpdir))) == 6 150 | 151 | 152 | @pytest.mark.parametrize("tile_size", [256, 512]) 153 | def test_export_tile_size(tmpdir, data, tile_size): 154 | inputfile = str(data.join("RGB.byte.tif")) 155 | outputfile = str(tmpdir.join("export.mbtiles")) 156 | dumpdir = tmpdir.ensure("dump", dir=True) 157 | runner = CliRunner() 158 | result = runner.invoke( 159 | main_group, 160 | [ 161 | "mbtiles", 162 | inputfile, 163 | outputfile, 164 | "--image-dump", 165 | str(dumpdir), 166 | "--tile-size", 167 | tile_size, 168 | ], 169 | ) 170 | dump_files = os.listdir(str(dumpdir)) 171 | assert result.exit_code == 0 172 | warnings.simplefilter("ignore") 173 | with rasterio.open(os.path.join(str(dumpdir), dump_files[0]), "r") as src: 174 | assert src.shape == (tile_size, tile_size) 175 | warnings.resetwarnings() 176 | 177 | 178 | def test_export_bilinear(tmpdir, data): 179 | inputfile = str(data.join("RGB.byte.tif")) 180 | outputfile = str(tmpdir.join("export.mbtiles")) 181 | runner = CliRunner() 182 | result = runner.invoke( 183 | main_group, ["mbtiles", inputfile, outputfile, "--resampling", "bilinear"] 184 | ) 185 | assert result.exit_code == 0 186 | conn = sqlite3.connect(outputfile) 187 | cur = conn.cursor() 188 | cur.execute("select * from tiles") 189 | assert len(cur.fetchall()) == 6 190 | 191 | 192 | def test_skip_empty(tmpdir, empty_data): 193 | """This file has the same shape as RGB.byte.tif, but no data.""" 194 | inputfile = empty_data 195 | outputfile = str(tmpdir.join("export.mbtiles")) 196 | runner = CliRunner() 197 | result = runner.invoke(main_group, ["mbtiles", inputfile, outputfile]) 198 | assert result.exit_code == 0 199 | conn = sqlite3.connect(outputfile) 200 | cur = conn.cursor() 201 | cur.execute("select * from tiles") 202 | assert len(cur.fetchall()) == 0 203 | 204 | 205 | def test_include_empty(tmpdir, empty_data): 206 | """This file has the same shape as RGB.byte.tif, but no data.""" 207 | inputfile = empty_data 208 | outputfile = str(tmpdir.join("export.mbtiles")) 209 | runner = CliRunner() 210 | result = runner.invoke( 211 | main_group, ["mbtiles", "--include-empty-tiles", inputfile, outputfile] 212 | ) 213 | assert result.exit_code == 0 214 | conn = sqlite3.connect(outputfile) 215 | cur = conn.cursor() 216 | cur.execute("select * from tiles") 217 | assert len(cur.fetchall()) == 6 218 | 219 | 220 | def test_invalid_format_rgba(tmpdir, empty_data): 221 | """--format JPEG --rgba is not allowed""" 222 | inputfile = empty_data 223 | outputfile = str(tmpdir.join("export.mbtiles")) 224 | runner = CliRunner() 225 | result = runner.invoke( 226 | main_group, ["mbtiles", "--format", "JPEG", "--rgba", inputfile, outputfile] 227 | ) 228 | assert result.exit_code == 2 229 | 230 | 231 | @pytest.mark.parametrize("filename", ["RGBA.byte.tif", "RGB.byte.tif"]) 232 | def test_rgba_png(tmpdir, data, filename): 233 | inputfile = str(data.join(filename)) 234 | outputfile = str(tmpdir.join("export.mbtiles")) 235 | runner = CliRunner() 236 | result = runner.invoke( 237 | main_group, ["mbtiles", "--rgba", "--format", "PNG", inputfile, outputfile] 238 | ) 239 | assert result.exit_code == 0 240 | conn = sqlite3.connect(outputfile) 241 | cur = conn.cursor() 242 | cur.execute("select * from metadata where name == 'name'") 243 | assert cur.fetchone()[1] == filename 244 | 245 | 246 | @pytest.mark.parametrize( 247 | "minzoom,maxzoom,exp_num_tiles,source", 248 | [ 249 | (4, 10, 70, "RGB.byte.tif"), 250 | (6, 7, 6, "RGB.byte.tif"), 251 | (4, 10, 12, "rgb-193f513.vrt"), 252 | (4, 10, 69, "rgb-fa48952.vrt"), 253 | ], 254 | ) 255 | @pytest.mark.parametrize( 256 | "impl", 257 | [ 258 | pytest.param( 259 | "cf", 260 | marks=pytest.mark.skipif( 261 | sys.version_info < (3, 7), 262 | reason="c.f. implementation requires Python >= 3.7", 263 | ), 264 | ), 265 | "mp", 266 | ], 267 | ) 268 | def test_export_count(tmpdir, data, minzoom, maxzoom, exp_num_tiles, source, impl): 269 | inputfile = str(data.join(source)) 270 | outputfile = str(tmpdir.join("export.mbtiles")) 271 | runner = CliRunner() 272 | result = runner.invoke( 273 | main_group, 274 | [ 275 | "mbtiles", 276 | "--implementation", 277 | impl, 278 | "--zoom-levels", 279 | "{}..{}".format(minzoom, maxzoom), 280 | inputfile, 281 | outputfile, 282 | ], 283 | ) 284 | assert result.exit_code == 0 285 | conn = sqlite3.connect(outputfile) 286 | cur = conn.cursor() 287 | cur.execute("select * from tiles") 288 | results = cur.fetchall() 289 | assert len(results) == exp_num_tiles 290 | 291 | 292 | @pytest.mark.parametrize("filename", ["RGBA.byte.tif"]) 293 | @pytest.mark.parametrize( 294 | "impl", 295 | [ 296 | pytest.param( 297 | "cf", 298 | marks=pytest.mark.skipif( 299 | sys.version_info < (3, 7), 300 | reason="c.f. implementation requires Python >= 3.7", 301 | ), 302 | ), 303 | "mp", 304 | ], 305 | ) 306 | def test_progress_bar(tmpdir, data, impl, filename): 307 | inputfile = str(data.join(filename)) 308 | outputfile = str(tmpdir.join("export.mbtiles")) 309 | runner = CliRunner() 310 | result = runner.invoke( 311 | main_group, 312 | [ 313 | "mbtiles", 314 | "-#", 315 | "--implementation", 316 | impl, 317 | "--zoom-levels", 318 | "4..11", 319 | "--rgba", 320 | "--format", 321 | "PNG", 322 | inputfile, 323 | outputfile, 324 | ], 325 | ) 326 | assert result.exit_code == 0 327 | assert "100%" in result.output 328 | 329 | 330 | @pytest.mark.parametrize( 331 | "minzoom,maxzoom,exp_num_tiles,sources", 332 | [(4, 10, 70, ["rgb-193f513.vrt", "rgb-fa48952.vrt"])], 333 | ) 334 | @pytest.mark.parametrize( 335 | "impl", 336 | [ 337 | pytest.param( 338 | "cf", 339 | marks=pytest.mark.skipif( 340 | sys.version_info < (3, 7), 341 | reason="c.f. implementation requires Python >= 3.7", 342 | ), 343 | ), 344 | "mp", 345 | ], 346 | ) 347 | def test_appending_export_count( 348 | tmpdir, data, minzoom, maxzoom, exp_num_tiles, sources, impl 349 | ): 350 | """Appending adds to the tileset but does not duplicate any""" 351 | inputfiles = [str(data.join(source)) for source in sources] 352 | outputfile = str(tmpdir.join("export.mbtiles")) 353 | runner = CliRunner() 354 | result = runner.invoke( 355 | main_group, 356 | [ 357 | "mbtiles", 358 | "--implementation", 359 | impl, 360 | "--zoom-levels", 361 | "{}..{}".format(minzoom, maxzoom), 362 | inputfiles[0], 363 | outputfile, 364 | ], 365 | ) 366 | assert result.exit_code == 0 367 | 368 | conn = sqlite3.connect(outputfile) 369 | cur = conn.cursor() 370 | cur.execute("select * from tiles") 371 | results = cur.fetchall() 372 | assert len(results) == 12 373 | 374 | result = runner.invoke( 375 | main_group, 376 | [ 377 | "mbtiles", 378 | "--append", 379 | "--implementation", 380 | impl, 381 | "--zoom-levels", 382 | "{}..{}".format(minzoom, maxzoom), 383 | inputfiles[1], 384 | outputfile, 385 | ], 386 | ) 387 | assert result.exit_code == 0 388 | 389 | conn = sqlite3.connect(outputfile) 390 | cur = conn.cursor() 391 | cur.execute("select * from tiles") 392 | results = cur.fetchall() 393 | assert len(results) == exp_num_tiles 394 | 395 | 396 | @pytest.mark.parametrize("inputfiles", [[], ["a.tif", "b.tif"]]) 397 | def test_input_required(inputfiles): 398 | """We require exactly one input file""" 399 | runner = CliRunner() 400 | result = runner.invoke(main_group, ["mbtiles"] + inputfiles + ["foo.mbtiles"]) 401 | assert result.exit_code == 2 402 | 403 | 404 | def test_append_or_overwrite_required(tmpdir): 405 | """If the output files exists --append or --overwrite is required""" 406 | outputfile = tmpdir.join("export.mbtiles") 407 | outputfile.ensure() 408 | runner = CliRunner() 409 | result = runner.invoke(main_group, ["mbtiles", "a.tif", str(outputfile)]) 410 | assert result.exit_code == 1 411 | 412 | 413 | @pytest.mark.parametrize("filename", ["RGBA.byte.tif"]) 414 | @pytest.mark.parametrize( 415 | "impl", 416 | [ 417 | pytest.param( 418 | "cf", 419 | marks=pytest.mark.skipif( 420 | sys.version_info < (3, 7), 421 | reason="c.f. implementation requires Python >= 3.7", 422 | ), 423 | ), 424 | "mp", 425 | ], 426 | ) 427 | def test_cutline_progress_bar(tmpdir, data, rgba_cutline_path, impl, filename): 428 | """rio-mbtiles accepts and uses a cutline""" 429 | inputfile = str(data.join(filename)) 430 | outputfile = str(tmpdir.join("export.mbtiles")) 431 | runner = CliRunner() 432 | result = runner.invoke( 433 | main_group, 434 | [ 435 | "mbtiles", 436 | "-#", 437 | "--implementation", 438 | impl, 439 | "--zoom-levels", 440 | "4..11", 441 | "--rgba", 442 | "--format", 443 | "PNG", 444 | "--cutline", 445 | rgba_cutline_path, 446 | inputfile, 447 | outputfile, 448 | ], 449 | ) 450 | assert result.exit_code == 0 451 | assert "100%" in result.output 452 | 453 | 454 | @pytest.mark.parametrize("filename", ["RGBA.byte.tif"]) 455 | @pytest.mark.parametrize( 456 | "impl", 457 | [ 458 | pytest.param( 459 | "cf", 460 | marks=pytest.mark.skipif( 461 | sys.version_info < (3, 7), 462 | reason="c.f. implementation requires Python >= 3.7", 463 | ), 464 | ), 465 | "mp", 466 | ], 467 | ) 468 | def test_invalid_cutline(tmpdir, data, rgba_points_path, impl, filename): 469 | """Points cannot serve as a cutline""" 470 | inputfile = str(data.join(filename)) 471 | outputfile = str(tmpdir.join("export.mbtiles")) 472 | runner = CliRunner() 473 | result = runner.invoke( 474 | main_group, 475 | [ 476 | "mbtiles", 477 | "-#", 478 | "--implementation", 479 | impl, 480 | "--zoom-levels", 481 | "4..11", 482 | "--rgba", 483 | "--format", 484 | "PNG", 485 | "--cutline", 486 | rgba_points_path, 487 | inputfile, 488 | outputfile, 489 | ], 490 | ) 491 | assert result.exit_code == 1 492 | 493 | 494 | @pytest.mark.parametrize(("source", "quadkey", "zooms", "exp_num_results"), [("RGB.byte.tif", "0320", "4..4", 1), ("RGB.byte.tif", "032022", "6..6", 0)]) 495 | def test_covers(tmpdir, data, source, quadkey, zooms, exp_num_results): 496 | inputfile = str(data.join(source)) 497 | outputfile = str(tmpdir.join("export.mbtiles")) 498 | runner = CliRunner() 499 | result = runner.invoke( 500 | main_group, 501 | [ 502 | "mbtiles", 503 | "--zoom-levels", 504 | zooms, 505 | "--covers", 506 | quadkey, 507 | inputfile, 508 | outputfile, 509 | ], 510 | ) 511 | assert result.exit_code == 0 512 | conn = sqlite3.connect(outputfile) 513 | cur = conn.cursor() 514 | cur.execute("select * from tiles") 515 | results = cur.fetchall() 516 | assert len(results) == exp_num_results 517 | -------------------------------------------------------------------------------- /tests/test_mod.py: -------------------------------------------------------------------------------- 1 | """Module tests""" 2 | 3 | from mercantile import Tile 4 | import pytest 5 | 6 | import mbtiles.worker 7 | 8 | 9 | @pytest.mark.parametrize("tile", [Tile(36, 73, 7), Tile(0, 0, 0), Tile(1, 1, 1)]) 10 | @pytest.mark.parametrize("filename", ["RGB.byte.tif", "RGBA.byte.tif"]) 11 | def test_process_tile(data, filename, tile): 12 | sourcepath = str(data.join(filename)) 13 | mbtiles.worker.init_worker( 14 | sourcepath, 15 | { 16 | "driver": "PNG", 17 | "dtype": "uint8", 18 | "nodata": 0, 19 | "height": 256, 20 | "width": 256, 21 | "count": 3, 22 | "crs": "EPSG:3857", 23 | }, 24 | "nearest", 25 | {}, 26 | {}, 27 | ) 28 | t, contents = mbtiles.worker.process_tile(tile) 29 | assert t.x == tile.x 30 | assert t.y == tile.y 31 | assert t.z == tile.z 32 | --------------------------------------------------------------------------------