adafruit-circuitpython-maptiles 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adafruit_circuitpython_maptiles-1.0.0.dist-info/METADATA +180 -0
- adafruit_circuitpython_maptiles-1.0.0.dist-info/RECORD +9 -0
- adafruit_circuitpython_maptiles-1.0.0.dist-info/WHEEL +5 -0
- adafruit_circuitpython_maptiles-1.0.0.dist-info/licenses/LICENSE +21 -0
- adafruit_circuitpython_maptiles-1.0.0.dist-info/top_level.txt +1 -0
- adafruit_maptiles/__init__.py +104 -0
- adafruit_maptiles/cache.py +286 -0
- adafruit_maptiles/map_view.py +509 -0
- adafruit_maptiles/png.py +156 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: adafruit-circuitpython-maptiles
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Download and display map tiles from XYZ tile servers such as Open Street Maps. Tiles are cached on SD card.
|
|
5
|
+
Author-email: Adafruit Industries <circuitpython@adafruit.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/adafruit/Adafruit_CircuitPython_MapTiles
|
|
8
|
+
Keywords: adafruit,blinka,circuitpython,micropython,maptiles,slippy,map,tiles,maptiles,maptile,slippymap,openstreetmap,osm
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
11
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
12
|
+
Classifier: Topic :: System :: Hardware
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Description-Content-Type: text/x-rst
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: Adafruit-Blinka
|
|
17
|
+
Requires-Dist: adafruit-circuitpython-requests
|
|
18
|
+
Requires-Dist: adafruit-circuitpython-connectionmanager
|
|
19
|
+
Provides-Extra: optional
|
|
20
|
+
Requires-Dist: adafruit-circuitpython-esp32spi; extra == "optional"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
Introduction
|
|
24
|
+
============
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
.. image:: https://readthedocs.org/projects/adafruit-circuitpython-maptiles/badge/?version=latest
|
|
28
|
+
:target: https://docs.circuitpython.org/projects/maptiles/en/latest/
|
|
29
|
+
:alt: Documentation Status
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
.. image:: https://raw.githubusercontent.com/adafruit/Adafruit_CircuitPython_Bundle/main/badges/adafruit_discord.svg
|
|
33
|
+
:target: https://adafru.it/discord
|
|
34
|
+
:alt: Discord
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
.. image:: https://github.com/adafruit/Adafruit_CircuitPython_MapTiles/workflows/Build%20CI/badge.svg
|
|
38
|
+
:target: https://github.com/adafruit/Adafruit_CircuitPython_MapTiles/actions
|
|
39
|
+
:alt: Build Status
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
|
|
43
|
+
:target: https://github.com/astral-sh/ruff
|
|
44
|
+
:alt: Code Style: Ruff
|
|
45
|
+
|
|
46
|
+
Slippy-map (OpenStreetMap style) tiles for CircuitPython displays: a map
|
|
47
|
+
widget, and a tile cache on the SD card that every program using this
|
|
48
|
+
library shares.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
Dependencies
|
|
52
|
+
=============
|
|
53
|
+
This driver depends on:
|
|
54
|
+
|
|
55
|
+
* `Adafruit CircuitPython <https://github.com/adafruit/circuitpython>`_
|
|
56
|
+
|
|
57
|
+
Please ensure all dependencies are available on the CircuitPython filesystem.
|
|
58
|
+
This is easily achieved by downloading
|
|
59
|
+
`the Adafruit library and driver bundle <https://circuitpython.org/libraries>`_
|
|
60
|
+
or individual libraries can be installed using
|
|
61
|
+
`circup <https://github.com/adafruit/circup>`_.
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
`Adafruit Fruit Jam <https://www.adafruit.com/product/6200>`_
|
|
65
|
+
|
|
66
|
+
Installing from PyPI
|
|
67
|
+
=====================
|
|
68
|
+
|
|
69
|
+
On supported GNU/Linux systems like the Raspberry Pi, you can install the driver locally `from
|
|
70
|
+
PyPI <https://pypi.org/project/adafruit-circuitpython-maptiles/>`_.
|
|
71
|
+
To install for current user:
|
|
72
|
+
|
|
73
|
+
.. code-block:: shell
|
|
74
|
+
|
|
75
|
+
pip3 install adafruit-circuitpython-maptiles
|
|
76
|
+
|
|
77
|
+
To install system-wide (this may be required in some cases):
|
|
78
|
+
|
|
79
|
+
.. code-block:: shell
|
|
80
|
+
|
|
81
|
+
sudo pip3 install adafruit-circuitpython-maptiles
|
|
82
|
+
|
|
83
|
+
To install in a virtual environment in your current project:
|
|
84
|
+
|
|
85
|
+
.. code-block:: shell
|
|
86
|
+
|
|
87
|
+
mkdir project-name && cd project-name
|
|
88
|
+
python3 -m venv .venv
|
|
89
|
+
source .env/bin/activate
|
|
90
|
+
pip3 install adafruit-circuitpython-maptiles
|
|
91
|
+
|
|
92
|
+
Installing to a Connected CircuitPython Device with Circup
|
|
93
|
+
==========================================================
|
|
94
|
+
|
|
95
|
+
Make sure that you have ``circup`` installed in your Python environment.
|
|
96
|
+
Install it with the following command if necessary:
|
|
97
|
+
|
|
98
|
+
.. code-block:: shell
|
|
99
|
+
|
|
100
|
+
pip3 install circup
|
|
101
|
+
|
|
102
|
+
With ``circup`` installed and your CircuitPython device connected use the
|
|
103
|
+
following command to install:
|
|
104
|
+
|
|
105
|
+
.. code-block:: shell
|
|
106
|
+
|
|
107
|
+
circup install adafruit_maptiles
|
|
108
|
+
|
|
109
|
+
Or the following command to update an existing version:
|
|
110
|
+
|
|
111
|
+
.. code-block:: shell
|
|
112
|
+
|
|
113
|
+
circup update
|
|
114
|
+
|
|
115
|
+
Usage Example
|
|
116
|
+
=============
|
|
117
|
+
|
|
118
|
+
.. code-block:: python
|
|
119
|
+
|
|
120
|
+
import sys
|
|
121
|
+
|
|
122
|
+
import supervisor
|
|
123
|
+
from adafruit_fruitjam.peripherals import request_display_config
|
|
124
|
+
|
|
125
|
+
from adafruit_maptiles.cache import TileCache
|
|
126
|
+
from adafruit_maptiles.map_view import MapView
|
|
127
|
+
|
|
128
|
+
CENTRE = (40.7128, -74.0060) # (lat, lon)
|
|
129
|
+
ZOOM = 12
|
|
130
|
+
|
|
131
|
+
request_display_config(320, 240, color_depth=8)
|
|
132
|
+
display = supervisor.runtime.display
|
|
133
|
+
display.auto_refresh = False
|
|
134
|
+
|
|
135
|
+
# Name your program in the User-Agent: the OpenStreetMap tile servers ask for it.
|
|
136
|
+
cache = TileCache(user_agent="maptiles_simpletest/1.0 CircuitPython")
|
|
137
|
+
view = MapView(display.width, display.height, CENTRE, ZOOM, cache=cache)
|
|
138
|
+
display.root_group = view
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def show():
|
|
142
|
+
view.update(progress=lambda done, total: print(f"tile {done + 1} of {total}"))
|
|
143
|
+
if view.net_error:
|
|
144
|
+
print("Download failed:", view.net_error)
|
|
145
|
+
view.redraw() # the tiles and the scale bar and credit; draw markers before dirty()
|
|
146
|
+
lat, lon = view.centre
|
|
147
|
+
x, y = view.xy(lat, lon)
|
|
148
|
+
view.cross(x, y)
|
|
149
|
+
view.dirty()
|
|
150
|
+
display.refresh()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
show()
|
|
154
|
+
while True:
|
|
155
|
+
if supervisor.runtime.serial_bytes_available:
|
|
156
|
+
key = sys.stdin.read(1)
|
|
157
|
+
if key in "wasd":
|
|
158
|
+
view.pan(*{"w": (0, -0.25), "a": (-0.25, 0), "s": (0, 0.25), "d": (0.25, 0)}[key])
|
|
159
|
+
elif key in "+=":
|
|
160
|
+
view.zoom_in()
|
|
161
|
+
elif key == "-":
|
|
162
|
+
view.zoom_out()
|
|
163
|
+
else:
|
|
164
|
+
continue
|
|
165
|
+
show()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
Documentation
|
|
169
|
+
=============
|
|
170
|
+
API documentation for this library can be found on `Read the Docs <https://docs.circuitpython.org/projects/maptiles/en/latest/>`_.
|
|
171
|
+
|
|
172
|
+
For information on building library documentation, please check out
|
|
173
|
+
`this guide <https://learn.adafruit.com/creating-and-sharing-a-circuitpython-library/sharing-our-docs-on-readthedocs#sphinx-5-1>`_.
|
|
174
|
+
|
|
175
|
+
Contributing
|
|
176
|
+
============
|
|
177
|
+
|
|
178
|
+
Contributions are welcome! Please read our `Code of Conduct
|
|
179
|
+
<https://github.com/adafruit/Adafruit_CircuitPython_MapTiles/blob/HEAD/CODE_OF_CONDUCT.md>`_
|
|
180
|
+
before contributing to help this project stay welcoming.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
adafruit_circuitpython_maptiles-1.0.0.dist-info/licenses/LICENSE,sha256=IrDmGxfX4xdm-jZka6acotFMss63SMR3pHWyFp7jA3o,1100
|
|
2
|
+
adafruit_maptiles/__init__.py,sha256=bbBnTFO_Ss1FLKVNVjEnzh61hDmi1vYGfmoZN7E-d_s,3458
|
|
3
|
+
adafruit_maptiles/cache.py,sha256=h2jaWt4F60mh05NAok4-TjG2X3mYG4Jjf4sVvz6Dzgk,10253
|
|
4
|
+
adafruit_maptiles/map_view.py,sha256=NieAEcd44V-s7fOW8IooO5Vr2T7sCLFJKK-So1rq-MQ,19465
|
|
5
|
+
adafruit_maptiles/png.py,sha256=nThOWXVxWj6ESHqqq05Pir6cXwz1CibvGdj32kQMjKY,5728
|
|
6
|
+
adafruit_circuitpython_maptiles-1.0.0.dist-info/METADATA,sha256=TN20_mNsPYWQzp7G3uk9Qt7wIZtxQFySbX1VQGxWWl8,5905
|
|
7
|
+
adafruit_circuitpython_maptiles-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
adafruit_circuitpython_maptiles-1.0.0.dist-info/top_level.txt,sha256=2G_NVbS9ck3MTtwBqfntHAxIf-8c9rLG9eJNpLtED6o,18
|
|
9
|
+
adafruit_circuitpython_maptiles-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tim Cocks for Adafruit Industries
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
adafruit_maptiles
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""
|
|
5
|
+
`adafruit_maptiles`
|
|
6
|
+
================================================================================
|
|
7
|
+
|
|
8
|
+
Slippy-map (OpenStreetMap style) tiles for CircuitPython displays: a map
|
|
9
|
+
widget, and a tile cache on the SD card that every program using this
|
|
10
|
+
library shares.
|
|
11
|
+
|
|
12
|
+
* Author(s): Tim Cocks
|
|
13
|
+
|
|
14
|
+
The package:
|
|
15
|
+
|
|
16
|
+
* :mod:`adafruit_maptiles` (this module): the tile maths (Web Mercator,
|
|
17
|
+
256x256 tiles, ``{z}/{x}/{y}``) and the RGB332 colours the cached tiles
|
|
18
|
+
are stored in. Plain Python.
|
|
19
|
+
* :mod:`adafruit_maptiles.png`: PNG -> 8-bit RGB332 BMP conversion. Plain
|
|
20
|
+
Python (``zlib``).
|
|
21
|
+
* :mod:`adafruit_maptiles.cache`: :class:`~adafruit_maptiles.cache.TileCache`,
|
|
22
|
+
the shared cache (``/sd/tiles/<source>/{z}/{x}/{y}.bmp``), downloads, and
|
|
23
|
+
mounting the SD card.
|
|
24
|
+
* :mod:`adafruit_maptiles.map_view`: :class:`~adafruit_maptiles.map_view.MapView`,
|
|
25
|
+
a :class:`displayio.Group` showing the map, with helpers for drawing on
|
|
26
|
+
it.
|
|
27
|
+
|
|
28
|
+
Implementation Notes
|
|
29
|
+
--------------------
|
|
30
|
+
|
|
31
|
+
**Hardware:**
|
|
32
|
+
|
|
33
|
+
* A board with an SD card slot (``board.SD_*`` pins, or a card mounted at
|
|
34
|
+
``/sd`` by the program) and a network connection, e.g. the
|
|
35
|
+
`Adafruit Fruit Jam <https://www.adafruit.com/product/6200>`_.
|
|
36
|
+
|
|
37
|
+
**Software and Dependencies:**
|
|
38
|
+
|
|
39
|
+
* Adafruit CircuitPython firmware for the supported boards:
|
|
40
|
+
https://circuitpython.org/downloads
|
|
41
|
+
* Adafruit's Requests library: https://github.com/adafruit/Adafruit_CircuitPython_Requests
|
|
42
|
+
* Adafruit's Connection Manager library:
|
|
43
|
+
https://github.com/adafruit/Adafruit_CircuitPython_ConnectionManager
|
|
44
|
+
* Adafruit's ESP32SPI library (boards with an ESP32 co-processor, such as
|
|
45
|
+
the Fruit Jam): https://github.com/adafruit/Adafruit_CircuitPython_ESP32SPI
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import math
|
|
49
|
+
|
|
50
|
+
__version__ = "1.0.0"
|
|
51
|
+
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MapTiles.git"
|
|
52
|
+
|
|
53
|
+
TILE = 256
|
|
54
|
+
"""Tile width and height, pixels."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ----------------------------------------------------------------- maths
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def world_x(lon, zoom):
|
|
61
|
+
"""Longitude -> x in pixels on the whole map at this zoom."""
|
|
62
|
+
return (lon + 180.0) / 360.0 * (TILE << zoom)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def mercator(lat):
|
|
66
|
+
"""Latitude -> Mercator y, -0.5 (85.05N) .. 0.5 (85.05S)."""
|
|
67
|
+
lat = max(-85.05, min(85.05, lat))
|
|
68
|
+
s = math.sin(math.radians(lat))
|
|
69
|
+
return -math.log((1 + s) / (1 - s)) / (4 * math.pi)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def unmercator(y):
|
|
73
|
+
"""Mercator y -> latitude (:func:`mercator`'s inverse)."""
|
|
74
|
+
k = math.exp(-4 * math.pi * max(-0.5, min(0.5, y)))
|
|
75
|
+
return math.degrees(math.asin((k - 1) / (k + 1)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def world_y(lat, zoom):
|
|
79
|
+
"""Latitude -> y in pixels on the whole map at this zoom."""
|
|
80
|
+
return (0.5 + mercator(lat)) * (TILE << zoom)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def tile_xy(lat, lon, zoom):
|
|
84
|
+
"""The ``(x, y)`` numbers of the tile that (lat, lon) is on."""
|
|
85
|
+
n = 1 << zoom
|
|
86
|
+
return int(world_x(lon, zoom)) // TILE % n, min(n - 1, int(world_y(lat, zoom)) // TILE)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def metres_per_pixel(lat, zoom):
|
|
90
|
+
"""Ground distance across one map pixel at this latitude and zoom."""
|
|
91
|
+
return 156543.034 * math.cos(math.radians(lat)) / (1 << zoom)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------ colours
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def rgb332(r, g, b):
|
|
98
|
+
"""8-bit r, g, b -> an RGB332 value (the cached tiles' pixels)."""
|
|
99
|
+
return (r & 0xE0) | (g & 0xE0) >> 3 | b >> 6
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def rgb332_colour(v):
|
|
103
|
+
"""RGB332 value -> 0xRRGGBB."""
|
|
104
|
+
return ((v >> 5) * 255 // 7) << 16 | ((v >> 2 & 7) * 255 // 7) << 8 | (v & 3) * 85
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""
|
|
5
|
+
`adafruit_maptiles.cache`
|
|
6
|
+
================================================================================
|
|
7
|
+
|
|
8
|
+
The tile cache on the SD card, shared by every program that uses this
|
|
9
|
+
library: a tile downloaded once (by any of them) is there for all of
|
|
10
|
+
them. Each tile source has its own folder, in the source's own
|
|
11
|
+
``{z}/{x}/{y}`` layout::
|
|
12
|
+
|
|
13
|
+
/sd/tiles/osm/9/122/196.png as downloaded
|
|
14
|
+
/sd/tiles/osm/9/122/196.bmp the same, 8-bit RGB332, rows top-down
|
|
15
|
+
|
|
16
|
+
Only the BMP is read to draw the map (:mod:`adafruit_maptiles.png`).
|
|
17
|
+
Downloads and conversions each take seconds, so they happen once per
|
|
18
|
+
tile; files are written under a temporary name and renamed when complete,
|
|
19
|
+
so a reset part way leaves no broken tile. Tiles copied to the card from
|
|
20
|
+
a PC (PNG or BMP, same layout) are used as they are.
|
|
21
|
+
|
|
22
|
+
The default source is OpenStreetMap's standard tile server. Its usage
|
|
23
|
+
policy (https://operations.osmfoundation.org/policies/tiles/) asks for a
|
|
24
|
+
User-Agent that identifies the program, that downloaded tiles are kept
|
|
25
|
+
(which the cache does) and that the map shows "(C) OpenStreetMap
|
|
26
|
+
contributors" (:class:`~adafruit_maptiles.map_view.MapView` does). Pass
|
|
27
|
+
your program's name as ``user_agent``.
|
|
28
|
+
|
|
29
|
+
Plain Python apart from mounting the card and downloading.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
|
|
34
|
+
from adafruit_maptiles import TILE
|
|
35
|
+
from adafruit_maptiles.png import png_to_bmp
|
|
36
|
+
|
|
37
|
+
CACHE_ROOT = "/sd/tiles"
|
|
38
|
+
"""Where every program's tiles go, a folder per source."""
|
|
39
|
+
|
|
40
|
+
USER_AGENT = "adafruit_maptiles/1.0 CircuitPython"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TileSource:
|
|
44
|
+
"""Where tiles come from.
|
|
45
|
+
|
|
46
|
+
:param str name: the cache folder's name (under ``CACHE_ROOT``); one per
|
|
47
|
+
tile style, shared by everything that uses that style
|
|
48
|
+
:param str url: the tile URL, with ``{z}``, ``{x}`` and ``{y}``
|
|
49
|
+
:param str credit: the attribution that must be shown on the map
|
|
50
|
+
:param int max_zoom: the closest zoom the server has
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, name, url, credit, max_zoom=19):
|
|
54
|
+
self.name = name
|
|
55
|
+
self.url = url
|
|
56
|
+
self.credit = credit
|
|
57
|
+
self.max_zoom = max_zoom
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
OSM = TileSource(
|
|
61
|
+
"osm", "https://tile.openstreetmap.org/{z}/{x}/{y}.png", "(C) OpenStreetMap contributors"
|
|
62
|
+
)
|
|
63
|
+
"""OpenStreetMap's standard tiles (the default)."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _exists(path):
|
|
67
|
+
try:
|
|
68
|
+
os.stat(path)
|
|
69
|
+
return True
|
|
70
|
+
except OSError:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _makedirs(path):
|
|
75
|
+
at = ""
|
|
76
|
+
for part in path.strip("/").split("/"):
|
|
77
|
+
at += "/" + part
|
|
78
|
+
try:
|
|
79
|
+
os.mkdir(at)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def mount_sd(path="/sd"):
|
|
85
|
+
"""True if a card is mounted at path, mounting the board's SD card
|
|
86
|
+
(``board.SD_SCK``, ``SD_MOSI``, ``SD_MISO``, ``SD_CS``, or ``board.SPI()``
|
|
87
|
+
and ``SD_CS`` on boards without SD bus pins) there if the supervisor or
|
|
88
|
+
the program has not already."""
|
|
89
|
+
try:
|
|
90
|
+
if os.statvfs(path) != os.statvfs("/"):
|
|
91
|
+
return True
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
spi = None # a bus made here, released if the card does not mount
|
|
95
|
+
try:
|
|
96
|
+
import board # noqa: PLC0415
|
|
97
|
+
import busio # noqa: PLC0415
|
|
98
|
+
import sdcardio # noqa: PLC0415
|
|
99
|
+
import storage # noqa: PLC0415
|
|
100
|
+
|
|
101
|
+
if hasattr(board, "SD_SCK"):
|
|
102
|
+
spi = busio.SPI(board.SD_SCK, board.SD_MOSI, board.SD_MISO)
|
|
103
|
+
bus = spi
|
|
104
|
+
else:
|
|
105
|
+
# The card shares the board's SPI bus (e.g. PyPortal, Metro
|
|
106
|
+
# ESP32-S3) with other devices, so the bus is left as it is.
|
|
107
|
+
bus = board.SPI()
|
|
108
|
+
storage.mount(storage.VfsFat(sdcardio.SDCard(bus, board.SD_CS)), path)
|
|
109
|
+
return os.statvfs(path) != os.statvfs("/")
|
|
110
|
+
except Exception as error:
|
|
111
|
+
if spi is not None:
|
|
112
|
+
try:
|
|
113
|
+
spi.deinit()
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
print("SD_MOUNT_ERROR", type(error).__name__, error)
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_radio = None
|
|
121
|
+
_session = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _find_radio():
|
|
125
|
+
"""The board's native Wi-Fi (``wifi.radio``), otherwise its ESP32
|
|
126
|
+
co-processor: shared through the core when the core is managing it (web
|
|
127
|
+
workflow), otherwise driven over the ``board.ESP_*`` pins from here."""
|
|
128
|
+
try:
|
|
129
|
+
import wifi # noqa: PLC0415
|
|
130
|
+
|
|
131
|
+
return wifi.radio
|
|
132
|
+
except ImportError:
|
|
133
|
+
pass
|
|
134
|
+
from adafruit_esp32spi import adafruit_esp32spi # noqa: PLC0415
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
import _airlift # noqa: PLC0415, PLC2701
|
|
138
|
+
|
|
139
|
+
shared = _airlift.active()
|
|
140
|
+
except ImportError:
|
|
141
|
+
shared = False
|
|
142
|
+
if shared:
|
|
143
|
+
return adafruit_esp32spi.ESP_SPIcontrol()
|
|
144
|
+
import board # noqa: PLC0415
|
|
145
|
+
from digitalio import DigitalInOut # noqa: PLC0415
|
|
146
|
+
|
|
147
|
+
gpio0 = getattr(board, "ESP_GPIO0", None)
|
|
148
|
+
return adafruit_esp32spi.ESP_SPIcontrol(
|
|
149
|
+
board.SPI(),
|
|
150
|
+
DigitalInOut(board.ESP_CS),
|
|
151
|
+
DigitalInOut(board.ESP_BUSY),
|
|
152
|
+
DigitalInOut(board.ESP_RESET),
|
|
153
|
+
DigitalInOut(gpio0) if gpio0 else None,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def default_session():
|
|
158
|
+
"""An ``adafruit_requests`` session, made once and shared by every cache:
|
|
159
|
+
over the board's native Wi-Fi (``wifi.radio``), otherwise through an
|
|
160
|
+
ESP32 co-processor (``adafruit_esp32spi``; on the Fruit Jam the core
|
|
161
|
+
shares its ESP32-C6 when web workflow is running). If the radio is not
|
|
162
|
+
connected yet, it joins ``CIRCUITPY_WIFI_SSID`` with
|
|
163
|
+
``CIRCUITPY_WIFI_PASSWORD`` from settings.toml."""
|
|
164
|
+
global _radio, _session # noqa: PLW0603
|
|
165
|
+
if _session is None:
|
|
166
|
+
import adafruit_connection_manager # noqa: PLC0415
|
|
167
|
+
import adafruit_requests # noqa: PLC0415
|
|
168
|
+
|
|
169
|
+
if _radio is None:
|
|
170
|
+
_radio = _find_radio()
|
|
171
|
+
if not _radio.connected:
|
|
172
|
+
ssid = os.getenv("CIRCUITPY_WIFI_SSID")
|
|
173
|
+
if not ssid:
|
|
174
|
+
raise RuntimeError("Wi-Fi is not connected, and CIRCUITPY_WIFI_SSID is not set")
|
|
175
|
+
_radio.connect(ssid, os.getenv("CIRCUITPY_WIFI_PASSWORD", ""))
|
|
176
|
+
pool = adafruit_connection_manager.get_radio_socketpool(_radio)
|
|
177
|
+
ssl = adafruit_connection_manager.get_radio_ssl_context(_radio)
|
|
178
|
+
_session = adafruit_requests.Session(pool, ssl)
|
|
179
|
+
return _session
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class TileCache:
|
|
183
|
+
"""The cached tiles of one source, fetched when missing.
|
|
184
|
+
|
|
185
|
+
:param TileSource source: where tiles come from (default: :data:`OSM`)
|
|
186
|
+
:param str root: the cache's top folder (default: :data:`CACHE_ROOT`);
|
|
187
|
+
a root on ``/sd`` mounts the SD card when the cache is first used
|
|
188
|
+
:param str user_agent: identifies the program to the tile server
|
|
189
|
+
:param session: an ``adafruit_requests.Session`` to download with
|
|
190
|
+
(default: :func:`default_session`, made at the first download)
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self, source=OSM, root=CACHE_ROOT, user_agent=USER_AGENT, session=None):
|
|
194
|
+
self.source = source
|
|
195
|
+
root = root.rstrip("/")
|
|
196
|
+
self.folder = f"{root}/{source.name}"
|
|
197
|
+
self.user_agent = user_agent
|
|
198
|
+
self._session = session
|
|
199
|
+
self._mounted = None
|
|
200
|
+
self.fetched = 0
|
|
201
|
+
"""Tiles downloaded since the cache was made."""
|
|
202
|
+
self.converted = 0
|
|
203
|
+
"""Tiles converted to BMP since the cache was made."""
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def available(self):
|
|
207
|
+
"""True if the card the cache is on is there (mounting it the first
|
|
208
|
+
time, if needed)."""
|
|
209
|
+
if self._mounted is None:
|
|
210
|
+
self._mounted = mount_sd() if self.folder.startswith("/sd/") else True
|
|
211
|
+
return self._mounted
|
|
212
|
+
|
|
213
|
+
def path(self, z, x, y, ext="bmp"):
|
|
214
|
+
"""The file of tile (z, x, y): ``ext`` ``"bmp"`` or ``"png"``."""
|
|
215
|
+
return f"{self.folder}/{z}/{x}/{y}.{ext}"
|
|
216
|
+
|
|
217
|
+
def ready(self, z, x, y):
|
|
218
|
+
"""True if the tile's BMP is in the cache."""
|
|
219
|
+
return self.available and _exists(self.path(z, x, y))
|
|
220
|
+
|
|
221
|
+
def has_png(self, z, x, y):
|
|
222
|
+
"""True if the tile's PNG is in the cache (no download needed)."""
|
|
223
|
+
return self.available and _exists(self.path(z, x, y, "png"))
|
|
224
|
+
|
|
225
|
+
def open(self, z, x, y):
|
|
226
|
+
"""The tile's BMP, open for reading (pixels from ``png.BMP_HEADER``),
|
|
227
|
+
or None if it is not in the cache."""
|
|
228
|
+
try:
|
|
229
|
+
return open(self.path(z, x, y), "rb")
|
|
230
|
+
except OSError:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def session(self):
|
|
234
|
+
"""The ``adafruit_requests`` session downloads use."""
|
|
235
|
+
if self._session is None:
|
|
236
|
+
self._session = default_session()
|
|
237
|
+
return self._session
|
|
238
|
+
|
|
239
|
+
def fetch(self, z, x, y):
|
|
240
|
+
"""Download the tile's PNG to the cache; returns its bytes."""
|
|
241
|
+
url = self.source.url.format(z=z, x=x, y=y)
|
|
242
|
+
r = self.session().get(url, headers={"User-Agent": self.user_agent})
|
|
243
|
+
try:
|
|
244
|
+
if r.status_code != 200:
|
|
245
|
+
raise OSError(f"HTTP {r.status_code} for {url}")
|
|
246
|
+
data = r.content
|
|
247
|
+
finally:
|
|
248
|
+
r.close()
|
|
249
|
+
path = self.path(z, x, y, "png")
|
|
250
|
+
_makedirs(path[: path.rfind("/")])
|
|
251
|
+
with open(path + ".tmp", "wb") as f:
|
|
252
|
+
f.write(data)
|
|
253
|
+
os.rename(path + ".tmp", path)
|
|
254
|
+
self.fetched += 1
|
|
255
|
+
return data
|
|
256
|
+
|
|
257
|
+
def convert(self, z, x, y, data=None):
|
|
258
|
+
"""The tile's PNG (data, or the cached file) -> its BMP."""
|
|
259
|
+
if data is None:
|
|
260
|
+
with open(self.path(z, x, y, "png"), "rb") as f:
|
|
261
|
+
data = f.read()
|
|
262
|
+
path = self.path(z, x, y)
|
|
263
|
+
_makedirs(path[: path.rfind("/")])
|
|
264
|
+
with open(path + ".tmp", "wb") as f:
|
|
265
|
+
size = png_to_bmp(data, f)
|
|
266
|
+
if size != (TILE, TILE):
|
|
267
|
+
os.remove(path + ".tmp")
|
|
268
|
+
raise ValueError(f"tile is {size[0]}x{size[1]}")
|
|
269
|
+
os.rename(path + ".tmp", path)
|
|
270
|
+
self.converted += 1
|
|
271
|
+
|
|
272
|
+
def get(self, z, x, y):
|
|
273
|
+
"""Make sure the tile's BMP is in the cache: downloads the PNG if it
|
|
274
|
+
is not there either, then converts it. Seconds. Returns True if it
|
|
275
|
+
downloaded.
|
|
276
|
+
|
|
277
|
+
:raises OSError: no card, no network, or the server refused
|
|
278
|
+
:raises ValueError: the PNG cannot be read
|
|
279
|
+
"""
|
|
280
|
+
if self.ready(z, x, y):
|
|
281
|
+
return False
|
|
282
|
+
if not self.available:
|
|
283
|
+
raise OSError("no SD card for the tile cache")
|
|
284
|
+
fetch = not self.has_png(z, x, y)
|
|
285
|
+
self.convert(z, x, y, self.fetch(z, x, y) if fetch else None)
|
|
286
|
+
return fetch
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""
|
|
5
|
+
`adafruit_maptiles.map_view`
|
|
6
|
+
================================================================================
|
|
7
|
+
|
|
8
|
+
:class:`MapView`, a :class:`displayio.Group` showing a slippy map from the
|
|
9
|
+
shared tile cache (:mod:`adafruit_maptiles.cache`), and the pieces for
|
|
10
|
+
drawing over it.
|
|
11
|
+
|
|
12
|
+
The map is one 256-colour bitmap (:attr:`MapView.bitmap`, palette: the
|
|
13
|
+
RGB332 colours), over a second one holding just the tiles (the
|
|
14
|
+
underlay). Tiles are read from their BMPs a strip of rows at a time into
|
|
15
|
+
the underlay. Drawing a frame, the program:
|
|
16
|
+
|
|
17
|
+
1. copies the underlay back over the map (:meth:`MapView.restore`, of the
|
|
18
|
+
whole map or one rectangle),
|
|
19
|
+
2. draws its markers with :meth:`MapView.fill`, :meth:`MapView.line`,
|
|
20
|
+
:meth:`MapView.blit` and :meth:`MapView.cross` (clipped to the map, or
|
|
21
|
+
to the rectangle), at :meth:`MapView.xy` of their positions,
|
|
22
|
+
3. draws the scale bar and the tiles' credit (:meth:`MapView.draw_legend`),
|
|
23
|
+
4. marks the bitmap changed (:meth:`MapView.dirty`) and refreshes the display.
|
|
24
|
+
|
|
25
|
+
Colours are RGB332 values (:func:`adafruit_maptiles.rgb332`); on an 8-bit
|
|
26
|
+
``picodvi`` framebuffer they are the framebuffer's own.
|
|
27
|
+
|
|
28
|
+
Missing tiles are grey until they are fetched. Setting the view queues
|
|
29
|
+
them (:attr:`MapView.missing`, nearest the centre first);
|
|
30
|
+
:meth:`MapView.fetch_step` downloads and converts one (seconds: a
|
|
31
|
+
program that must stay responsive calls it when it can wait), then
|
|
32
|
+
:meth:`MapView.load` puts it on the map. Programs that do not mind
|
|
33
|
+
waiting call :meth:`MapView.update` instead.
|
|
34
|
+
|
|
35
|
+
Loading: :meth:`MapView.load_step` reads one strip of rows (a few ms),
|
|
36
|
+
:meth:`MapView.load` all the strips still to read.
|
|
37
|
+
|
|
38
|
+
CircuitPython note: assigning to a slice of a memoryview of a big buffer
|
|
39
|
+
moves the buffer's whole tail, so pixels are copied with ``bitmaptools``,
|
|
40
|
+
never by slices.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import bitmaptools
|
|
44
|
+
import displayio
|
|
45
|
+
import terminalio
|
|
46
|
+
|
|
47
|
+
from adafruit_maptiles import (
|
|
48
|
+
TILE,
|
|
49
|
+
mercator,
|
|
50
|
+
metres_per_pixel,
|
|
51
|
+
rgb332_colour,
|
|
52
|
+
unmercator,
|
|
53
|
+
world_x,
|
|
54
|
+
world_y,
|
|
55
|
+
)
|
|
56
|
+
from adafruit_maptiles.cache import TileCache
|
|
57
|
+
from adafruit_maptiles.png import BMP_HEADER
|
|
58
|
+
|
|
59
|
+
# Colours are RGB332 values.
|
|
60
|
+
BLACK = 0x00
|
|
61
|
+
WHITE = 0xFF
|
|
62
|
+
NO_TILE = 0xB6
|
|
63
|
+
"""Where a tile is missing."""
|
|
64
|
+
WAITING = 0x49
|
|
65
|
+
"""The whole map, while it has no centre."""
|
|
66
|
+
|
|
67
|
+
_palette = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def palette():
|
|
71
|
+
"""The RGB332 palette (256 colours), shared by every map."""
|
|
72
|
+
global _palette # noqa: PLW0603
|
|
73
|
+
if _palette is None:
|
|
74
|
+
_palette = displayio.Palette(256)
|
|
75
|
+
for v in range(256):
|
|
76
|
+
_palette[v] = rgb332_colour(v)
|
|
77
|
+
return _palette
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_glyphs = {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def text_bitmap(text, background, width=None, outline=False):
|
|
84
|
+
"""text on background in terminalio's font (6x12; 1 pixel margin), as a
|
|
85
|
+
256-colour bitmap to :meth:`MapView.blit` onto the map. With outline:
|
|
86
|
+
a black border, 14 pixels high and 2 pixels either side of the text.
|
|
87
|
+
width: wider than the text (the text goes on the right)."""
|
|
88
|
+
# (a glyph's own blank column is on its right)
|
|
89
|
+
b = displayio.Bitmap(width or 6 * len(text) + (5 if outline else 2), 14 if outline else 13, 256)
|
|
90
|
+
if outline:
|
|
91
|
+
b.fill(BLACK)
|
|
92
|
+
bitmaptools.fill_region(b, 1, 1, b.width - 1, b.height - 1, background)
|
|
93
|
+
else:
|
|
94
|
+
b.fill(background)
|
|
95
|
+
for k, ch in enumerate(text):
|
|
96
|
+
g = _glyphs.get(ch)
|
|
97
|
+
if g is None:
|
|
98
|
+
glyph = terminalio.FONT.get_glyph(ord(ch))
|
|
99
|
+
g = _glyphs[ch] = glyph.tile_index * 6 if glyph else -1
|
|
100
|
+
if g >= 0:
|
|
101
|
+
bitmaptools.blit(
|
|
102
|
+
b,
|
|
103
|
+
terminalio.FONT.bitmap,
|
|
104
|
+
b.width - 6 * len(text) - (2 if outline else 1) + 6 * k,
|
|
105
|
+
1,
|
|
106
|
+
x1=g,
|
|
107
|
+
y1=0,
|
|
108
|
+
x2=g + 6,
|
|
109
|
+
y2=12,
|
|
110
|
+
skip_source_index=0,
|
|
111
|
+
)
|
|
112
|
+
return b
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def overlaps(a, b):
|
|
116
|
+
"""True if rectangles a and b, ``(x1, y1, x2, y2)``, overlap."""
|
|
117
|
+
return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class MapView(displayio.Group): # noqa: PLR0904
|
|
121
|
+
"""A slippy map, width x height pixels, centred on centre at zoom.
|
|
122
|
+
|
|
123
|
+
:param int width: the map's width in pixels
|
|
124
|
+
:param int height: the map's height in pixels
|
|
125
|
+
:param tuple centre: ``(lat, lon)``, or None for no map yet (the map is
|
|
126
|
+
dark grey until :attr:`centre` is set)
|
|
127
|
+
:param int zoom: 0 (the world in one tile) .. ``zooms[1]``
|
|
128
|
+
:param TileCache cache: where the tiles come from (default: a
|
|
129
|
+
:class:`~adafruit_maptiles.cache.TileCache` of OpenStreetMap
|
|
130
|
+
tiles in the shared cache)
|
|
131
|
+
:param tuple zooms: ``(closest out, closest in)`` that :attr:`zoom` keeps to
|
|
132
|
+
(default: 0 .. the source's maximum)
|
|
133
|
+
:param int x: the map's position in its parent group
|
|
134
|
+
:param int y: the map's position in its parent group
|
|
135
|
+
:param int strip: rows read per :meth:`load_step`
|
|
136
|
+
:param bool scale_bar: draw a scale bar at the bottom left
|
|
137
|
+
:param bool credit: draw the source's credit at the bottom right (the
|
|
138
|
+
OpenStreetMap tiles' usage policy requires it)
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__( # noqa: PLR0913, PLR0917
|
|
142
|
+
self,
|
|
143
|
+
width,
|
|
144
|
+
height,
|
|
145
|
+
centre=None,
|
|
146
|
+
zoom=8,
|
|
147
|
+
cache=None,
|
|
148
|
+
zooms=None,
|
|
149
|
+
x=0,
|
|
150
|
+
y=0,
|
|
151
|
+
strip=8,
|
|
152
|
+
scale_bar=True,
|
|
153
|
+
credit=True,
|
|
154
|
+
):
|
|
155
|
+
super().__init__(x=x, y=y)
|
|
156
|
+
self.width = width
|
|
157
|
+
self.height = height
|
|
158
|
+
self.cache = cache or TileCache()
|
|
159
|
+
self.zooms = zooms or (0, self.cache.source.max_zoom)
|
|
160
|
+
self.strip = strip
|
|
161
|
+
self.bitmap = displayio.Bitmap(width, height, 256)
|
|
162
|
+
"""The map as shown: the tiles and what is drawn over them."""
|
|
163
|
+
self.under = displayio.Bitmap(width, height, 256)
|
|
164
|
+
"""Just the tiles."""
|
|
165
|
+
self._rows = displayio.Bitmap(TILE, strip, 256) # a tile's rows, read from its file
|
|
166
|
+
self._rows_mv = memoryview(self._rows)
|
|
167
|
+
self.append(displayio.TileGrid(self.bitmap, pixel_shader=palette()))
|
|
168
|
+
self.credit = text_bitmap(self.cache.source.credit, WHITE) if credit else None
|
|
169
|
+
self._show_scale = scale_bar
|
|
170
|
+
self.scale_bar = None
|
|
171
|
+
"""The scale bar's bitmap (None: no scale bar, or no centre)."""
|
|
172
|
+
self.missing = []
|
|
173
|
+
"""``(z, x, y)`` tiles to fetch, nearest the centre first."""
|
|
174
|
+
self.net_error = None
|
|
175
|
+
"""Why the last download failed (until the view changes), or None."""
|
|
176
|
+
self._todo = [] # strips to (re)load
|
|
177
|
+
self._files = {} # (x, y) -> open tile BMP, or None, while strips load
|
|
178
|
+
self._centre = centre
|
|
179
|
+
self._zoom = self._clamp(zoom)
|
|
180
|
+
self.set_view()
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------ view
|
|
183
|
+
|
|
184
|
+
def _clamp(self, zoom):
|
|
185
|
+
return max(self.zooms[0], min(self.zooms[1], zoom))
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def centre(self):
|
|
189
|
+
"""The map's centre, ``(lat, lon)``, or None. Setting it moves the map."""
|
|
190
|
+
return self._centre
|
|
191
|
+
|
|
192
|
+
@centre.setter
|
|
193
|
+
def centre(self, centre):
|
|
194
|
+
self.set_view(centre=centre)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def zoom(self):
|
|
198
|
+
"""The zoom (kept to :attr:`zooms`). Setting it zooms the map."""
|
|
199
|
+
return self._zoom
|
|
200
|
+
|
|
201
|
+
@zoom.setter
|
|
202
|
+
def zoom(self, zoom):
|
|
203
|
+
self.set_view(zoom=zoom)
|
|
204
|
+
|
|
205
|
+
def set_view(self, centre=None, zoom=None):
|
|
206
|
+
"""Move the map to centre and / or zoom (or just reload it): queues
|
|
207
|
+
the tiles not in the cache (:attr:`missing`) and every strip for
|
|
208
|
+
loading. Nothing changes on the screen until :meth:`load` (or
|
|
209
|
+
:meth:`load_step`) and a redraw."""
|
|
210
|
+
if centre is not None:
|
|
211
|
+
self._centre = (centre[0], centre[1])
|
|
212
|
+
if zoom is not None:
|
|
213
|
+
self._zoom = self._clamp(zoom)
|
|
214
|
+
self._close_files()
|
|
215
|
+
self._todo = list(range((self.height + self.strip - 1) // self.strip))
|
|
216
|
+
self.missing = []
|
|
217
|
+
self.net_error = None
|
|
218
|
+
if self._centre is None:
|
|
219
|
+
self.scale_bar = None
|
|
220
|
+
return
|
|
221
|
+
z = self._zoom
|
|
222
|
+
lat, lon = self._centre
|
|
223
|
+
w, h = self.width, self.height
|
|
224
|
+
self.x0 = int(world_x(lon, z)) - w // 2
|
|
225
|
+
self.y0 = int(world_y(lat, z)) - h // 2
|
|
226
|
+
self.clon = lon
|
|
227
|
+
self.cmerc = mercator(lat)
|
|
228
|
+
self.world = TILE << z
|
|
229
|
+
n = 1 << z
|
|
230
|
+
ctx, cty = (self.x0 + w // 2) >> 8, (self.y0 + h // 2) >> 8
|
|
231
|
+
need = []
|
|
232
|
+
seen = set()
|
|
233
|
+
for ty in range(self.y0 >> 8, ((self.y0 + h - 1) >> 8) + 1):
|
|
234
|
+
for tx in range(self.x0 >> 8, ((self.x0 + w - 1) >> 8) + 1):
|
|
235
|
+
# (at low zooms the map wraps round: the same tile twice)
|
|
236
|
+
if 0 <= ty < n and (tx % n, ty) not in seen:
|
|
237
|
+
seen.add((tx % n, ty))
|
|
238
|
+
if not self.cache.ready(z, tx % n, ty):
|
|
239
|
+
need.append(((tx - ctx) ** 2 + (ty - cty) ** 2, tx % n, ty))
|
|
240
|
+
self.missing = [(z, x, y) for _, x, y in sorted(need)]
|
|
241
|
+
self.scale_bar = self._make_scale(lat, z) if self._show_scale else None
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def _make_scale(lat, z):
|
|
245
|
+
"""The scale bar: the longest 1, 2 or 5 x 10^k that fits in 80 pixels."""
|
|
246
|
+
mpp = metres_per_pixel(lat, z)
|
|
247
|
+
step = 1
|
|
248
|
+
while step * 10 <= mpp * 80:
|
|
249
|
+
step *= 10
|
|
250
|
+
metres = max(m for m in (step, 2 * step, 5 * step) if m <= mpp * 80 or m == step)
|
|
251
|
+
px = max(1, int(metres / mpp))
|
|
252
|
+
text = f"{metres // 1000} km" if metres >= 1000 else f"{metres} m"
|
|
253
|
+
scale = text_bitmap(text, WHITE, px + 8 + 6 * len(text))
|
|
254
|
+
bitmaptools.fill_region(scale, 3, 9, 3 + px, 11, BLACK)
|
|
255
|
+
bitmaptools.fill_region(scale, 3, 4, 4, 11, BLACK)
|
|
256
|
+
bitmaptools.fill_region(scale, 2 + px, 4, 3 + px, 11, BLACK)
|
|
257
|
+
return scale
|
|
258
|
+
|
|
259
|
+
def xy(self, lat, lon):
|
|
260
|
+
"""(lat, lon) -> ``(x, y)`` on the map, in pixels (outside
|
|
261
|
+
0 .. width, 0 .. height when it is off the map)."""
|
|
262
|
+
return (
|
|
263
|
+
self.width // 2 + round((lon - self.clon) / 360 * self.world),
|
|
264
|
+
self.height // 2 + round((mercator(lat) - self.cmerc) * self.world),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def latlon(self, x, y):
|
|
268
|
+
"""``(x, y)`` on the map -> (lat, lon)."""
|
|
269
|
+
lon = self.clon + (x - self.width // 2) / self.world * 360
|
|
270
|
+
lat = unmercator(self.cmerc + (y - self.height // 2) / self.world)
|
|
271
|
+
return lat, (lon + 540) % 360 - 180
|
|
272
|
+
|
|
273
|
+
def pan(self, dx, dy):
|
|
274
|
+
"""Move the map by dx widths and dy heights (e.g. ``pan(0.25, 0)``:
|
|
275
|
+
a quarter of the map to the right)."""
|
|
276
|
+
if self._centre is not None:
|
|
277
|
+
self.centre = self.latlon(
|
|
278
|
+
self.width // 2 + dx * self.width, self.height // 2 + dy * self.height
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def zoom_in(self):
|
|
282
|
+
"""One zoom closer; True if the zoom changed."""
|
|
283
|
+
z = self._zoom
|
|
284
|
+
self.zoom = z + 1
|
|
285
|
+
return self._zoom != z
|
|
286
|
+
|
|
287
|
+
def zoom_out(self):
|
|
288
|
+
"""One zoom further out; True if the zoom changed."""
|
|
289
|
+
z = self._zoom
|
|
290
|
+
self.zoom = z - 1
|
|
291
|
+
return self._zoom != z
|
|
292
|
+
|
|
293
|
+
def fit(self, points, max_zoom=None, margin=24):
|
|
294
|
+
"""Centre the map on the ``(lat, lon)`` points, at the closest zoom
|
|
295
|
+
(up to max_zoom) that shows them all, margin pixels inside the map."""
|
|
296
|
+
lons = [lon for _, lon in points]
|
|
297
|
+
ys = [mercator(lat) for lat, _ in points]
|
|
298
|
+
z = self._clamp(self.zooms[1] if max_zoom is None else max_zoom)
|
|
299
|
+
while z > self.zooms[0] and (
|
|
300
|
+
(max(lons) - min(lons)) / 360 * (TILE << z) > self.width - 2 * margin
|
|
301
|
+
or (max(ys) - min(ys)) * (TILE << z) > self.height - 2 * margin
|
|
302
|
+
):
|
|
303
|
+
z -= 1
|
|
304
|
+
self.set_view(((unmercator((max(ys) + min(ys)) / 2)), (max(lons) + min(lons)) / 2), z)
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------- loading
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def loading(self):
|
|
310
|
+
"""True while strips of the map are still to be read."""
|
|
311
|
+
return bool(self._todo)
|
|
312
|
+
|
|
313
|
+
def _tile_file(self, x, y):
|
|
314
|
+
key = (x, y)
|
|
315
|
+
if key not in self._files:
|
|
316
|
+
self._files[key] = self.cache.open(self._zoom, x, y)
|
|
317
|
+
return self._files[key]
|
|
318
|
+
|
|
319
|
+
def _close_files(self):
|
|
320
|
+
for f in self._files.values():
|
|
321
|
+
if f:
|
|
322
|
+
f.close()
|
|
323
|
+
self._files = {}
|
|
324
|
+
|
|
325
|
+
def load_step(self):
|
|
326
|
+
"""Read the next strip of the map into the underlay. Returns the
|
|
327
|
+
rectangle loaded, ``(x1, y1, x2, y2)`` (to redraw), or None when
|
|
328
|
+
the map is loaded."""
|
|
329
|
+
if not self._todo:
|
|
330
|
+
self._close_files()
|
|
331
|
+
return None
|
|
332
|
+
y1 = self._todo.pop(0) * self.strip
|
|
333
|
+
y2 = min(self.height, y1 + self.strip)
|
|
334
|
+
w = self.width
|
|
335
|
+
if self._centre is None:
|
|
336
|
+
bitmaptools.fill_region(self.under, 0, y1, w, y2, WAITING)
|
|
337
|
+
return (0, y1, w, y2)
|
|
338
|
+
n = 1 << self._zoom
|
|
339
|
+
y = y1
|
|
340
|
+
while y < y2:
|
|
341
|
+
# Rows y .. y + h are in the same row of tiles.
|
|
342
|
+
gy = self.y0 + y
|
|
343
|
+
ty = gy >> 8
|
|
344
|
+
h = min(y2 - y, TILE - (gy & 255))
|
|
345
|
+
sx = 0
|
|
346
|
+
while sx < w:
|
|
347
|
+
gx = self.x0 + sx
|
|
348
|
+
off = gx & 255
|
|
349
|
+
m = min(TILE - off, w - sx)
|
|
350
|
+
f = self._tile_file((gx >> 8) % n, ty) if 0 <= ty < n else None
|
|
351
|
+
if f is None:
|
|
352
|
+
bitmaptools.fill_region(self.under, sx, y, sx + m, y + h, NO_TILE)
|
|
353
|
+
else:
|
|
354
|
+
f.seek(BMP_HEADER + (gy & 255) * TILE)
|
|
355
|
+
f.readinto(self._rows_mv[: h * TILE])
|
|
356
|
+
bitmaptools.blit(self.under, self._rows, sx, y, x1=off, y1=0, x2=off + m, y2=h)
|
|
357
|
+
sx += m
|
|
358
|
+
y += h
|
|
359
|
+
return (0, y1, w, y2)
|
|
360
|
+
|
|
361
|
+
def load(self):
|
|
362
|
+
"""Read every strip still to load. True if there were any."""
|
|
363
|
+
if not self._todo:
|
|
364
|
+
return False
|
|
365
|
+
while self.load_step():
|
|
366
|
+
pass
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------- fetching
|
|
370
|
+
|
|
371
|
+
def fetch_step(self):
|
|
372
|
+
"""Download (if needed) and convert the next missing tile: seconds.
|
|
373
|
+
Returns False if no tile was waiting. A download that fails sets
|
|
374
|
+
:attr:`net_error` and drops the tiles that need downloading (the
|
|
375
|
+
rest still convert); setting the view again retries them. A tile
|
|
376
|
+
fetched for the current view is queued to load."""
|
|
377
|
+
if not self.missing:
|
|
378
|
+
return False
|
|
379
|
+
z, x, y = self.missing.pop(0)
|
|
380
|
+
fetch = not self.cache.has_png(z, x, y)
|
|
381
|
+
try:
|
|
382
|
+
self.cache.get(z, x, y)
|
|
383
|
+
except Exception as error:
|
|
384
|
+
print("TILE_ERROR", z, x, y, type(error).__name__, error)
|
|
385
|
+
if fetch:
|
|
386
|
+
self.net_error = f"{type(error).__name__} {error}"
|
|
387
|
+
self.missing = [t for t in self.missing if self.cache.has_png(*t)]
|
|
388
|
+
return True
|
|
389
|
+
if z == self._zoom and self._centre is not None:
|
|
390
|
+
f = self._files.pop((x, y), None)
|
|
391
|
+
if f:
|
|
392
|
+
f.close()
|
|
393
|
+
# Reload the strips the tile is in.
|
|
394
|
+
top = (y << 8) - self.y0
|
|
395
|
+
for k in range(
|
|
396
|
+
max(0, top // self.strip), min(self.height, top + TILE) // self.strip + 1
|
|
397
|
+
):
|
|
398
|
+
if k * self.strip < self.height and k not in self._todo:
|
|
399
|
+
self._todo.append(k)
|
|
400
|
+
return True
|
|
401
|
+
|
|
402
|
+
def update(self, progress=None):
|
|
403
|
+
"""Fetch every missing tile, then load the map: for programs that can
|
|
404
|
+
wait (seconds a tile). progress(done, total) is called before each
|
|
405
|
+
tile."""
|
|
406
|
+
total = len(self.missing)
|
|
407
|
+
while self.missing:
|
|
408
|
+
if progress:
|
|
409
|
+
progress(total - len(self.missing), total)
|
|
410
|
+
self.fetch_step()
|
|
411
|
+
self.load()
|
|
412
|
+
|
|
413
|
+
# ---------------------------------------------------------- drawing
|
|
414
|
+
|
|
415
|
+
def _clip(self, clip):
|
|
416
|
+
if clip is None:
|
|
417
|
+
return (0, 0, self.width, self.height)
|
|
418
|
+
return (
|
|
419
|
+
max(0, clip[0]),
|
|
420
|
+
max(0, clip[1]),
|
|
421
|
+
min(self.width, clip[2]),
|
|
422
|
+
min(self.height, clip[3]),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def restore(self, clip=None):
|
|
426
|
+
"""Copy the tiles back over the map, in clip ``(x1, y1, x2, y2)``
|
|
427
|
+
(default: all of it), wiping what was drawn there."""
|
|
428
|
+
x1, y1, x2, y2 = self._clip(clip)
|
|
429
|
+
if x1 < x2 and y1 < y2:
|
|
430
|
+
bitmaptools.blit(self.bitmap, self.under, x1, y1, x1=x1, y1=y1, x2=x2, y2=y2)
|
|
431
|
+
|
|
432
|
+
def fill(self, x1, y1, x2, y2, colour, clip=None): # noqa: PLR0913, PLR0917
|
|
433
|
+
"""A filled rectangle, x2 and y2 exclusive."""
|
|
434
|
+
c = self._clip(clip)
|
|
435
|
+
x1, y1, x2, y2 = max(x1, c[0]), max(y1, c[1]), min(x2, c[2]), min(y2, c[3])
|
|
436
|
+
if x1 < x2 and y1 < y2:
|
|
437
|
+
bitmaptools.fill_region(self.bitmap, x1, y1, x2, y2, colour)
|
|
438
|
+
|
|
439
|
+
def line(self, x1, y1, x2, y2, colour, clip=None): # noqa: PLR0913, PLR0917
|
|
440
|
+
"""A line, clipped (Liang-Barsky)."""
|
|
441
|
+
c = self._clip(clip)
|
|
442
|
+
dx, dy = x2 - x1, y2 - y1
|
|
443
|
+
t0, t1 = 0, 1
|
|
444
|
+
for p, q in ((-dx, x1 - c[0]), (dx, c[2] - 1 - x1), (-dy, y1 - c[1]), (dy, c[3] - 1 - y1)):
|
|
445
|
+
if p == 0:
|
|
446
|
+
if q < 0:
|
|
447
|
+
return
|
|
448
|
+
elif p < 0:
|
|
449
|
+
t0 = max(t0, q / p)
|
|
450
|
+
else:
|
|
451
|
+
t1 = min(t1, q / p)
|
|
452
|
+
if t0 <= t1:
|
|
453
|
+
bitmaptools.draw_line(
|
|
454
|
+
self.bitmap,
|
|
455
|
+
round(x1 + t0 * dx),
|
|
456
|
+
round(y1 + t0 * dy),
|
|
457
|
+
round(x1 + t1 * dx),
|
|
458
|
+
round(y1 + t1 * dy),
|
|
459
|
+
colour,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def blit(self, src, x, y, clip=None):
|
|
463
|
+
"""The bitmap src (256 colours, RGB332) with its top left at x, y."""
|
|
464
|
+
c = self._clip(clip)
|
|
465
|
+
x1, y1 = max(x, c[0]), max(y, c[1])
|
|
466
|
+
x2, y2 = min(x + src.width, c[2]), min(y + src.height, c[3])
|
|
467
|
+
if x1 < x2 and y1 < y2:
|
|
468
|
+
bitmaptools.blit(self.bitmap, src, x1, y1, x1=x1 - x, y1=y1 - y, x2=x2 - x, y2=y2 - y)
|
|
469
|
+
|
|
470
|
+
def cross(self, x, y, clip=None):
|
|
471
|
+
"""A white cross with a black middle, 13 pixels across, centred on
|
|
472
|
+
x, y (e.g. home)."""
|
|
473
|
+
self.fill(x - 6, y - 2, x + 7, y + 3, WHITE, clip)
|
|
474
|
+
self.fill(x - 2, y - 6, x + 3, y + 7, WHITE, clip)
|
|
475
|
+
self.fill(x - 5, y - 1, x + 6, y + 2, BLACK, clip)
|
|
476
|
+
self.fill(x - 1, y - 5, x + 2, y + 6, BLACK, clip)
|
|
477
|
+
|
|
478
|
+
def draw_legend(self, clip=None):
|
|
479
|
+
"""The scale bar (bottom left) and the credit (bottom right)."""
|
|
480
|
+
if self.scale_bar is not None:
|
|
481
|
+
self.blit(self.scale_bar, 0, self.height - self.scale_bar.height, clip)
|
|
482
|
+
if self.credit is not None and self._centre is not None:
|
|
483
|
+
self.blit(
|
|
484
|
+
self.credit,
|
|
485
|
+
self.width - self.credit.width,
|
|
486
|
+
self.height - self.credit.height,
|
|
487
|
+
clip,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
def dirty(self, clip=None):
|
|
491
|
+
"""Mark the map (or clip of it) changed, for the next display refresh."""
|
|
492
|
+
if clip is None:
|
|
493
|
+
self.bitmap.dirty()
|
|
494
|
+
else:
|
|
495
|
+
x1, y1, x2, y2 = self._clip(clip)
|
|
496
|
+
if x1 < x2 and y1 < y2:
|
|
497
|
+
self.bitmap.dirty(x1, y1, x2, y2)
|
|
498
|
+
|
|
499
|
+
def redraw(self):
|
|
500
|
+
"""The tiles and the legend, nothing else (a plain map): loads what
|
|
501
|
+
is still to load, then draws."""
|
|
502
|
+
self.load()
|
|
503
|
+
self.restore()
|
|
504
|
+
self.draw_legend()
|
|
505
|
+
self.dirty()
|
|
506
|
+
|
|
507
|
+
def close(self):
|
|
508
|
+
"""Close any tile files left open by a partly loaded map."""
|
|
509
|
+
self._close_files()
|
adafruit_maptiles/png.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""
|
|
5
|
+
`adafruit_maptiles.png`
|
|
6
|
+
================================================================================
|
|
7
|
+
|
|
8
|
+
PNG -> 8-bit BMP conversion, in the RGB332 colours that a CircuitPython
|
|
9
|
+
8-bit framebuffer (e.g. ``picodvi`` with ``color_depth=8``) shows
|
|
10
|
+
directly. The BMP's rows are top-down and, for a 256 pixel wide tile, need
|
|
11
|
+
no padding, so a row of the tile is at ``BMP_HEADER + row * 256`` in the
|
|
12
|
+
file and can be read straight into a :class:`displayio.Bitmap`.
|
|
13
|
+
|
|
14
|
+
Converting a 256x256 tile takes ~1.5 s on an RP2350 (zlib, then a palette
|
|
15
|
+
lookup per pixel in Python), so it is done once per tile and the BMP kept.
|
|
16
|
+
|
|
17
|
+
Plain Python: it runs on a PC too (the tests do).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import struct
|
|
21
|
+
import zlib
|
|
22
|
+
|
|
23
|
+
from adafruit_maptiles import rgb332, rgb332_colour
|
|
24
|
+
|
|
25
|
+
BMP_HEADER = 14 + 40 + 1024
|
|
26
|
+
"""Bytes before the pixels: file header, BITMAPINFOHEADER, 256-colour palette."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def bmp_header(width, height):
|
|
30
|
+
"""8-bit top-down BMP header with the RGB332 palette."""
|
|
31
|
+
h = bytearray(BMP_HEADER)
|
|
32
|
+
stride = (width + 3) & ~3
|
|
33
|
+
struct.pack_into("<2sIHHI", h, 0, b"BM", BMP_HEADER + stride * height, 0, 0, BMP_HEADER)
|
|
34
|
+
struct.pack_into(
|
|
35
|
+
"<IiiHHIIiiII", h, 14, 40, width, -height, 1, 8, 0, stride * height, 2835, 2835, 256, 0
|
|
36
|
+
)
|
|
37
|
+
for v in range(256):
|
|
38
|
+
c = rgb332_colour(v)
|
|
39
|
+
struct.pack_into("<BBBB", h, 54 + 4 * v, c & 255, c >> 8 & 255, c >> 16, 0)
|
|
40
|
+
return h
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _unfilter(f, cur, prev, bpp):
|
|
44
|
+
"""Undo one PNG row filter in place (prev: the row above, unfiltered)."""
|
|
45
|
+
n = len(cur)
|
|
46
|
+
if f == 1:
|
|
47
|
+
for i in range(bpp, n):
|
|
48
|
+
cur[i] = (cur[i] + cur[i - bpp]) & 255
|
|
49
|
+
elif f == 2:
|
|
50
|
+
for i in range(n):
|
|
51
|
+
cur[i] = (cur[i] + prev[i]) & 255
|
|
52
|
+
elif f == 3:
|
|
53
|
+
for i in range(n):
|
|
54
|
+
a = cur[i - bpp] if i >= bpp else 0
|
|
55
|
+
cur[i] = (cur[i] + ((a + prev[i]) >> 1)) & 255
|
|
56
|
+
elif f == 4:
|
|
57
|
+
for i in range(n):
|
|
58
|
+
a = cur[i - bpp] if i >= bpp else 0
|
|
59
|
+
c = prev[i - bpp] if i >= bpp else 0
|
|
60
|
+
b = prev[i]
|
|
61
|
+
p = a + b - c
|
|
62
|
+
pa = abs(p - a)
|
|
63
|
+
pb = abs(p - b)
|
|
64
|
+
pc = abs(p - c)
|
|
65
|
+
cur[i] = (cur[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
|
|
66
|
+
elif f:
|
|
67
|
+
raise ValueError(f"PNG filter {f}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def png_to_bmp(data, out): # noqa: PLR0912, PLR0914, PLR0915
|
|
71
|
+
"""A PNG (bytes) -> an RGB332 BMP written to the open file out.
|
|
72
|
+
Indexed (1-8 bit), grey (1-8 bit) and 8-bit RGB, RGBA and grey+alpha;
|
|
73
|
+
transparent pixels become white. Returns ``(width, height)``.
|
|
74
|
+
|
|
75
|
+
:raises ValueError: not a PNG this can read (interlaced, 16-bit)
|
|
76
|
+
"""
|
|
77
|
+
if bytes(data[:8]) != b"\x89PNG\r\n\x1a\n":
|
|
78
|
+
raise ValueError("not a PNG")
|
|
79
|
+
mv = memoryview(data)
|
|
80
|
+
pos = 8
|
|
81
|
+
idat = bytearray()
|
|
82
|
+
plte = trns = None
|
|
83
|
+
width = 0
|
|
84
|
+
while pos + 8 <= len(data):
|
|
85
|
+
ln = int.from_bytes(bytes(mv[pos : pos + 4]), "big")
|
|
86
|
+
kind = bytes(mv[pos + 4 : pos + 8])
|
|
87
|
+
body = mv[pos + 8 : pos + 8 + ln]
|
|
88
|
+
if kind == b"IHDR":
|
|
89
|
+
width, height, depth, ctype, _, _, interlace = struct.unpack(">IIBBBBB", bytes(body))
|
|
90
|
+
elif kind == b"PLTE":
|
|
91
|
+
plte = bytes(body)
|
|
92
|
+
elif kind == b"tRNS":
|
|
93
|
+
trns = bytes(body)
|
|
94
|
+
elif kind == b"IDAT":
|
|
95
|
+
idat.extend(body)
|
|
96
|
+
elif kind == b"IEND":
|
|
97
|
+
break
|
|
98
|
+
pos += 12 + ln
|
|
99
|
+
if not width:
|
|
100
|
+
raise ValueError("no IHDR")
|
|
101
|
+
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(ctype)
|
|
102
|
+
if channels is None or interlace or depth > 8 or (depth < 8 and ctype not in {0, 3}):
|
|
103
|
+
raise ValueError(f"PNG type {ctype} depth {depth} interlace {interlace}")
|
|
104
|
+
raw = zlib.decompress(idat)
|
|
105
|
+
del idat
|
|
106
|
+
stride = (width * channels * depth + 7) // 8
|
|
107
|
+
if len(raw) < (stride + 1) * height:
|
|
108
|
+
raise ValueError("PNG data short")
|
|
109
|
+
bpp = max(1, channels * depth // 8)
|
|
110
|
+
# Indexed and grey: one lookup per pixel value.
|
|
111
|
+
lut = None
|
|
112
|
+
if ctype == 3:
|
|
113
|
+
lut = bytearray(b"\xff" * 256)
|
|
114
|
+
for i in range(min(256, len(plte or b"") // 3)):
|
|
115
|
+
if trns is None or i >= len(trns) or trns[i] >= 128:
|
|
116
|
+
lut[i] = rgb332(plte[3 * i], plte[3 * i + 1], plte[3 * i + 2])
|
|
117
|
+
elif ctype == 0:
|
|
118
|
+
levels = (1 << depth) - 1
|
|
119
|
+
lut = bytearray(256)
|
|
120
|
+
for i in range(levels + 1):
|
|
121
|
+
v = i * 255 // levels
|
|
122
|
+
lut[i] = rgb332(v, v, v)
|
|
123
|
+
pad = (width + 3) & ~3
|
|
124
|
+
row = bytearray(pad)
|
|
125
|
+
prev = bytearray(stride)
|
|
126
|
+
out.write(bmp_header(width, height))
|
|
127
|
+
per_byte = 8 // depth
|
|
128
|
+
mask = (1 << depth) - 1
|
|
129
|
+
for y in range(height):
|
|
130
|
+
at = y * (stride + 1)
|
|
131
|
+
f = raw[at]
|
|
132
|
+
cur = bytearray(raw[at + 1 : at + 1 + stride])
|
|
133
|
+
if f:
|
|
134
|
+
_unfilter(f, cur, prev, bpp)
|
|
135
|
+
if lut is not None and depth == 8:
|
|
136
|
+
for x in range(width):
|
|
137
|
+
row[x] = lut[cur[x]]
|
|
138
|
+
elif lut is not None:
|
|
139
|
+
for x in range(width):
|
|
140
|
+
shift = 8 - depth * (x % per_byte + 1)
|
|
141
|
+
row[x] = lut[cur[x // per_byte] >> shift & mask]
|
|
142
|
+
elif ctype == 2:
|
|
143
|
+
for x in range(width):
|
|
144
|
+
k = 3 * x
|
|
145
|
+
row[x] = rgb332(cur[k], cur[k + 1], cur[k + 2])
|
|
146
|
+
elif ctype == 6:
|
|
147
|
+
for x in range(width):
|
|
148
|
+
k = 4 * x
|
|
149
|
+
row[x] = rgb332(cur[k], cur[k + 1], cur[k + 2]) if cur[k + 3] >= 128 else 0xFF
|
|
150
|
+
else: # grey + alpha
|
|
151
|
+
for x in range(width):
|
|
152
|
+
v = cur[2 * x]
|
|
153
|
+
row[x] = rgb332(v, v, v) if cur[2 * x + 1] >= 128 else 0xFF
|
|
154
|
+
out.write(row)
|
|
155
|
+
prev = cur
|
|
156
|
+
return width, height
|