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.buildingandfootprint— the building mass and its ground outline.level— one polygon per floor, each carrying an integerordinalthat encodes vertical order (ground = 0, first floor up = 1, first below = −1).unit— the rooms, corridors, and walkable spaces, each linked to its floor byproperties.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.
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.
Related
- SDK Integration Patterns for Indoor Maps — the delivery workflow this publishing branch belongs to.
- JSON Schema Design for Indoor Maps — the validated envelope this archive is transformed from.
- Indoor Coordinate Reference Systems — the local metric frame every coordinate is reprojected out of into WGS84.
- POI Taxonomy Classification — the vocabulary the IMDF amenity categories should be drawn from.
This page is part of the SDK Integration Patterns guide within the Production-Ready Indoor Map Deployment reference.