While recently researching how to load Bing Maps in ArcGIS Pro, the author conducted a simple packet capture of Bing Maps and discovered an unusual phenomenon. When requesting tile data, Bing Maps uses a request format that is noticeably different from common approaches. For example, services like Gaode Map typically pass xyz parameters, which correspond to x and y coordinates and the zoom level z. In contrast, Bing Maps passes a single integer parameter instead of three separate xyz values. How does this work? The image below shows a packet capture of Bing Maps requesting remote sensing imagery.

The image below shows a packet capture of Gaode Map requests.

Clearly, the request format of Bing Maps differs from that of Gaode Map. Bing Maps uses an integer parameter, while Gaode Map uses xyz parameters. So how is the Bing Maps request format generated?

Principles of the Quadkeys Algorithm

This brings us to the Quadkeys algorithm used by Bing Maps. According to the official documentation (https://learn.microsoft.com/en-us/bingmaps/articles/bing-maps-tile-system?redirectedfrom=MSDN), the core idea is to convert the three-dimensional xyz parameters into a one-dimensional parameter, somewhat similar to Geohash. How is this conversion done?

Let's use the official diagram as an example:

Suppose we want to represent the point marked by the author. Using the xyz method, it would be (3, 5, 3), meaning x=3, y=5, z=3. How would it be represented using the Quadkeys algorithm?

Step 1: Convert x and y into binary numbers. First, convert x to binary: 011. Why 011 and not 11? The number of bits must match the zoom level z, padding with zeros if necessary. Then convert y to binary: 101.

Step 2: Perform interleaved encoding, starting with y followed by x, resulting in the code 10011.

Step 3: Convert the code 10011 into a base-4 number, resulting in the code 213.

Characteristics of the Quadkeys Algorithm

The Quadkeys algorithm has three important features:

  1. The length of the Quadkey equals the zoom level of the corresponding tile. For example, the length of 213 is 3, corresponding to zoom level 3.
  2. The beginning of the Quadkey string corresponds to the encoding of the parent tile. Referring to the official diagram, tile 2 is the parent of tiles 20 to 23, and tile 13 is the parent of tiles 130 to 133.
  3. Two tiles with adjacent XY coordinates typically have closely related encodings. This is important for optimizing database performance, as tiles are often requested in batches. Storing these tiles in the same disk blocks minimizes the number of disk reads.

Implementation of the Quadkeys Algorithm

Understanding the algorithm itself is sufficient. The official implementation is provided in C#, but the author used AI to create a Python version. The code is as follows:

import math

class TileSystem:
    EARTH_RADIUS = 6378137
    MIN_LATITUDE = -85.05112878
    MAX_LATITUDE = 85.05112878
    MIN_LONGITUDE = -180
    MAX_LONGITUDE = 180

    @staticmethod
    def clip(n: float, min_value: float, max_value: float) -> float:
        """Clips a number to the specified minimum and maximum values."""
        return min(max(n, min_value), max_value)

    @staticmethod
    def map_size(level_of_detail: int) -> int:
        """Determines the map width and height (in pixels) at a specified level of detail."""
        return 256 << level_of_detail

    @staticmethod
    def ground_resolution(latitude: float, level_of_detail: int) -> float:
        """Determines the ground resolution (in meters per pixel) at a specified latitude and level of detail."""
        latitude = TileSystem.clip(latitude, TileSystem.MIN_LATITUDE, TileSystem.MAX_LATITUDE)
        return (math.cos(latitude * math.pi / 180) *
                2 * math.pi * TileSystem.EARTH_RADIUS / TileSystem.map_size(level_of_detail))

    @staticmethod
    def map_scale(latitude: float, level_of_detail: int, screen_dpi: int) -> float:
        """Determines the map scale at a specified latitude, level of detail, and screen resolution."""
        return TileSystem.ground_resolution(latitude, level_of_detail) * screen_dpi / 0.0254

    @staticmethod
    def latlong_to_pixel_xy(latitude: float, longitude: float, level_of_detail: int):
        """Converts latitude/longitude WGS-84 coordinates into pixel XY coordinates."""
        latitude = TileSystem.clip(latitude, TileSystem.MIN_LATITUDE, TileSystem.MAX_LATITUDE)
        longitude = TileSystem.clip(longitude, TileSystem.MIN_LONGITUDE, TileSystem.MAX_LONGITUDE)

        x = (longitude + 180) / 360
        sin_latitude = math.sin(latitude * math.pi / 180)
        y = 0.5 - math.log((1 + sin_latitude) / (1 - sin_latitude)) / (4 * math.pi)

        map_size = TileSystem.map_size(level_of_detail)
        pixel_x = int(TileSystem.clip(x * map_size + 0.5, 0, map_size - 1))
        pixel_y = int(TileSystem.clip(y * map_size + 0.5, 0, map_size - 1))

        return pixel_x, pixel_y

    @staticmethod
    def pixel_xy_to_latlong(pixel_x: int, pixel_y: int, level_of_detail: int):
        """Converts pixel XY coordinates into latitude/longitude WGS-84 coordinates."""
        map_size = float(TileSystem.map_size(level_of_detail))
        x = (TileSystem.clip(pixel_x, 0, map_size - 1) / map_size) - 0.5
        y = 0.5 - (TileSystem.clip(pixel_y, 0, map_size - 1) / map_size)

        latitude = 90 - 360 * math.atan(math.exp(-y * 2 * math.pi)) / math.pi
        longitude = 360 * x

        return latitude, longitude

    @staticmethod
    def pixel_xy_to_tile_xy(pixel_x: int, pixel_y: int):
        """Converts pixel XY coordinates into tile XY coordinates."""
        tile_x = pixel_x // 256
        tile_y = pixel_y // 256
        return tile_x, tile_y

    @staticmethod
    def tile_xy_to_pixel_xy(tile_x: int, tile_y: int):
        """Converts tile XY coordinates into pixel XY coordinates."""
        pixel_x = tile_x * 256
        pixel_y = tile_y * 256
        return pixel_x, pixel_y

    @staticmethod
    def tile_xy_to_quadkey(tile_x: int, tile_y: int, level_of_detail: int) -> str:
        """Converts tile XY coordinates into a QuadKey."""
        quadkey = []
        for i in range(level_of_detail, 0, -1):
            digit = 0
            mask = 1 << (i - 1)
            if (tile_x & mask) != 0:
                digit += 1
            if (tile_y & mask) != 0:
                digit += 2
            quadkey.append(str(digit))
        return ''.join(quadkey)

    @staticmethod
    def quadkey_to_tile_xy(quadkey: str):
        """Converts a QuadKey into tile XY coordinates and level of detail."""
        tile_x = tile_y = 0
        level_of_detail = len(quadkey)
        for i in range(level_of_detail, 0, -1):
            mask = 1 << (i - 1)
            digit = quadkey[level_of_detail - i]
            if digit == '1':
                tile_x |= mask
            elif digit == '2':
                tile_y |= mask
            elif digit == '3':
                tile_x |= mask
                tile_y |= mask
            elif digit != '0':
                raise ValueError("Invalid QuadKey digit sequence.")
        return tile_x, tile_y, level_of_detail

Usage example:

if __name__ == "__main__":
    lat, lon = 47.60357, -122.32945  # Seattle
    level = 15

    px, py = TileSystem.latlong_to_pixel_xy(lat, lon, level)
    tx, ty = TileSystem.pixel_xy_to_tile_xy(px, py)
    quadkey = TileSystem.tile_xy_to_quadkey(tx, ty, level)

    print(f"LatLon=({lat}, {lon})")
    print(f"Pixel=({px}, {py})  Tile=({tx}, {ty})")
    print(f"QuadKey={quadkey}")

    # Reverse conversion verification
    txx, tyy, lvl = TileSystem.quadkey_to_tile_xy(quadkey)
    print(f"QuadKey -> Tile=({txx}, {tyy}), Level={lvl}")

PS: The above code was generated by AI. Please test it yourself. For other versions, you can use AI to generate them.

Summary

From the above explanation, it is clear that the QuadKey algorithm and the xyz algorithm are interchangeable. This is the core reason why the functionality in the author's previous article, "ArcGIS Pro Add Bing Maps Basemap", was successfully implemented.

Have you used QuadKey in your projects? Feel free to share more experiences.