Integrating Indoor Maps with Apple Maps (IMDF)

This reference covers the one integration path that is not a renderer at all — publishing an indoor dataset to Apple Maps as an IMDF archive — and it sits inside the SDK Integration Patterns workflow as the branch you take when Apple, not your own WebGL canvas, will draw the map.

What “Integrating with Apple Maps via IMDF” Means

IMDF — the Indoor Mapping Data Format — is a GeoJSON-based specification that Apple (and the OGC, which now stewards it) consume to render venues inside Apple Maps. The critical difference from the MapLibre and Mapbox references is architectural: with a GL renderer you draw the tiles yourself from a FeatureCollection you host; with IMDF you publish a dataset and Apple renders it. You are not writing layer styles or filters. You are producing a conforming archive, validating it, and submitting it through Apple’s Indoor Maps Program, after which Apple owns the presentation.

An IMDF archive is not one GeoJSON file. It is a directory of feature-type-specific GeoJSON files plus a manifest.json, each file holding a FeatureCollection of exactly one feature type. The types you almost always produce are:

  • venue — the overall place, its address and name.
  • building and footprint — the building mass and its ground outline.
  • level — one polygon per floor, each carrying an integer ordinal that encodes vertical order (ground = 0, first floor up = 1, first below = −1).
  • unit — the rooms, corridors, and walkable spaces, each linked to its floor by properties.level_id.
  • opening — doorways and passages between units.
  • amenity — points of interest such as restrooms, elevators, and information desks, each linked to the unit that contains it.

Every feature carries a feature_type, a UUID id, a geometry, and a properties object; cross-references between types are by UUID. And the single hardest requirement to retrofit: all geometry must be in WGS84 longitude/latitude (EPSG:4326). Your indoor pipeline almost certainly works in a local metric frame with an origin on a survey control point, so publishing to IMDF means reprojecting every coordinate out of that local frame — the frame defined in Indoor Coordinate Reference Systems — into global lng/lat before it ever enters an IMDF file.

Minimal Working Example: A Level and a Unit Feature

Below is a conforming level feature and a unit feature that references it. Note the ordinal on the level, the level_id UUID linkage on the unit, the category drawn from the IMDF vocabulary, and coordinates already in WGS84. These would live in level.geojson and unit.geojson respectively, each as one member of a FeatureCollection.

{
  "type": "Feature",
  "feature_type": "level",
  "id": "a3f1c2d4-1111-4a2b-9c3d-0002000000l2",
  "geometry": { "type": "Polygon", "coordinates": [[
    [-122.40145, 37.78421], [-122.40098, 37.78421],
    [-122.40098, 37.78455], [-122.40145, 37.78455], [-122.40145, 37.78421]]] },
  "properties": {
    "category": "unspecified",
    "restriction": null,
    "outdoor": false,
    "ordinal": 2,
    "name": { "en": "Level 2" },
    "short_name": { "en": "2" },
    "display_point": { "type": "Point", "coordinates": [-122.40121, 37.78438] },
    "address_id": null,
    "building_ids": ["a3f1c2d4-1111-4a2b-9c3d-0001000000bd"]
  }
}
{
  "type": "Feature",
  "feature_type": "unit",
  "id": "a3f1c2d4-1111-4a2b-9c3d-0184000000un",
  "geometry": { "type": "Polygon", "coordinates": [[
    [-122.40140, 37.78425], [-122.40122, 37.78425],
    [-122.40122, 37.78440], [-122.40140, 37.78440], [-122.40140, 37.78425]]] },
  "properties": {
    "category": "room",
    "restriction": null,
    "accessibility": null,
    "name": { "en": "Room 0184" },
    "alt_name": null,
    "display_point": { "type": "Point", "coordinates": [-122.40131, 37.78432] },
    "level_id": "a3f1c2d4-1111-4a2b-9c3d-0002000000l2"
  }
}

The unit’s level_id must match a level feature’s id, and that level’s ordinal is what places the unit vertically. This UUID linkage — not a shared level integer as in the GL renderers — is how IMDF models the Z axis.

IMDF archive structure and the reprojection into WGS84 An IMDF archive is a directory of GeoJSON files, one feature type each, plus a manifest. A venue feature references a building; the building references level features, and each level carries an integer ordinal that encodes floor order with ground at zero. Unit features reference their level by UUID through a level_id property, opening features connect adjacent units, and amenity points reference the unit that contains them. Before any feature enters the archive, its geometry is reprojected from the local indoor metric coordinate frame into WGS84 longitude and latitude, because Apple only accepts EPSG:4326 coordinates. IMDF: UUID-linked features, reprojected into WGS84 reproject local (x, y) metres → WGS84 (lng, lat) before any feature enters the archive venue name · address building footprint level ordinal: 2 (Z order) level_id unit category: room · level_id opening connects units amenity unit_ids → unit manifest.json version · language

Mapping and Reprojecting the Envelope to IMDF

The site’s validated GeoJSON envelope carries space_class, level, and local (x, y) metres. This typed helper maps each envelope feature to an IMDF unit — translating space_class to an IMDF category and reprojecting local coordinates to WGS84 with pyproj — while logging and skipping any geometry that fails to transform.

import logging
import uuid
from typing import Any

from pyproj import Transformer
from pyproj.exceptions import ProjError

logger = logging.getLogger(__name__)

# space_class (site envelope) -> IMDF unit category (Apple vocabulary)
CATEGORY_MAP: dict[str, str] = {
    "room": "room", "corridor": "walkway", "stair": "stairs",
    "elevator": "elevator", "service": "nonpublic",
}


def envelope_to_units(features: list[dict[str, Any]], local_epsg: int, level_ids: dict[int, str]) -> list[dict[str, Any]]:
    """Map site envelope features to IMDF unit features, reprojecting local metres to WGS84."""
    tf = Transformer.from_crs(local_epsg, 4326, always_xy=True)  # (x, y) -> (lng, lat)
    units: list[dict[str, Any]] = []
    for feat in features:
        props = feat["properties"]
        category = CATEGORY_MAP.get(props.get("space_class", ""))
        if category is None:
            continue  # openings/doors are emitted as a separate feature_type, not units
        try:
            ring = [list(tf.transform(x, y)) for x, y in feat["geometry"]["coordinates"][0]]
        except ProjError as exc:
            logger.error("reprojection failed for %s: %s", props.get("feature_id"), exc)
            continue
        units.append({
            "type": "Feature", "feature_type": "unit", "id": str(uuid.uuid4()),
            "geometry": {"type": "Polygon", "coordinates": [ring]},
            "properties": {
                "category": category, "restriction": None, "accessibility": None,
                "name": {"en": props.get("feature_id")}, "alt_name": None,
                "display_point": {"type": "Point", "coordinates": ring[0]},
                "level_id": level_ids[props["level"]],
            },
        })
    logger.info("mapped %d envelope features to %d IMDF units", len(features), len(units))
    return units

The always_xy=True flag is not optional: without it pyproj follows the CRS’s declared axis order and can silently swap latitude and longitude, which passes validation but places the whole venue in the wrong hemisphere.

IMDF Mapping Reference

Site envelope IMDF target Notes
space_class: room unit.category = "room" Public occupiable space
space_class: corridor unit.category = "walkway" Circulation space
space_class: stair / elevator unit.category = "stairs" / "elevator" Vertical circulation units
space_class: service unit.category = "nonpublic" Restricted / back-of-house
space_class: door opening feature (separate file) Openings are their own feature_type, not units
level (integer) level.ordinal + level.id UUID Ordinal encodes Z order; units link by level_id
local (x, y) metres WGS84 (lng, lat) Reproject local_epsg → EPSG:4326 with pyproj
POI classification amenity.category Drawn from the IMDF amenity vocabulary; see the POI taxonomy guide
archive layout one GeoJSON per type + manifest.json venue, building, footprint, level, unit, opening, amenity

Common Errors & Fixes

Validation error: coordinates out of range / venue appears mid-ocean. You shipped local metric coordinates (e.g. [3.42, 7.88]) into an IMDF file instead of WGS84. A longitude of 3.42 is a valid number but not your building, so the archive validates structurally yet lands nowhere near the site. Reproject every ring before writing it:

tf = Transformer.from_crs(local_epsg, 4326, always_xy=True)
lng, lat = tf.transform(x_metres, y_metres)   # always_xy avoids a silent lat/lng swap

level_id references a non-existent level / units render on the wrong floor. A unit’s level_id does not match any level feature’s id, or two floors were assigned the same ordinal. IMDF resolves the Z axis entirely through this UUID-and-ordinal linkage, so a mismatch drops units or stacks them wrong. Build the level.id map first and assign every unit’s level_id from it, and assert that ordinals are unique per building:

assert len({lvl["properties"]["ordinal"] for lvl in levels}) == len(levels), "duplicate ordinal"

The archive is rejected as invalid before Apple ingests it. A required feature type is missing (commonly manifest.json, venue, or footprint), or a feature_type string is misspelled. Run the dataset through the official imdf-validator (or the OGC IMDF schema) in CI before submission so a structural defect fails your pipeline, not Apple’s ingestion queue.

Integration Point

This publishing path consumes the same validated envelope as every renderer — the FeatureCollection produced under JSON Schema Design for Indoor Maps — but instead of styling it, it transforms it into an IMDF archive Apple ingests. The reprojection step is where this page ties hardest to the rest of the system: your indoor geometry lives in the local metric frame defined by Indoor Coordinate Reference Systems, and every coordinate must be transformed to WGS84 before it enters the archive. The amenity categories you emit should come straight from your existing POI Taxonomy Classification so the labels Apple renders match the vocabulary the rest of your maps already use.

Frequently Asked Questions

How is IMDF different from just rendering my GeoJSON in a map SDK?

With a GL SDK you host a FeatureCollection and draw it yourself, controlling every layer, filter, and style. With IMDF you publish a dataset and Apple renders it — you produce a conforming archive, validate it, and submit it through the Apple Indoor Maps Program, after which Apple owns the presentation. So there are no style layers or floor filters to write here; the work is schema conformance, correct feature-type linkage, and a correct coordinate frame, not rendering.

Why must I reproject, and what happens if I skip it?

IMDF requires WGS84 longitude/latitude (EPSG:4326), but indoor pipelines work in a local metric frame with an origin on a survey point. If you write local metres into IMDF files the coordinates are still valid numbers, so the archive can pass structural validation, but the venue lands in the wrong place — often mid-ocean, since small metric values read as points near 0,0. Reproject every coordinate with a pyproj transformer from your local EPSG to 4326, and set always_xy=True so latitude and longitude are never silently swapped.

How does IMDF represent floors, and how do units attach to them?

Each floor is a level feature carrying an integer ordinal that encodes vertical order — ground at 0, one up at 1, one below at −1 — and a UUID id. Every unit references its floor through properties.level_id, which must equal that level’s id. This UUID-and-ordinal linkage, not a shared integer field, is how IMDF models the Z axis, so a mismatched level_id or a duplicated ordinal drops units or stacks floors incorrectly.

This page is part of the SDK Integration Patterns guide within the Production-Ready Indoor Map Deployment reference.