Level Mapping & Z-Axis Logic
Level mapping is the process of collapsing a continuous stream of elevation samples into a small set of discrete, logically indexed floor levels that an indoor routing graph can traverse. It sits inside the Indoor Mapping Architecture & Standards framework as the vertical-topology stage: the point where raw survey geometry stops being a cloud of Z-coordinates and becomes addressable floors with stable integer indices. Outdoor GIS treats elevation as a continuous attribute; indoor wayfinding treats it as a primary key. Get the Z-axis wrong and every downstream concern — graph connectivity, POI placement, vertical transitions — inherits the error.
Problem Statement: Continuous Elevation Is Not a Floor Level
A building does not announce its floors. What a survey hands you is a population of Z-values — slab tops, finished-floor heights, ceiling fixtures, mezzanine decks, ramp midpoints — scattered along a vertical axis with measurement noise on top. The job of this stage is to answer one question deterministically: which floor level does each point belong to, and what integer should name it?
The symptom of getting this wrong is unmistakable in production. A routing engine reports a user “between floors” because a ramp sample landed halfway between two clusters. A POI for a third-floor café renders on a phantom floor that exists only because a suspended light fixture survived noise filtering. Two adjacent buildings on a campus disagree on what “Level 2” means because one anchored Z to a geodetic datum and the other to a local benchmark. Each of these is a level-mapping defect, not a rendering or routing bug — which is why the fix belongs here, before any edge is ever drawn.
Because the Z-index this stage emits becomes the floor key that POIs are joined against, a misassigned level silently corrupts POI Taxonomy & Classification downstream: asset metadata attaches to the wrong floor, and multi-tenant campuses end up with duplicated or orphaned points. The discretization here is the contract every later stage trusts.
Prerequisites & Dependencies
Before implementing this stage, the following must already hold:
- Aligned vertical datum. All elevation samples must share one origin. Raw BIM exports, LiDAR point clouds, and CAD floor plans rarely agree, so reconcile them to a consistent metric datum first. The horizontal counterpart of this guarantee lives in Indoor Coordinate Reference Systems; the same orthogonal, metre-based frame that anchors X/Y must anchor Z, or clustering tolerances expressed in metres are meaningless.
- Extracted Z-values. You need a flat array of elevations, not raw CAD entities. Pulling clean Z-coordinates out of DWG/DXF — where elevations are often stored as relative block offsets rather than absolute heights — is its own task, covered in Converting CAD elevations to indoor Z-levels.
- Python libraries.
numpyfor sample arrays,scipy.cluster.hierarchyfor floor discretization, andnetworkxfor the multi-level routing graph. Optional:scikit-learnforAgglomerativeClusteringon large campuses, andlaspy/open3dif you downsample point clouds upstream. - Connector inventory. A list of vertical connectors (stairs, elevators, ramps, escalators) expressed as floor-index pairs. Geometry detection for the passable edges those connectors imply is handled by Wall & Door Detection Algorithms upstream; this stage only needs to know which floor levels each connector joins.
How Z-Axis Normalization Works
The approach is a three-stage reduction: filter, cluster, index. Each stage narrows the data and hardens the guarantees the next stage can assume.
- Noise filtering removes samples that are not walking surfaces or structural slabs — ceiling fixtures, floor drains, transient obstructions — so they cannot pull a floor centroid off true.
- Hierarchical clustering groups the surviving samples into vertical bands. A distance threshold tuned to real floor-to-floor clearance decides where one floor level ends and the next begins.
- Z-index assignment maps each band to a sequential integer (0, 1, 2…). That integer is the floor key the routing graph, the POI join, and the client SDK all share.
Clustering is the load-bearing choice. Fixed-width binning fails the moment a building has irregular storey heights — a tall lobby, a low mezzanine, a double-height atrium. Hierarchical (Ward) linkage instead lets the gaps in the data define the boundaries, which is exactly how a human reads a section drawing. DBSCAN with a vertical eps is the alternative when sample density varies wildly across floors.
Step-by-Step Implementation
The pipeline below is engineered for batch processing inside automated GIS workflows. Every function carries typed signatures, emits a logging call, and handles the failure modes most likely to abort a real run. Start from a shared configuration and import block.
import logging
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import networkx as nx
import numpy as np
from scipy.cluster.hierarchy import fcluster, linkage
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("z_axis")
@dataclass
class ZLevelConfig:
vertical_tolerance: float = 0.35 # metres; max within-floor cophenetic distance
min_cluster_size: int = 5
outlier_iqr_factor: float = 1.5
Step 1 — Ingest and Filter Elevation Noise
Raw elevation data carries measurement noise, structural protrusions, and temporary obstructions. Before clustering, isolate true walking surfaces and slabs with robust statistical filtering. Interquartile-range (IQR) clipping removes the ceiling fixtures, suspended lighting, and floor drains that would otherwise drag a floor centroid off true. The function raises on the one failure that makes everything downstream meaningless — too few valid samples to define any floor level at all.
def filter_elevation_noise(
z_values: np.ndarray, iqr_factor: float = 1.5, min_valid: int = 5
) -> np.ndarray:
"""Remove elevation outliers using IQR clipping.
Returns the surviving samples; raises ValueError when too few remain
to define even a single floor level.
"""
if z_values.size == 0:
raise ValueError("Empty elevation array passed to noise filter.")
q75, q25 = np.percentile(z_values, [75, 25])
iqr = q75 - q25
lower, upper = q25 - iqr_factor * iqr, q75 + iqr_factor * iqr
filtered = z_values[(z_values >= lower) & (z_values <= upper)]
if filtered.size < min_valid:
raise ValueError(
f"Only {filtered.size} samples survived IQR filtering "
f"(need >= {min_valid}); check the input datum and units."
)
logger.info("Noise filter: %d -> %d samples", z_values.size, filtered.size)
return filtered
For point-cloud inputs, downsample with a voxel grid (typically 0.5 m) before this step so dense returns do not dominate the IQR statistics — this also caps clustering cost, addressed in the performance notes below.
Step 2 — Cluster Into Discrete Floor Levels
Continuous Z-values are discretized into logical floor levels with Ward hierarchical clustering. The tolerance is a real, physical quantity: the maximum vertical spread allowed within one floor level, which should sit well below the building’s smallest floor-to-floor clearance (typically 2.8–4.5 m for office and retail, 1.8–2.5 m for mezzanines). Clusters smaller than min_size are dropped and their members flagged invalid rather than promoted to a phantom floor.
def cluster_floors(
z_values: np.ndarray, tolerance: float = 0.35, min_size: int = 5
) -> Tuple[np.ndarray, Dict[int, np.ndarray]]:
"""Discretize continuous Z-values into 0-based floor-level clusters.
Returns (per-sample floor index with -1 for dropped samples,
{floor_index: member elevations}).
"""
if z_values.size < min_size:
raise ValueError(
f"Need >= {min_size} samples to cluster, got {z_values.size}."
)
linkage_matrix = linkage(z_values.reshape(-1, 1), method="ward")
labels = fcluster(linkage_matrix, t=tolerance, criterion="distance")
# Order labels by mean elevation so index 0 is the lowest floor level.
order = sorted(np.unique(labels), key=lambda lbl: z_values[labels == lbl].mean())
label_to_idx = {lbl: idx for idx, lbl in enumerate(order)}
z_indices = np.array([label_to_idx[lbl] for lbl in labels])
floor_members: Dict[int, np.ndarray] = {}
for idx in range(len(order)):
mask = z_indices == idx
if int(mask.sum()) >= min_size:
floor_members[idx] = z_values[mask]
else:
logger.warning("Cluster %d dropped (size %d < %d)", idx, int(mask.sum()), min_size)
z_indices[mask] = -1
logger.info("Clustering: %d candidate bands -> %d floor levels", len(order), len(floor_members))
return z_indices, floor_members
Sorting clusters by mean elevation before assigning indices is what makes index 0 reliably the lowest floor level across every building on a campus — without it, fcluster label order is arbitrary and “Level 2” drifts between datasets.
Step 3 — Assign Z-Indices and Build the Routing Graph
Once floor levels are fixed, each becomes a node in a MultiDiGraph, keyed by its integer Z-index and tagged with its centroid elevation. Vertical connectors become directed edges so the routing graph can honour one-way escalators and elevator call logic. Edges are added in both directions only for bidirectional connector types; the caller controls directionality through the connector tuples.
def build_multilevel_graph(
floor_members: Dict[int, np.ndarray],
vertical_connectors: Optional[List[Tuple[int, int, str]]] = None,
) -> nx.MultiDiGraph:
"""Construct a routable multi-level graph from clustered floor levels."""
graph = nx.MultiDiGraph()
for floor_idx, elevations in floor_members.items():
graph.add_node(
floor_idx,
floor_id=floor_idx,
z_centroid=float(np.median(elevations)),
sample_count=int(elevations.size),
)
bidirectional = {"stair", "elevator", "ramp"}
for src, dst, conn_type in vertical_connectors or []:
if src not in graph or dst not in graph:
logger.warning("Connector %s skips missing floor level (%s -> %s)", conn_type, src, dst)
continue
graph.add_edge(src, dst, type=conn_type, weight=1.0)
if conn_type in bidirectional:
graph.add_edge(dst, src, type=conn_type, weight=1.0)
logger.info("Routing graph: %d floor nodes, %d connector edges",
graph.number_of_nodes(), graph.number_of_edges())
return graph
Step 4 — Orchestrate the End-to-End Pass
The orchestrator chains the three stages and returns a routing graph ready for an indoor wayfinding engine. A single entry point keeps the contract narrow: raw elevations and a connector inventory in, a validated multi-level routing graph out.
def normalize_z_pipeline(
raw_elevations: np.ndarray,
config: ZLevelConfig = ZLevelConfig(),
vertical_connectors: Optional[List[Tuple[int, int, str]]] = None,
) -> nx.MultiDiGraph:
"""End-to-end Z-axis normalization: filter -> cluster -> graph."""
logger.info("Starting Z-axis normalization over %d raw samples", raw_elevations.size)
filtered = filter_elevation_noise(raw_elevations, config.outlier_iqr_factor, config.min_cluster_size)
_, floor_members = cluster_floors(filtered, config.vertical_tolerance, config.min_cluster_size)
return build_multilevel_graph(floor_members, vertical_connectors)
if __name__ == "__main__":
rng = np.random.default_rng(42)
samples = np.concatenate([
rng.normal(0.0, 0.05, 150), # ground floor level
rng.normal(3.2, 0.08, 180), # floor level 1
rng.normal(6.5, 0.06, 140), # floor level 2
rng.uniform(-2.0, 10.0, 30), # fixtures, drains, transient noise
])
connectors = [(0, 1, "stair"), (1, 2, "elevator"), (0, 2, "ramp")]
routing_graph = normalize_z_pipeline(samples, vertical_connectors=connectors)
for node, data in routing_graph.nodes(data=True):
print(f"Floor {node}: Z-centroid = {data['z_centroid']:.2f} m ({data['sample_count']} samples)")
Edge Cases & Gotchas
| Symptom | Root cause | Diagnostic & resolution |
|---|---|---|
| Phantom floor levels detected | Tolerance too tight, or ceiling fixtures survived filtering | Raise vertical_tolerance to 0.4–0.6 m; confirm IQR clipping removed >95% of non-slab returns; cross-check against the architectural floor schedule. |
| Vertical drift across datasets | Mismatched vertical datums (e.g. NAVD88 vs. local building 0.00) | Run a datum transformation before ingestion and anchor to a known benchmark; validate against Indoor Coordinate Reference Systems alignment rules. |
| Mezzanine merges with main level | Low clearance (<1.8 m) or sparse samples | Drop vertical_tolerance to 0.25 m and spatially mask the mezzanine footprint before clustering; or switch to DBSCAN with eps=0.2 for irregular geometry. |
| Routing graph disconnected across floor levels | Missing or mislabelled vertical connectors | Audit stair/elevator shaft coordinates; confirm each connector tuple references valid floor indices; run a BFS to surface unreachable floor levels. |
| Floor indices shuffle between runs | Relying on raw fcluster label order |
Sort clusters by mean elevation before index assignment (Step 2) so index 0 is always the lowest floor level. |
| Y-axis inversion bleeds into Z | Source CAD uses a screen-space coordinate convention | Confirm the upstream extractor emits a right-handed metric frame; a flipped axis sends “up” the wrong way and clusters invert. |
Validation Output
Correctness is checkable, not eyeballed. After a clean run the three centroids should track the building’s storey heights and the routing graph should be strongly connected.
import networkx as nx
EXPECTED_CENTROIDS = [0.0, 3.2, 6.5] # metres, from the architectural floor schedule
centroids = [d["z_centroid"] for _, d in sorted(routing_graph.nodes(data=True))]
assert len(centroids) == 3, f"expected 3 floor levels, got {len(centroids)}"
for got, want in zip(centroids, EXPECTED_CENTROIDS):
assert abs(got - want) <= 0.15, f"centroid {got:.2f} m drifts > 0.15 m from {want} m"
assert nx.is_strongly_connected(routing_graph), "vertical transitions leave a floor level unreachable"
A correct run prints centroids within ±0.15 m of the schedule:
INFO: Noise filter: 500 -> 470 samples
INFO: Clustering: 3 candidate bands -> 3 floor levels
INFO: Routing graph: 3 floor nodes, 6 connector edges
Floor 0: Z-centroid = 0.00 m (150 samples)
Floor 1: Z-centroid = 3.20 m (180 samples)
Floor 2: Z-centroid = 6.50 m (142 samples)
An incorrect run is just as legible: Clustering: 5 candidate bands -> 5 floor levels for a three-storey building means a too-tight tolerance split fixtures into phantom floor levels — exactly the first row of the gotchas table.
When this stage publishes for downstream consumption, emit each floor level as a feature in the same GeoJSON FeatureCollection envelope the rest of the pipeline uses, so the z_index becomes a first-class, joinable property rather than an implicit array position:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "z_index": 0, "z_centroid_m": 0.00, "floor_name": "Ground", "sample_count": 150 },
"geometry": null
},
{
"type": "Feature",
"properties": { "z_index": 1, "z_centroid_m": 3.20, "floor_name": "Level 1", "sample_count": 180 },
"geometry": null
}
]
}
That envelope is the contract JSON Schema Design for Indoor Maps validates against before any map artefact ships.
Performance & Scale Notes
Ward linkage is the cost driver. scipy.cluster.hierarchy.linkage is roughly O(n²) in both time and memory on the raw sample count, so a dense LiDAR floor with millions of returns will exhaust RAM long before it finishes. Three levers keep large campuses tractable:
- Pre-aggregate before clustering. Voxel-downsample point clouds to ~0.5 m and feed the filter the reduced set. Z-axis clustering needs the distribution of elevations, not every return; a 50× reduction changes the centroids by less than a millimetre while cutting linkage cost by orders of magnitude.
- Swap the estimator at scale. Beyond a few hundred thousand samples, replace
linkage/fclusterwithsklearn.cluster.AgglomerativeClustering(compute_full_tree=False, distance_threshold=tolerance), which avoids materializing the full dendrogram, or use DBSCAN whose cost scales with neighbourhood density rather than n². - Process per building, not per campus. Floor levels never span buildings, so cluster each building independently and assign indices within its own datum. This bounds n, parallelizes trivially across a worker pool, and keeps one building’s noise from perturbing another’s centroids.
The graph itself is cheap: floor counts are tiny (tens of nodes even for a tower), so MultiDiGraph construction and strong-connectivity checks are effectively free relative to clustering. Batch the whole pass per building inside CI so a regression in centroid output fails the build before it reaches users — the gating mechanics live in CI Gating for Map Updates.
FAQ
How do I pick the right clustering tolerance for a building with mixed storey heights?
Set vertical_tolerance to the maximum vertical spread you expect within one floor level (slab-to-finished-floor variation plus survey noise, usually 0.2–0.4 m), never to the floor-to-floor height. Hierarchical clustering finds boundaries in the gaps between floors, so as long as the tolerance stays well below the smallest real clearance — including any low mezzanine — mixed storey heights cluster correctly without per-floor tuning.
Should Z-indices be sequential integers or real elevations?
Both, for different jobs. The integer z_index is the floor key — stable, joinable, and what POIs, the routing graph, and the client SDK all reference. The real z_centroid is metadata for rendering and elevation-aware UI. Routing on raw elevations is fragile because a ramp sample sits between two floors; routing on integer indices is unambiguous.
How do I keep "Level 2" meaning the same building across a multi-building campus?
Cluster each building independently and assign indices within its own aligned datum, then sort clusters by mean elevation so index 0 is always the lowest occupied floor level. Persist a per-building map from z_index to the human floor name. Sharing one global Z-datum across buildings is the common mistake — campus terrain varies, so absolute elevation does not imply the same floor level.
What happens to samples that fall between two floor levels, like ramp midpoints?
Ward clustering assigns each sample to its nearest band, so an isolated ramp midpoint joins whichever floor level it is closest to and contributes negligibly to that centroid. A cluster of ramp samples large enough to exceed min_cluster_size would form its own band — model that ramp explicitly as a vertical connector edge rather than letting it become a phantom floor level.
Related
- Converting CAD elevations to indoor Z-levels — extracting and datum-shifting Z-values out of DWG/DXF before they reach this pipeline.
- Indoor Coordinate Reference Systems — the metric datum that makes a metre-valued clustering tolerance meaningful.
- POI Taxonomy & Classification — the downstream join that depends on every floor level’s Z-index being correct.
- Fallback Routing Architectures — how the vertical connectors modelled here are reweighted when a transition goes offline.
This page is part of the Indoor Mapping Architecture & Standards section — return there for the end-to-end pipeline this vertical-topology stage feeds.