Skip to content

Prepare labels

mapcv burns polygons from a label file (or several, or OpenStreetMap) into the masks, or reads the classes from a classified raster (a land-cover map, an earlier model’s output). This page covers what a polygon file must look like, how to export it from common tools and how class IDs are chosen, then raster labels.

Requirement
File type .geojson, .json, .kml, .gpkg (GeoPackage), .shp (Shapefile) or .parquet/.geoparquet (GeoParquet). Not .kmz (unzip it, below). The suffix picks the reader
Coordinates GeoJSON and KML are WGS-84 longitude, latitude (EPSG:4326), in that order, as RFC 7946 requires. GeoPackage, Shapefile and GeoParquet may be in any CRS they state; mapcv reprojects them to lon/lat
Geometry Polygon and MultiPolygon, including those inside a GeometryCollection (nested ones too). Points and lines are skipped with a warning (points are kept for detection with point_box_size)
GeoJSON shape A FeatureCollection or a single Feature. A crs member is allowed only if it names WGS-84 (CRS84, EPSG:4326); projected CRSs are rejected
KML <Polygon> and <MultiGeometry> placemarks; attributes from <ExtendedData> <Data name="…"> or <SimpleData name="…"> (what QGIS and ogr2ogr write), not from the placemark’s <name>. The file may be UTF-8, UTF-16 or UTF-32 (with a byte order mark), or ISO-8859-1 or windows-1252 as its XML declaration says; another encoding is an error that asks for UTF-8
GeoPackage One feature table (a layer). With several, set labels.layer. The layer’s CRS comes from gpkg_spatial_ref_sys; a layer without one (srs_id 0 or -1) is an error. A GeoPackage in WAL mode (QGIS and GDAL leave them so) in a read-only folder, such as a read-only mount, is read as it is; if it has changes in a -wal file next to it that are not merged into it yet, mapcv says so instead of reading stale data
Shapefile The .shp with its .dbf (attributes) next to it. The CRS comes from the .prj: without one mapcv stops rather than guess. Text attributes are read as UTF-8 unless a .cpg file names another encoding. The reader is pure Python: a Shapefile with hundreds of thousands of features loads in tens of seconds, so for very large layers a GeoPackage or GeoParquet is faster
GeoParquet WKB geometry (the default of geopandas and GDAL), with the standard geo metadata. Needs pip install "mapcv[parquet]". A missing crs means WGS-84 (OGC:CRS84, as the specification says); crs: null is an error. Native (GeoArrow) encodings are not read

mapcv reprojects the polygons into the imagery’s CRS itself (Web Mercator for XYZ, the product’s UTM zone for Sentinel-2, the file’s own CRS for a GeoTIFF), so never reproject labels to match the imagery.

For a GeoPackage, Shapefile or GeoParquet file, mapcv reads the CRS the file declares and reprojects the coordinates to WGS-84 with pyproj, always taking x as easting/longitude and y as northing/latitude, whatever order the CRS authority lists (EPSG:4326 and EPSG:4269 are lat/lon on paper but stored lon/lat in these formats). A file with no stated CRS is an error, not a guess: wrongly assuming WGS-84 would shift every label by hundreds of kilometres without any warning. Fix it by putting the real CRS in the file:

  • Shapefile without .prj: in QGIS, Layer → Set Layer CRS, then export again; or ogr2ogr -a_srs EPSG:32633 fixed.shp broken.shp (use the CRS the coordinates are really in).
  • GeoPackage without a CRS (srs_id 0 or -1) and GeoParquet with crs: null: the same, or re-write the file with the right CRS (gdf.set_crs(32633).to_parquet(...)).

Coordinates that cannot be reprojected (outside the CRS’s area of use) are an error too.

labels:
path: landuse.geojson # relative to this config file's folder; .gpkg, .shp and .parquet work too
label_field: class # property or column holding the class; omit for "every polygon is 1"
layer: null # GeoPackage only: the layer (table) to read when the file has several
classes: # optional: pin IDs (needs label_field)
built_up: 1
farmland: 2
forest: 3
water: 4
all_touched: false # true also burns pixels the outline merely touches
  1. No label_field: every polygon is 1; class_map in the manifest is empty. Good for one-class problems such as buildings.
  2. label_field with integer labels 1–255: the integers are the IDs. "3", 3 and 3.0 are the same class. Only the digits 0–9 make a number: a full-width 1 or a superscript ² is a name.
  3. label_field with any other labels: distinct values are sorted and numbered 1, 2, 3, …: numbers by value (2 before 10), then names in alphabetical order. The result does not depend on feature order, so two files with the same classes get the same IDs.
  4. classes given: exactly those IDs (1–255); features whose label is not listed are skipped. Use it to merge classes ({house: 1, apartments: 1}), to keep IDs stable across datasets, or to match an existing model.

The rules are the same for every file format. Mask value 0 is always background. More than 255 distinct labels is an error; map them down with classes. Class 255 is also labels.ignore_index by default, so without classes at most 254 labels fit (and a label 255 is its own ID, which clashes too): mapcv plan stops with the same message as generate; list the labels to keep in classes, or set ignore_index: null. With labels.files, classes also settles the limit: a file’s values are looked up there, and the ones it does not list are skipped, whatever number each file has.

Always check the result with mapcv plan, which prints the class map and any skipped features before anything is downloaded:

│ Labels landuse.geojson · 643 polygons · classes: built_up → 1, farmland → 2, │
│ forest → 3, water → 4 │

Features are skipped, and counted in a warning, when they have no polygon geometry, no value in label_field, a value missing from classes, or a coordinate that is not a number (NaN, infinite) or a latitude beyond ±90°:

⚠ GeoJSON: skipped 1 without polygon geometry (points/lines), 1 without a label value.

The warning starts with the format (GeoJSON, KML, GeoPackage, Shapefile, GeoParquet). A feature with no geometry at all (a null geometry) is ignored without being counted. A label_field that no feature has (not a column of a GeoPackage, Shapefile or GeoParquet file, or a property of no GeoJSON or KML feature) is an error that lists the fields there are and suggests the closest, since it can only be a typo; a feature without the property is skipped as having no label. A GeoJSON file that is not valid GeoJSON (a feature that is not an object, an unknown geometry type, coordinates nested wrongly) is an error that names the feature.

Roads, rivers and trees: buffer lines and points

Section titled “Roads, rivers and trees: buffer lines and points”

Lines and points have no area, so they are skipped unless you give them one. labels.buffer turns a line into a band of the given width and a point into a disc of the given diameter, both in metres on the ground (drawn in the UTM zone of each feature; the parts of a multi-part feature that lie in different zones each in their own), before they are rasterized like polygons:

labels:
path: osm.geojson # roads as lines, trees as points, fields as polygons
label_field: kind
classes: {field: 1, road: 2, tree: 3}
buffer: {line: 6, point: 8}

Pick widths you would draw by hand: a two-lane road is about 6–8 m, a footpath 2 m, a tree crown a few metres. Polygons are never buffered. KML lines and points are not read, so convert KML to GeoJSON first.

Labels often come from several sources: buildings from a cadastre, roads from OpenStreetMap, land use from a national map. List them under labels.files instead of merging them first; each file says where its classes come from, and later files are drawn over earlier ones:

labels:
files:
- {path: landuse.gpkg, label_field: landuse}
- {path: buildings.geojson, class: building}
- {path: osm_roads.geojson, class: road, buffer: {line: 6}}
classes: {building: 1, road: 2, forest: 3, crop: 4} # optional; else sorted names

A class name gets one ID across all files. Names that classes does not list are skipped with a warning. Editing any of the files makes a resumed run stop, as editing a single label file does.

When only part of a scene was labeled, the unlabeled rest is not “background”, and training on it as background teaches the model to miss objects. Outline the part you labeled with labels.annotated_area, and mask pixels outside it get ignore_index (255 by default) instead:

labels:
path: buildings.geojson
annotated_area: surveyed_blocks.geojson # polygons; their attributes are not used

Your loss then skips those pixels like pixels without imagery (CrossEntropyLoss(ignore_index=255)). With task: classification they do not count towards a class’s coverage. A patch entirely outside the area keeps an all-ignored mask; drop such patches with sampler.min_label_ratio.

A GeoPackage may hold several feature tables. With one, mapcv reads it; with more, name the one with the labels, or plan stops and lists them:

labels:
path: city.gpkg
layer: buildings
label_field: kind
Cannot plan this config: city.gpkg has 3 layers: buildings, landuse, roads. Set labels.layer to the one with the labels.

labels.layer is only for .gpkg files. mapcv init asks which layer to use.

Where polygons overlap, the feature that comes later in the file wins. Put fine classes after coarse ones (for example buildings after land use) if they overlap.

  1. Right-click the layer → Export → Save Features As…
  2. Format GeoJSON (or GeoPackage, ESRI Shapefile, Keyhole Markup Language [KML]).
  3. CRS EPSG:4326 - WGS 84 for GeoJSON and KML. GeoPackage and Shapefile can keep the layer’s own CRS: mapcv reads it from the file.
  4. Keep the attribute you want as label_field among the exported fields.

QGIS writes KML attributes as <SimpleData>, which mapcv reads. A Shapefile export writes the .prj and .cpg mapcv needs next to the .shp: keep the files together.

OpenStreetMap is a common source of building, road and land-use polygons. Its data is licensed under the ODbL: credit “© OpenStreetMap contributors” when you share a dataset made from it.

Straight from the config. labels.osm asks the Overpass API for the region’s features and turns them into labels, cached on disk (reference):

labels:
osm:
classes:
- {name: building, tags: {building: "*"}}
- {name: road, tags: {highway: [primary, secondary, residential]}}
buffer: {line: 6}

For larger areas, or to keep a file under version control, export a GeoJSON yourself and use labels.path. Some ways to get one:

  • osmnx (Python), for example:

    import osmnx as ox
    # bbox is (west, south, east, north) in osmnx 2.x
    gdf = ox.features_from_bbox(bbox=(4.9375, 52.3725, 4.9515, 52.3780), tags={"building": True})
    gdf = gdf[gdf.geom_type.isin(["Polygon", "MultiPolygon"])][["building", "geometry"]]
    gdf.to_file("buildings.geojson", driver="GeoJSON")
  • Overpass Turbo in the browser: run a query, then Export → GeoJSON.

  • QuackOSM or osmium for large areas from a .osm.pbf extract, without hammering public APIs.

The example label files in the repository were made this way; see examples/.

Many datasets start from an existing classified raster: ESA WorldCover (10 m, global), Dynamic World (10 m, near real time), a national land-cover map, or the predictions of an earlier model. Set labels.type: raster and map its values to mask IDs:

labels:
type: raster
path: ESA_WorldCover_10m_2021_v200_N48E000_Map.tif # local, https:// or anonymous s3://
classes: # raster value -> {id: mask ID, name: class name}
10: {id: 1, name: tree_cover}
20: {id: 2, name: shrubland}
30: {id: 3, name: grassland}
40: {id: 4, name: cropland}
50: {id: 5, name: built_up}
60: {id: 6, name: bare}
80: {id: 7, name: water}
90: {id: 8, name: wetland}
95: {id: 8, name: wetland} # mangroves merged into wetland
unmapped: background # snow (70), moss (100): background | ignore
  • Class IDs. classes maps each raster value to a mask ID from 0 to 255; 0 is background. Several values can share an ID (to merge classes); the short form 10: 1 names the class value_10. The names become the manifest’s class_map.
  • Values not listed become background (unmapped: background, the default, as features missing from classes do with polygon labels) or ignore_index (unmapped: ignore), so a loss skips them.
  • No label. The file’s NoData value, ignore_values (for example a “not classified” code) and every pixel outside the raster get ignore_index (255), like pixels without imagery. Set nodata: to override the file’s NoData value. A value that classes maps to a class is that class even when the file tags it as NoData (with a warning).
  • Integer values only. uint8, uint16, int16, … in one band (band: picks another). A float raster (probabilities) must be turned into classes first.

mapcv plan opens the raster’s header (no pixels) and prints its CRS, size and pixel size with the class map, and warns when it does not overlap the region. mapcv init recognises a .tif label file, lists the values under your area and writes a classes block to rename.

The label raster does not need the imagery’s CRS, resolution or origin; it is read on the imagery’s pixel grid:

  1. For each imagery pixel, mapcv takes the pixel’s centre and projects it exactly into the label raster’s CRS (Web Mercator tiles and a WorldCover tile in EPSG:4326, for example).
  2. The label pixel that contains that point gives the value (nearest neighbour). Labels are categories, so values are never averaged or interpolated, whatever the resolutions: a 10 m label pixel covers about 100 pixels of 1 m imagery, and a 0.5 m label raster is sampled once per 1 m pixel.
  3. A centre exactly on the boundary between two label pixels takes the right / lower one, as GDAL does. PixelIsPoint files are handled as in GDAL.

This is what rasterio.warp.reproject(..., resampling=Resampling.nearest) gives onto each patch’s transform, without GDAL’s default approximation of the transformation; mapcv’s tests check every mask against it, including rotated imagery grids and labels in another CRS. When the label raster was made on the imagery’s grid (same CRS, pixel size and whole-pixel origin), windows are copied straight from it.

  • All masks are background and generate warns “no label polygon intersects the imagery extent”: the polygons are outside the region, or longitude and latitude are swapped (coordinates like [52.37, 4.94] instead of [4.94, 52.37]). Check in QGIS or geojson.io.
  • GeoJSON 'crs' … is not supported: the file is in a projected CRS. Re-export to EPSG:4326, or save it as a GeoPackage, Shapefile or GeoParquet, which carry their CRS.
  • labels.path must be a .geojson, .json, .kml, .gpkg, .shp, .parquet or .geoparquet file: unzip a KMZ to its KML (or a .zip to the file inside), or convert other formats as shown above.
  • … is a named pipe (FIFO), not a regular file (or a character device, such as a link to /dev/zero): a path that names something other than an ordinary file would be read for ever. This holds for every file mapcv is given: the config, region.path, labels, an annotated area and GeoTIFF imagery. Point it at a file.
  • the KML file's encoding … is not supported; save it as UTF-8: re-export the KML as UTF-8 (UTF-16, ISO-8859-1 and windows-1252 are read as they are declared).
  • … has no .prj file, so its CRS is unknown: the Shapefile lacks its projection file; see Labels in another CRS.
  • … has 3 layers: …: set labels.layer.
  • GeoParquet labels need the pyarrow package: pip install "mapcv[parquet]".
  • … attributes are not valid utf-8: a Shapefile written in a legacy encoding without a .cpg; add a <name>.cpg containing the encoding, for example ISO-8859-1 or windows-1252.
  • Masks look shifted on tall buildings: footprints are traced at ground level while aerial imagery shows roofs displaced by perspective. That is a property of the data, not a reprojection error.

More in Troubleshooting.