Indoor Map Data Standards: IMDF, IndoorGML and CityGML
Sooner or later an indoor map has to leave the system that built it — to Apple Maps, to a tenant’s own GIS, to a transit authority’s passenger information system. Each of those consumers wants a standard format, and the standards disagree about what an indoor map is. This topic, part of Indoor Mapping Architecture & Standards, covers what each standard models, how to map an internal envelope onto it, and why the conversion belongs at the publishing boundary rather than in the data model.
The Problem: Three Standards, Three Different Models
The three standards that matter for indoor mapping were designed for different purposes, and it shows in what they make easy.
IMDF (Indoor Mapping Data Format) is Apple’s format, now an OGC community standard. It is a zip
archive of GeoJSON files — venue, level, unit, opening, amenity, occupant and a few
more — with a UUID-linked hierarchy and closed category enumerations. It is the only one of the
three with mass-market consumers, and it is the least effort to emit because the encoding is
GeoJSON, which every stack already speaks.
IndoorGML is the OGC’s indoor navigation standard, and it is the best model of the three. It represents a building as primal space (cells and their boundaries) and dual space (a node for each cell, an edge for each connection) — the routing graph as a first-class citizen rather than something each consumer reconstructs. Almost nothing reads it outside research and transit information systems, which is the entire argument against adopting it as an internal format.
CityGML LoD4 extends a city-scale model down to interior detail. It is the right choice when indoor geometry has to sit inside an existing city or campus model, and the wrong one when the purpose is navigation — routing was never its intent, and reconstructing a graph from it is strictly harder than from your own envelope.
Why Conversion Belongs at the Boundary
The recurring temptation is to adopt a standard as the internal format — store IMDF, publish IMDF, avoid the mapping. It goes wrong for four reasons that are worth naming, because the argument comes up on every project.
Coordinates. IMDF is WGS84 longitude and latitude. Internal work is in local metres, because every geometric operation in the pipeline — offsetting a wall, measuring a corridor, computing a medial axis — is defined in metres and wrong in degrees. Storing WGS84 means converting on every operation instead of once at publication.
Enumerations. IMDF’s category lists are closed and set by Apple. An internal taxonomy shaped by your own routing and search needs, as POI taxonomy classification describes, will not fit inside them, and constraining it to do so means every internal feature request becomes a standards question.
Multiple targets. An estate that publishes to Apple Maps this year publishes to something else next year. One internal model with two boundary converters is straightforward; a model stored in one standard and converted to another is a translation between two foreign vocabularies.
Round-tripping loses. Data that goes internal → IMDF → internal does not come back intact,
because IMDF has no place for the fields your pipeline depends on: topology_hash, the drawing
entity handles, the cleanup report, the per-profile weights.
Step-by-Step: Emitting IMDF from the Envelope
Step 1 — reproject to WGS84. The campus CRS transform runs in reverse, once, over every geometry:
import logging
from pyproj import Transformer
from shapely.geometry import shape
from shapely.ops import transform as shp_transform
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def to_wgs84(envelope: dict, local_crs: str) -> dict:
"""Reproject every feature from the campus metric frame to WGS84 lon/lat."""
if not local_crs:
raise ValueError("no campus CRS recorded; cannot reproject for publication")
tf = Transformer.from_crs(local_crs, "EPSG:4326", always_xy=True)
out = {"type": "FeatureCollection", "features": []}
for f in envelope["features"]:
geom = shp_transform(lambda x, y, z=None: tf.transform(x, y), shape(f["geometry"]))
out["features"].append({**f, "geometry": geom.__geo_interface__})
logger.info("reprojected %d feature(s) from %s to EPSG:4326",
len(out["features"]), local_crs)
return out
Step 2 — map classes onto IMDF’s enumerations. The mapping is explicit and total; an unmapped class is a publishing error, never a pass-through:
IMDF_UNIT_CATEGORY = {
"room": "room", "corridor": "walkway", "stair": "stairs",
"elevator": "elevator", "escalator": "escalator", "service": "nonpublic",
}
def imdf_category(space_class: str) -> str:
try:
return IMDF_UNIT_CATEGORY[space_class]
except KeyError:
raise ValueError(f"no IMDF category for space_class {space_class!r}; "
"add a mapping rather than passing it through")
Step 3 — derive stable UUIDs. IMDF identifies everything by UUID, and regenerating them per build makes every publish look like a complete replacement:
import uuid
IMDF_NAMESPACE = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8") # any fixed namespace
def imdf_id(feature_id: str, kind: str) -> str:
"""Deterministic UUID v5, so a room keeps its identity across rebuilds."""
return str(uuid.uuid5(IMDF_NAMESPACE, f"{kind}:{feature_id}"))
Step 4 — validate with the target’s own validator, not with your own schema. The point of publishing to a standard is that someone else’s software will read it, and their validator is the one that decides.
Validation & Failure Modes
| Skipped | Symptom | Where it surfaces |
|---|---|---|
| Reprojection | Venue lands off the coast of Africa | The consumer’s map, immediately |
| Deterministic ids | Every publish is a full replacement | Occupants orphaned at the consumer |
| Category mapping | Validator rejects an unknown category | Submission, before publication |
| Level ordinal sign | Basement renders above ground | The consumer’s floor picker |
| Opening geometry type | Doors dropped silently | Routing at the consumer, much later |
| Address / venue record | Archive rejected wholesale | Submission |
The 0°N 0°E case deserves its own mention because it is so common and so recognisable: local metric
coordinates emitted as if they were degrees put every building within a few hundred metres of the
Gulf of Guinea. Any consumer sees it instantly, which at least makes it a cheap failure — unlike
the opening-geometry case, where a door emitted as a Point rather than a LineString is dropped
by the validator’s leniency and only surfaces when routing at the consumer fails months later.
A publication report is worth emitting alongside the archive:
{
"venue": "HQ",
"target": "imdf-1.0.0",
"features": {"level": 8, "unit": 486, "opening": 512, "amenity": 74, "occupant": 61},
"unmapped_classes": [],
"reprojected_from": "EPSG:27700",
"id_scheme": "uuid5",
"validator": "pass"
}
Operational Considerations
Publication to a standard is a release with someone else’s release cadence attached, which changes how it should be scheduled.
Validate before you need to. Standards validators are strict and their error messages are terse. Running the target’s validator in CI on every map change — not only before a submission — means a mapping gap is found by the engineer who introduced it, rather than a quarter later by whoever is preparing the submission.
Version the mapping, not just the data. When IMDF’s enumerations change, or when a consumer moves to a new schema version, the mapping table changes and the same internal data produces a different archive. Recording which mapping version produced which archive makes an “it used to work” conversation tractable.
Publish deltas where the consumer supports it. A full venue archive for a large campus is hundreds of megabytes, and most publications change a handful of units. Where the target supports incremental updates, the stable UUIDs from step 3 are what make them possible; where it does not, the content hash at least tells you whether a resubmission is needed at all.
Keep the internal model authoritative. Nothing should read the published archive back into the pipeline. The archive is an output, and treating it as one avoids the whole class of bugs where an internal field quietly starts meaning whatever the standard’s nearest equivalent means.
Edge Cases & Gotchas
| Pattern | Symptom | Handling |
|---|---|---|
| A space with no IMDF equivalent | Validator rejects the archive | Map to the nearest category; carry the original as an alternate name |
| Two internal categories, one target category | Detail lost on publication | Accept it; the internal model stays authoritative |
| Level ordinal vs. level index | Basement above ground in the picker | IMDF ordinals are signed like ours — do not offset them |
A door as a Point |
Openings silently dropped | IMDF openings are LineString; convert at the boundary |
| Venue larger than a single site | Consumer rejects the footprint | Split into separate venues, one per site |
| Occupants without an anchor | Tenants render at the venue centroid | Every occupant needs an anchor referencing its unit |
The one-to-many mapping row is worth accepting rather than fighting. An internal taxonomy that
distinguishes a meeting_room from a quiet_room will publish both as IMDF room, and that is the
correct outcome: the standard exists to be understood by other people’s software, and the
distinction that matters to your booking system does not matter to a map application. Trying to
preserve every internal distinction in the published archive leads to abusing alternate names and
custom properties, which the consumer’s validator will either reject or ignore.
The opposite direction is more dangerous. When two target categories map from one internal class —
IMDF distinguishes stairs from escalator, and an internal taxonomy that lumps both as
vertical cannot say which — the archive is wrong rather than merely coarse. That is a signal to
add the distinction internally, because a consumer rendering an escalator as a staircase is a
user-visible defect, and because accessible routing
profiles
needs exactly that distinction anyway.
A third case sits between the two: attributes the standard has and the internal model does not. IMDF carries opening hours on occupants and accessibility attributes on units, and if the estate holds that data anywhere — a room-booking system, a facilities database — publication is the point at which it becomes visible to end users. Wiring those sources into the publishing step is usually a larger win than any geometric refinement, because it changes what the map can answer rather than how precisely it answers what it already could.
Frequently Asked Questions
Should I store my indoor map as IMDF?
No, and the coordinate system is the clearest reason. IMDF is WGS84 longitude and latitude, while every geometric operation in an indoor pipeline — offsetting a wall, measuring a corridor width, computing a medial axis, snapping a position — is defined in metres and behaves badly in degrees, where a degree of longitude is a different distance at every latitude. Storing IMDF means either converting on every operation or accepting subtly wrong geometry. Add the closed category enumerations and the absence of any place to put your pipeline’s own fields, and the format is clearly an output rather than a home.
Is IndoorGML worth emitting if nothing reads it?
Rarely, and the exceptions are specific: transit operators and research partners do consume it, and if one of them is your consumer then it is exactly the right format because it carries the navigation graph natively rather than making them rebuild it. What IndoorGML is genuinely useful for even when you do not emit it is as a design reference — its primal/dual separation is the clearest available statement of the relationship between rooms and the routing graph over them, and a pipeline built with that separation in mind ends up cleaner whatever it publishes.
How do I handle a category my internal taxonomy has and IMDF does not?
Map it to the closest permitted category and record the original in a way the standard allows to travel. IMDF permits alternate names and some extensibility on occupants, which is enough to carry the internal term as metadata even when the category itself must be one of the closed set. What matters is that the mapping is explicit in code and fails loudly for anything unmapped: a pass-through that emits your internal string as an IMDF category produces an archive that fails validation at submission, which is a much worse place to discover it.
Does publishing to a standard change what I need internally?
It adds two requirements and changes nothing else. Identifiers must be stable across rebuilds, because every standard identifies features by id and regenerated ids read as deletions followed by creations. And the campus CRS must be recorded per building rather than assumed, because reprojection to WGS84 is impossible without knowing what frame the local metres are in. Both are good practice regardless — the first is what makes content-addressed versioning meaningful, and the second is what indoor coordinate reference systems exists to establish.
Related
- IMDF vs. IndoorGML for Indoor Map Interchange — the two navigation-oriented standards compared in detail.
- Validating IMDF Archives in Python — the checks to run before a submission, and what the validator will not tell you.
- Converting GeoJSON Envelopes to IndoorGML — emitting primal and dual space from an internal envelope.
- Integrating Indoor Maps with Apple Maps (IMDF) — the consumer-side view of the same archive.
- POI Taxonomy & Classification — the internal vocabulary these mappings translate from.
This page is part of the Indoor Mapping Architecture & Standards section.