Converting GeoJSON Envelopes to IndoorGML
Part of Indoor Map Data Standards. Unlike an IMDF conversion, which is a geometry and vocabulary exercise, an IndoorGML conversion publishes the routing graph as well — so it consumes two internal artefacts rather than one, and runs later in the pipeline.
What the Conversion Has to Produce
IndoorGML represents a building as primal space — the cells people occupy and the boundaries between them — and dual space, a graph whose nodes correspond one-to-one with cells and whose edges correspond with boundaries that can be crossed. Both have to be emitted, and the second is the reason the conversion is not simply a reformatting of the envelope.
The SpaceLayer construct is where IndoorGML earns its complexity. A layer is a graph over the
cell set, and there can be several — one per level, and within a level, one per travel mode. A
default layer and a step-free layer share every CellSpace and differ only in which Transition
elements they contain. That is a direct expression of the idea
accessible routing profiles implements as weight
vectors, and emitting it means the consumer receives the accessibility model rather than having to
infer it.
Minimal Working Example
The emitter builds the document in dependency order so no reference points forward:
import logging
from xml.etree import ElementTree as ET
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
NS = {
"gml": "http://www.opengis.net/gml/3.2",
"indoorgml": "http://www.opengis.net/indoorgml/1.0/core",
}
for prefix, uri in NS.items():
ET.register_namespace(prefix, uri)
def q(prefix: str, tag: str) -> str:
# ElementTree wants "{namespace}tag"; built by concatenation to keep it readable.
return "{" + NS[prefix] + "}" + tag
def emit_cellspace(parent: ET.Element, feature: dict, srs: str) -> str:
"""Write one CellSpace and return its gml:id for the State to reference."""
props = feature["properties"]
cell_id = f"c-{props['feature_id']}"
member = ET.SubElement(parent, q("indoorgml", "cellSpaceMember"))
cell = ET.SubElement(member, q("indoorgml", "CellSpace"), {q("gml", "id"): cell_id})
geom = ET.SubElement(cell, q("indoorgml", "cellSpaceGeometry"))
poly = ET.SubElement(geom, q("gml", "Polygon"), {"srsName": srs})
ring = ET.SubElement(ET.SubElement(poly, q("gml", "exterior")),
q("gml", "LinearRing"))
coords = feature["geometry"]["coordinates"][0]
ET.SubElement(ring, q("gml", "posList")).text = " ".join(
f"{x:.6f} {y:.6f}" for x, y in coords)
return cell_id
def emit_state(parent: ET.Element, node_id: str, cell_id: str, xy: tuple[float, float]) -> str:
"""Write one dual-space State whose duality points at an already-written cell."""
state_id = f"s-{node_id}"
member = ET.SubElement(parent, q("indoorgml", "stateMember"))
state = ET.SubElement(member, q("indoorgml", "State"), {q("gml", "id"): state_id})
ET.SubElement(state, q("indoorgml", "duality"),
{q("gml", "href"): f"#{cell_id}"})
pos = ET.SubElement(ET.SubElement(state, q("indoorgml", "geometry")), q("gml", "Point"))
ET.SubElement(pos, q("gml", "pos")).text = f"{xy[0]:.6f} {xy[1]:.6f}"
return state_id
def emit_transition(parent: ET.Element, edge_id: str, a: str, b: str, weight: float) -> None:
"""Write one dual-space Transition connecting two States already written."""
member = ET.SubElement(parent, q("indoorgml", "transitionMember"))
tr = ET.SubElement(member, q("indoorgml", "Transition"), {q("gml", "id"): f"t-{edge_id}"})
for state in (a, b):
ET.SubElement(tr, q("indoorgml", "connects"), {q("gml", "href"): f"#{state}"})
ET.SubElement(tr, q("indoorgml", "weight")).text = f"{weight:.3f}"
Assembly. The three emitters are called in order — every CellSpace first, then every State,
then every Transition — so each href refers to an element already present in the document:
def convert(envelope: dict, graph, srs: str, profiles: tuple[str, ...] = ("default",)) -> bytes:
if not srs:
raise ValueError("IndoorGML requires an explicit srsName; refusing to guess")
root = ET.Element(q("indoorgml", "IndoorFeatures"), {q("gml", "id"): "venue"})
primal = ET.SubElement(root, q("indoorgml", "primalSpaceFeatures"))
cells = {f["properties"]["feature_id"]: emit_cellspace(primal, f, srs)
for f in envelope["features"] if f["properties"].get("is_routable")}
mlg = ET.SubElement(root, q("indoorgml", "multiLayeredGraph"))
for profile in profiles:
for level in sorted({n["level"] for _, n in graph.nodes(data=True)}):
layer = ET.SubElement(mlg, q("indoorgml", "SpaceLayer"),
{q("gml", "id"): f"layer-{profile}-{level:g}"})
states = {}
for node, attrs in graph.nodes(data=True):
if attrs.get("level") != level or attrs.get("kind") != "space":
continue
cell = cells.get(node.split(":", 1)[-1])
if cell is None:
logger.warning("state %s has no cell; skipping", node)
continue
states[node] = emit_state(layer, node, cell, (attrs["x"], attrs["y"]))
for a, b, data in graph.edges(data=True):
if a in states and b in states and _allowed(data, profile):
emit_transition(layer, f"{a}|{b}", states[a], states[b],
data.get("length", 0.0))
logger.info("emitted %d cell(s) across %d layer(s)", len(cells), len(profiles))
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
Emission Order and Reference Integrity
GML permits forward references — an xlink:href may point at an element defined later in the
document — and relying on that is a reliable way to ship a document with a dangling reference that
only a strict consumer notices.
Emitting in dependency order removes the possibility. Cells are written before the states that reference them; states before the transitions that connect them; layers before anything inside them. A reference that cannot be resolved at emission time is therefore a bug in the input, not a document that might resolve later, and it can be raised immediately with the id that failed.
The one place this needs care is vertical transitions, which connect states in two different
SpaceLayer elements. Both layers must be written before the transition, which means the vertical
edges are emitted in a final pass after every level’s layer is complete.
Common Errors & Fixes
Consumer reports “unresolved duality reference”. A State points at a CellSpace id that was
never written, usually because the cell was filtered out as non-routable while its graph node was
not. Filter both from the same predicate — if a space is not routable it should have neither a cell
nor a state.
Geometry is in the wrong place. srsName was omitted, so the consumer assumed a default. Every
geometry container needs it, and the emitter above refuses to run without one rather than emitting
a document whose coordinates are ambiguous.
Ring is not closed. GML LinearRing requires the first and last positions to be identical.
GeoJSON also requires it, but some producers omit it, so the safe move is to close it explicitly
during emission rather than trusting the input:
if coords[0] != coords[-1]:
coords = list(coords) + [coords[0]]
The document is enormous. A SpaceLayer per level per profile multiplies quickly — 8 levels
and 4 profiles is 32 layers over the same cells. Emit only the profiles a consumer asked for, and
remember that the cells are shared, so the marginal cost of a profile is its transitions rather
than the geometry.
Integration Point
This converter is the only publishing step that consumes the routing graph, which places it downstream of routing graph construction as well as of the envelope. That ordering is worth making explicit in the build, because an IndoorGML publication attempted before the graph exists will emit primal space with an empty dual — a document that validates and carries none of the information the standard exists to carry.
Everything else follows the boundary discipline described in Indoor Map Data Standards: the internal model stays in local metres with its own taxonomy, the conversion happens at the edge, and nothing reads the emitted document back.
Frequently Asked Questions
Do I have to emit every accessibility profile as a layer?
No — emit the ones a consumer will use, which is usually one or two. The MultiLayeredGraph construct makes multiple layers possible, not mandatory, and each additional layer costs a full set of Transition elements over the same cells. A transit information system typically wants a default layer and a step-free layer; emitting a low-vision and a service layer as well produces a document twice the size that nothing reads. The cells are shared, so adding a profile later is cheap if a consumer asks.
Can IndoorGML carry the same accessibility attributes as IMDF?
Yes, through the navigation module’s extensions, and it expresses them differently in a way that suits routing better. Where IMDF puts an accessibility attribute on a unit or opening and leaves the consumer to interpret it, IndoorGML lets you omit the Transition entirely from a step-free layer — the stair simply is not traversable in that graph. That is a stronger statement and a less ambiguous one, though it does mean a consumer cannot tell from the step-free layer alone why a connection is missing, which is an argument for emitting both layers rather than only the accessible one.
Is there a Python library for IndoorGML?
Nothing mature enough to depend on, which is why the example builds the XML directly. The schema is public and the element set an indoor map needs is small — CellSpace, State, Transition, SpaceLayer and their containers — so a hand-written emitter of a few hundred lines is both practical and easier to reason about than adapting a general GML library. Validate the output against the published XSD with lxml, which does have mature schema support, rather than trusting the emitter.
Related
- Indoor Map Data Standards — the publishing boundary this converter sits at.
- IMDF vs. IndoorGML for Indoor Map Interchange — when this conversion is worth doing at all.
- Indoor Routing Graph Construction — the source of the dual space this converter publishes.
This page is a companion to Indoor Map Data Standards, part of the Indoor Mapping Architecture & Standards section.