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.
Step-by-Step Implementation
Step 1 — Fix an Extent and Size Per Task
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.
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.
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.
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.
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
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.
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.
Related
- Serving Imagery Tiles to Annotation Tools — the service this guide consumes, including the render profile that must stay fixed
- Integrating Label Studio with Geospatial Workflows — the wider platform setup, labelling config and webhooks
- Converting Label Studio Exports to YOLOv8 Format — the other conversion the same percentages feed
This task setup is one piece of the Serving Imagery Tiles to Annotation Tools topic within Labeling Workflows & Toolchain Integration.