Displaying OpenStreetMap Tiles Using PySide6
Hi there! In this article I want to start a series of publications on writing my own customized application for displaying OSM…
Displaying OpenStreetMap Tiles Using PySide6
Hi there! In this article I want to start a series of publications on writing my own customized application for displaying OSM (OpenStreetMap) maps.

The work was motivated by the fact that even today there is no acceptable way to work with map data. Many existing approaches are either too complex to configure or not productive enough when working with large amounts of data. The search for ways to solve this problem prompted the development of my own analogy, which allows displaying OSM map tiles and storing tiles in a Redis storage.
This work is a kind of test of the possibility to develop your own simple solution for using OSM maps. The code in this work serves the purpose of getting acquainted with the map’s operation and getting an initial understanding of interaction with the OSM API.
It is based on PySide6 with QGraphicsView for displaying the map background.
Why Redis?
Redis was not chosen as a storage by chance. This in-memory data storage provides high performance when reading and writing, which is especially important when working with map tiles. The main advantages of using Redis:
- High access speed — maps are loaded almost instantly due to data storage in RAM.
- Storage flexibility — support for various data structures allows you to adapt the storage format to the needs of the application.
- Scalability — Redis can be scaled horizontally, which is especially important as the amount of data increases.
Solution architecture
The following architecture was chosen to implement the system:
- Data Retrieval: Standard protocols and tools are used to download OSM tiles.
- Caching in Redis: Tiles are loaded into Redis with keys formed according to the principle {x}_{y}:{x}_tile, where z is the zoom level and x and y are the coordinates.
- Tile display: The future plan is to use a lightweight web server to visualize the map, which dynamically fetches tiles from Redis and displays them in the user’s application.
Source code
The source code described here is intended to test the feasibility of using OSM. Further development is planned before its release to production. There are two main components here: NetworkAccessManagerPool for managing the connection pool and OSMGraphicsView for displaying map data.
class NetworkAccessManagerPool:
def __init__(self, parent, manager_count = 1):
self.parent = parent
self.manager_count = manager_count
self.network_manager_list = list()
for _ in range(manager_count):
network_manager = QNetworkAccessManager(self.parent)
network_manager.setTransferTimeout(5000)
self.network_manager_list.append(network_manager)
def getNetworkManager(self):
return rnd.choice(self.network_manager_list)
The NetworkAccessManagerPool class creates a pool of QNetworkAccessManager objects to manage network requests. The constructor accepts a parent object parent and a number of managers manager_count (default 1). Each manager is set to a data transfer timeout of 5000 ms and added to the network_manager_list. The getNetworkManager() method returns a randomly selected manager from this list, allowing network requests to be distributed across multiple managers for load balancing.
class OSMGraphicsView(QGraphicsView):
def __init__(self, zoom=2, parent=None):
super().__init__(parent)
self.tile_size = 256 # The size of one tile in pixels
self.zoom = zoom # Current zoom level
self.tiles = {} # Loaded tiles: key (zoom, x, y)
self.old_tiles_group = None # Group for scaling animation
self._zoom_anim = None # Link to zoom animation
self.scene = QGraphicsScene(self)
self.setScene(self.scene)
self.updateSceneRect()
self.network_manager_pool = NetworkAccessManagerPool(self, 5)
# Rendering settings
self.setRenderHint(QPainter.Antialiasing)
self.setDragMode(QGraphicsView.ScrollHandDrag)
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self.setResizeAnchor(QGraphicsView.AnchorUnderMouse)
# Initial loading of tiles
self.updateTiles()
The OSMGraphicsView class inherits from QGraphicsView and is designed to display map data based on OSM tiles. During initialization, basic parameters are set, including the tile size (256 pixels), the initial zoom level (zoom), as well as structures for storing loaded tiles (tiles) and controlling the zoom animation (_zoom_anim). A graphics scene is created using QGraphicsScene, and its size is updated using the updateSceneRect() method.
For network requests, a pool of 5 QNetworkAccessManager instances is created using the NetworkAccessManagerPool class to load tiles in parallel. Visual settings include antialiasing (Antialiasing), the ability to drag the scene (ScrollHandDrag), and setting anchor points for zooming (AnchorUnderMouse). At the end of initialization, the updateTiles() method is called to load the initial map tiles.
def loadCache(self):
self.cache = dict()
for key in redis_connection.keys():
tile_name = key.decode("utf-8")
is_valid, numbers = check_and_extract_numbers(tile_name)
if is_valid:
data = redis_connection.get(tile_name)
self.cache[tuple(numbers)] = data
print(f"The tile {tuple(numbers)} is loaded into cache ")
else:
print(f"Can't open load tile: {tile_name}")
The loadCache method loads the tile cache from Redis into the local dictionary self.cache. First, an empty dictionary is created, then all keys obtained from redis_connection.keys() are iterated over. Each key is decoded from bytes to a string, after which the check_and_extract_numbers function is used to check whether the key name matches the pattern assuming the format number_number_number_tile. If the name is valid, the data is extracted via redis_connection.get() and added to the cache with the key as a tuple of three numbers. If the load is successful, a message is printed with information about the loaded tile, and if the load fails, an error message is printed. This way, the cache contains tiles ready for use, which speeds up access to map data.
def updateSceneRect(self):
"""Updates the scene dimensions depending on the zoom level"""
size = self.tile_size * (2**self.zoom)
self.scene.setSceneRect(0, 0, size, size)
The updateSceneRect method updates the scene size depending on the current zoom level. The scene size is calculated as the product of the size of one tile (tile_size, equal to 256 pixels) and *2*zoom, which corresponds to the map size at the current zoom level, since each subsequent level doubles the map in both coordinate axes. The resulting value sets the scene boundaries using the setSceneRect method, starting from coordinates (0, 0) and up to (size, size). Thus, when the zoom level changes, the scene size is adjusted to display the map correctly.
def updateTiles(self):
"""
Determines which tiles fall within the visible area and starts loading them
"""
rect = self.mapToScene(self.viewport().rect()).boundingRect()
x_min = int(rect.left() // self.tile_size)
x_max = int(rect.right() // self.tile_size) + 1
y_min = int(rect.top() // self.tile_size)
y_max = int(rect.bottom() // self.tile_size) + 1
max_index = 2**self.zoom - 1
for x in range(x_min, x_max + 1):
if x < 0 or x > max_index:
continue
for y in range(y_min, y_max + 1):
if y < 0 or y > max_index:
continue
key = (self.zoom, x, y)
if key not in self.tiles:
self.loadTile(x, y, self.zoom)
The updateTiles method determines which map tiles should be displayed in the visible area and initiates their loading. First, the viewport’s visible area is converted to scene coordinates using mapToScene, and then the minimum and maximum x and y values are extracted from the resulting rectangle, which are calculated as an integer division of the boundary coordinates by the tile size. Next, the maximum tile index for the current zoom level is determined, equal to *(2*zoom-1), since each subsequent zoom level doubles the number of tiles. After that, the method iterates over all potential tiles, checking them for validity (coordinates should not go beyond the map boundaries) and presence in the already loaded self.tiles list. For missing tiles, the loadTile method is called, which loads the tile with the specified coordinates. Thus, updateTiles ensures that only the necessary tiles are always displayed in the visible area.
def loadTile(self, x, y, z):
"""Generates a tile URL and starts asynchronous loading"""
tile_name = f"{x}_{y}_{z}_tile"
if redis_connection.exists(tile_name):
data = redis_connection.get(tile_name)
pixmap = QPixmap()
pixmap.loadFromData(data)
item = QGraphicsPixmapItem(pixmap)
# We place the tile according to its coordinates for a given zoom
item.setPos(x * self.tile_size, y * self.tile_size)
# New tiles are drawn on top of the animated layer
item.setZValue(1)
self.scene.addItem(item)
self.tiles[(z, x, y)] = item
return
url = rnd.choice([
f"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
f"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
f"https://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
f"https://c.tile.openstreetmap.org/{z}/{x}/{y}.png",
f"https://tile.openstreetmap.de/{z}/{x}/{y}.png"
])
request = QNetworkRequest(QUrl(url))
# Set the correct User-Agent according to OSM policy
request.setRawHeader(b"User-Agent", b"OSM-Viewer/1.0 (contact@example.com)")
reply = self.network_manager_pool.getNetworkManager().get(request)
reply.finished.connect(partial(self.handleTileReply, reply, x, y, z))
The loadTile method loads and displays a map tile based on its x, y coordinates and z zoom level. First, a tile name is formed in the format “{x}{y}{z}_tile” and the data is checked for existence in the Redis cache using redis_connection.exists(). If the tile is found, the data is retrieved, loaded into a QPixmap object using loadFromData, and then displayed in the scene using a QGraphicsPixmapItem, which is positioned according to the tile coordinates and added to self.tiles. If the tile is not in the cache, a URL of one of the OpenStreetMap servers is randomly selected and a QNetworkRequest is created with the mandatory User-Agent header (as required by OSM). The request is then sent through one of the network managers from the pool obtained using the getNetworkManager() method. When a response is received, the finished signal is triggered, which is associated with the handleTileReply method, which is passed parameters for correct processing of the loaded data. Thus, the method ensures loading tiles from the cache if there is data, or from the network if there is none, with subsequent addition to the map.
def handleTileReply(self, reply, x, y, z):
"""Processes the response and adds the tile to the scene"""
err = reply.error()
if err != QNetworkReply.NetworkError.NoError:
print(f"Error {err} loading tile {z}/{x}/{y}: {reply.errorString()}")
reply.deleteLater()
return
data = reply.readAll()
pixmap = QPixmap()
pixmap.loadFromData(data)
if pixmap.isNull():
print(f"Can't load the tile ({z}/{x}/{y})")
reply.deleteLater()
return
item = QGraphicsPixmapItem(pixmap)
# We place the tile according to its coordinates for a given zoom
item.setPos(x * self.tile_size, y * self.tile_size)
# New tiles are drawn on top of the animated layer
item.setZValue(1)
self.scene.addItem(item)
self.tiles[(z, x, y)] = item
reply.deleteLater()
tile_name = f"{x}_{y}_{z}_tile"
redis_connection.set(tile_name, bytes(data))
The handleTileReply method handles the response from a network request to load a map tile. First, it checks for an error using reply.error(). If an error occurs, an error message is printed and the response object is freed using deleteLater(). If there is no error, the tile data is read using readAll() and loaded into a QPixmap object. If the image could not be loaded (empty tile), an error message is printed and the response is deleted. If the loading was successful, a QGraphicsPixmapItem is created, placed in the scene according to the tile coordinates, set to the top layer using setZValue(1) and added to self.tiles with the key (z, x, y). Finally, the method saves the loaded data in the Redis cache with a key corresponding to the format “{x}{y}{z}_tile” to make preloading this tile faster.
def clearOldTilesGroup(self):
"""Removes an animated group of old tiles after the animation is complete"""
if self.old_tiles_group:
self.scene.removeItem(self.old_tiles_group)
self.old_tiles_group = None
self._zoom_anim = None
The clearOldTilesGroup method removes the old tile group from the scene after the zoom animation is complete. It checks if the self.old_tiles_group object exists, and if so, removes it from the scene using the removeItem() method, and then assigns None to the self.old_tiles_group variable to clear the reference to the old group. It also resets the reference to the zoom animation by setting self._zoom_anim = None. This method helps to avoid unnecessary elements in the scene and frees up resources after changing the zoom level.
def onZoomAnimValueChanged(self, value):
"""Slot that updates the group scale during animation"""
if self.old_tiles_group:
self.old_tiles_group.setScale(value)
The onZoomAnimValueChanged method is a slot that is called when the value changes during the zoom animation. It receives the current scale factor value and, if there is an old tile group (self.old_tiles_group), applies the scale to it using the setScale(value) method. This way, when the animation value changes, the old tiles are smoothly zoomed in or out, providing a visually pleasing map zoom effect.
def wheelEvent(self, event):
"""
When scrolling the mouse wheel, smooth scaling is performed
Current tiles are grouped and animated, and new ones are loaded in parallel
"""
delta = event.angleDelta().y()
old_zoom = self.zoom
if delta > 0:
new_zoom = min(self.zoom + 1, 19)
else:
new_zoom = max(self.zoom - 1, 0)
if new_zoom == old_zoom:
return
# If the zoom animation is already running, we finish it
if (
self._zoom_anim is not None
and self._zoom_anim.state() == QVariantAnimation.Running
):
self._zoom_anim.stop()
self.clearOldTilesGroup()
# Scaling factor (eg 2 to increase by 1 level)
factor = pow(2, new_zoom - old_zoom)
cursor_scene_pos = self.mapToScene(event.position().toPoint())
# Grouping current tiles for animation
if self.tiles:
items = list(self.tiles.values())
self.old_tiles_group = self.scene.createItemGroup(items)
self.old_tiles_group.setZValue(0)
origin = self.old_tiles_group.mapFromScene(cursor_scene_pos)
self.old_tiles_group.setTransformOriginPoint(origin)
# Using QVariantAnimation for Smooth Scaling
self._zoom_anim = QVariantAnimation(self)
self._zoom_anim.setDuration(300) # animation duration in ms
self._zoom_anim.setStartValue(1.0)
self._zoom_anim.setEndValue(factor)
self._zoom_anim.valueChanged.connect(self.onZoomAnimValueChanged)
self._zoom_anim.finished.connect(self.clearOldTilesGroup)
self._zoom_anim.start()
# Update zoom and scene sizes
self.zoom = new_zoom
self.updateSceneRect()
new_center = cursor_scene_pos * factor
self.centerOn(new_center)
# Clear old tiles; new ones will be loaded for the new zoom
self.tiles.clear()
self.updateTiles()
The wheelEvent method handles the mouse wheel scroll event, performing a smooth map zoom using animation. When scrolling up or down, the zoom change is calculated: a positive wheel value increases the zoom to a maximum of 19, a negative wheel value decreases the zoom to a minimum of 0. If the new zoom level matches the old one, the method exits.
If the zoom animation is already running at the time of scrolling, it is forcibly stopped and the group of old tiles is deleted via clearOldTilesGroup. Next, the zoom factor is calculated, equal to 2^(new_zoom-old_zoom), and the cursor position in scene coordinates is determined, which is used as the focus point.
If tiles are currently displayed, they are grouped into a single object using createItemGroup, and a pivot point is set for this group corresponding to the cursor position. Then a scaling animation is created using QVariantAnimation, which changes the scale of the tile group from 1.0 to the calculated factor value in 300 ms. The animation calls the onZoomAnimValueChanged method for each value change to smoothly change the scale, and upon completion, it calls the clearOldTilesGroup method to remove old tiles.
After the animation starts, the current zoom level and scene dimensions are updated via updateSceneRect. The camera is centered at a new position calculated based on the cursor position, taking into account the new zoom. At the end, the method clears the self.tiles dictionary with the tiles that are no longer relevant and initiates loading new ones using updateTiles. Thus, scaling occurs smoothly, while maintaining the focus point at the cursor location.
def resizeEvent(self, event):
super().resizeEvent(event)
self.updateTiles()
def mouseMoveEvent(self, event):
super().mouseMoveEvent(event)
self.updateTiles()
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
self.updateTiles()
The methods resizeEvent, mouseMoveEvent, and mouseReleaseEvent ensure the map tiles are updated when the window is resized, the mouse is moved, or the mouse button is released. The resizeEvent method is triggered when the window size changes, calling updateTiles() to recalculate the visible map area and load missing tiles. The mouseMoveEvent is invoked when the mouse moves, running updateTiles() to load new tiles as the map is dragged. Finally, the mouseReleaseEvent is called when the mouse button is released, with updateTiles() ensuring all necessary tiles are loaded after the map movement is complete.
Thus, these methods ensure that new tiles are loaded dynamically when the window is resized, the map is moved, and the dragging process is completed.
redis_connection = redis.Redis(host='127.0.0.1', port=6379, db=0)
To connect to Redis, we will use the redis library. The Redis server is deployed using Docker. An example can be found at the link .
def check_and_extract_numbers(filename):
# Template for file name validation
pattern = r'^(\d+)_(\d+)_(\d+)\_tile$'
# Checking if the file name matches the pattern
match = re.match(pattern, filename)
if match:
# If it matches, we extract the numbers
numbers = match.groups()
return True, [int(v) for v in numbers]
else:
# If it doesn't match, return False and an empty list.
return False, []
The check_and_extract_numbers function checks whether a file name matches a pattern of the form number_number_number_tile using the regular expression ^(\d+)(\d+)(\d+)_tile$. If the file name matches the pattern, the function extracts the three numbers, converts them to integers, and returns a tuple of (True, [number1, number2, number3]). If the name does not match, it returns (False, []).
For example, for the file 123_456_789_tile the result is (True, [123, 456, 789]), and for abc_456_789_tile the result is (False, []).
Next code block serves as the entry point for running the application and creating an interface to view maps using OpenStreetMap.
if __name__ == "__main__":
app = QApplication(sys.argv)
view = OSMGraphicsView(zoom=2)
view.setWindowTitle("OpenStreetMap Viewer")
view.resize(800, 600)
view.show()
sys.exit(app.exec())
First, it initializes a Qt application with QApplication(sys.argv), passing command-line arguments for potential configuration. Next, it creates a map view object with view = OSMGraphicsView(zoom=2), setting an initial zoom level. The window is then configured with setWindowTitle, resize(800, 600) to set the size to 800x600 pixels, and then show() is called to display it.
Finally, the application’s main loop starts with sys.exit(app.exec()), keeping the app running until the window is closed.
When the application is launched, a window is displayed with a set of tiles according to the current coordinates (picture below).

Displaying loaded tiles on the view
In result: when you launch the program, a window appears with an interactive OpenStreetMap, which supports zooming using the mouse wheel, moving using drag and drop, and dynamic loading of tiles from OSM servers or from the Redis cache.
Just in case, I’m attaching a link to the repository.
메타데이터
- post_id
- 5ca2b471cc1b
- slug
- displaying-openstreetmap-tiles-using-pyside6-5ca2b471cc1b
- url
- https://medium.com/@sm.malichenko/displaying-openstreetmap-tiles-using-pyside6-5ca2b471cc1b
- canonical_url
- https://medium.com/@sm.malichenko/displaying-openstreetmap-tiles-using-pyside6-5ca2b471cc1b
- author_url
- https://medium.com/@sm.malichenko
- status
- ok
- fetched_at
- 2026-06-13 07:35:29