Adding an XYZ Tile Layer to Label Studio Tasks

Label Studio annotates a fixed image and returns coordinates as percentages of it. A tile service serves an arbitrary window of a georeferenced archive. Bridging the two takes one decision — fix an extent and a zoom per task — and one discipline: write the georeference into the task data, because the export will not carry it. Do both and a batch of tasks can point at a 2 TB archive without a single chip being copied, and every returned polygon converts back to ground coordinates exactly. This guide gives the task JSON, both conversions, and the assertion that proves the setup before a batch is created.

Why This Matters in Geospatial Pipelines

The naive alternative — export a chip per task, upload it, annotate it — duplicates the archive and loses the georeference at the upload boundary. Teams then reconstruct it from filenames, which works until a scene is renamed. Serving the pixels from the tile service and carrying the georeference in the task payload keeps one copy of the imagery and makes the coordinate contract explicit rather than conventional.

Three coordinate spaces, two conversions The task fixes a bounding box in a projected CRS and a pixel size. Label Studio returns a box as percentages of the image. Multiplying by the pixel size gives pixels; mapping pixels linearly through the stored bounding box gives ground coordinates. Both conversions use only values written into the task at creation time. percentages x 30.0 y 20.0 w 25.0 h 40.0 what the export contains × size pixels 307.2, 204.8 256.0 × 409.6 on a 1024 × 1024 render × bbox ground coordinates 512 432.6, 5 401 795.2 EPSG:25832 what the dataset stores both arrows use only values written into the task at creation bbox, CRS, image size and render profile — none of which the export carries by itself

Step-by-Step Implementation

Step 1 — Fix an Extent and Size Per Task

bash
pip install label-studio-sdk==1.0.10 geopandas==0.14.4 pyproj==3.6.1 httpx==0.27.0

Choose a ground extent per task — usually one tile of your working grid — and a render size. Both are then immutable properties of the task.

python
from dataclasses import dataclass, asdict

@dataclass(frozen=True)
class TaskFrame:
    """The immutable frame a task's annotations are expressed against."""
    tile_id: str
    bbox: tuple[float, float, float, float]   # minx, miny, maxx, maxy in `crs`
    crs: str                                  # e.g. "EPSG:25832"
    width_px: int
    height_px: int
    profile: str                              # the render profile, part of provenance

def frame_for_tile(tile_id: str, bounds, crs: str, px: int = 1024) -> TaskFrame:
    minx, miny, maxx, maxy = bounds
    return TaskFrame(tile_id, (minx, miny, maxx, maxy), crs, px, px, "pleiades_rgb")

Keeping the frame square and the extent square keeps the pixel-to-ground scale identical on both axes, which removes an entire class of conversion bug.

Step 2 — Build the Task Payload

The tiler’s bbox endpoint renders an arbitrary extent to an arbitrary size, which is exactly what a fixed-frame task needs.

python
def image_url(base: str, scene_uri: str, f: TaskFrame) -> str:
    minx, miny, maxx, maxy = f.bbox
    return (f"{base}/cog/bbox/{minx},{miny},{maxx},{maxy}/{f.width_px}x{f.height_px}.png"
            f"?url={scene_uri}&coord-crs={f.crs}&bidx=1&bidx=2&bidx=3"
            f"&rescale=0,3000&resampling=bilinear")

def task_payload(base: str, scene_uri: str, f: TaskFrame) -> dict:
    """One Label Studio task: an image plus every value needed to invert the coordinates."""
    return {
        "data": {
            "image": image_url(base, scene_uri, f),
            "frame": asdict(f),          # the georeference travels with the task
            "scene_uri": scene_uri,
        }
    }

Storing the whole frame in data rather than only a tile id is the difference between an export that can be converted by itself and one that needs a lookup against a database whose contents may have moved on.

Three values in the task, three failures prevented The bounding box and CRS prevent annotations landing in the wrong place when the tiler's defaults change. The pixel size prevents a rescaled render silently changing the conversion. The render profile records what the annotator was actually looking at, which is the first question asked when a batch turns out to be systematically poor. stored in the task what it prevents bbox + crs a tiler default change moving every annotation width_px + height_px a rescaled render silently altering the conversion profile not knowing what the annotator was looking at the first three are geometry; the fourth is provenance, and it is the one people leave out until a batch turns out to be systematically poor and nobody can say why

Step 3 — Convert the Export Back to Ground Coordinates

Label Studio returns x, y, width, height as percentages, with the origin at the top-left of the image and y increasing downward. Ground northing increases upward, so the vertical axis flips.

python
from shapely.geometry import box
from shapely.geometry.base import BaseGeometry

def result_to_world(result: dict, f: TaskFrame) -> BaseGeometry:
    """One Label Studio rectangle result → a polygon in the frame's CRS."""
    v = result["value"]
    px_x = v["x"] / 100.0 * f.width_px
    px_y = v["y"] / 100.0 * f.height_px
    px_w = v["width"] / 100.0 * f.width_px
    px_h = v["height"] / 100.0 * f.height_px

    minx, miny, maxx, maxy = f.bbox
    sx = (maxx - minx) / f.width_px
    sy = (maxy - miny) / f.height_px

    world_minx = minx + px_x * sx
    world_maxx = minx + (px_x + px_w) * sx
    world_maxy = maxy - px_y * sy              # top-left pixel is the HIGHEST northing
    world_miny = maxy - (px_y + px_h) * sy
    return box(world_minx, world_miny, world_maxx, world_maxy)

The two lines using maxy are where most implementations go wrong. Copying the pixel minimum into the world minimum produces annotations mirrored about the tile’s horizontal centre line — a failure that looks plausible on a single square building and is glaring on a row of terraces.

Step 4 — Assert the Roundtrip Before Creating a Batch

python
def assert_frame_roundtrips(f: TaskFrame, tol_m: float = 0.01) -> None:
    """A full-frame annotation must convert back to the frame's own bounding box."""
    full = {"value": {"x": 0.0, "y": 0.0, "width": 100.0, "height": 100.0}}
    got = result_to_world(full, f).bounds
    want = f.bbox
    for g, w, axis in zip(got, want, "xyXY"):
        if abs(g - w) > tol_m:
            raise AssertionError(f"{axis} off by {abs(g - w):.3f} m — check the y-axis flip")

Run it once per frame shape, not per task. It catches the axis flip, a transposed width and height, and a CRS mismatch between the frame and the tiler’s coord-crs, which are the three failures that otherwise surface as a whole batch of subtly wrong labels.

The axis flip, and what forgetting it looks like An annotation near the top of the image should convert to the north of the tile, because pixel rows increase southward while northing increases upward. Copying pixel minimum to world minimum instead mirrors every annotation about the tile's horizontal centre line, which reads as plausible on one square building and is obvious on a row of terraces. image space y = 14% row 0 at the top with the flip — correct north of the tile maxy at the top without it — mirrored south of the tile every feature reflected the full-frame assertion in Step 4 catches this in one call, before a batch of tasks exists

Parameters and Thresholds Reference

Setting Typical Note
Task extent one working-grid tile Matching the grid keeps task ids and tile ids the same thing
Render size 1024 × 1024 px Above 2048 the browser gets slow on polygon-heavy tasks
Ground resolution ≈ native GSD Rendering above native resolution annotates interpolation
coord-crs the frame’s CRS Must match the bbox you pass, or the render silently shifts
Roundtrip tolerance 0.01 m Anything larger means a scale or flip error, not float noise

Common Errors and Fixes

Every annotation is mirrored north–south Root cause: the vertical flip in Step 3 was skipped. Fix: derive the world maximum from maxy − px_y * sy, and add the Step 4 assertion so it cannot regress.

Features land hundreds of kilometres away Root cause: the bbox was passed in the frame’s CRS but the tiler defaulted to interpreting it as WGS84. Fix: always send coord-crs explicitly and store the same value in the frame.

Annotations are systematically half a tile off Root cause: the render size and the extent have different aspect ratios, so sx and sy differ and one axis is stretched. Fix: keep both square, or verify the roundtrip on a non-square frame before using it.

The image loads in the browser but not in Label Studio Root cause: the tile service requires a token the platform does not send. Fix: use a signed URL per task, or allow the platform’s origin — the trade-offs are in the parent topic.

Frequently Asked Questions

# Can one task show several bands or dates?

Yes — create one task per rendering and link them by tile id, or use a multi-image labelling config. What must not happen is one task whose image URL changes over time, because the annotations already stored against it were expressed against the old pixels.

# How do polygons differ from rectangles in the conversion?

Only in shape. A polygon result carries a list of percentage points; map each point through the same two conversions and build the ring. The axis flip applies identically, per point.

# Does this scale to tens of thousands of tasks?

The task creation is a bulk import and is not the bottleneck; the tile service is. Create tasks in batches and let the cache warm naturally as annotators work, rather than pre-rendering every frame at import time.

# Where should the frame live if we later move to CVAT?

In the same place — attached to the task. CVAT’s export is in pixels rather than percentages, which removes one multiplication and changes nothing else; the CVAT setup guide covers the manifest form it uses.

This task setup is one piece of the Serving Imagery Tiles to Annotation Tools topic within Labeling Workflows & Toolchain Integration.