Level Mapping & Z-Axis Logic
Pipeline Architecture & Spatial Alignment
Indoor environments fundamentally break traditional 2D GIS assumptions. While outdoor routing relies on continuous planar coordinates, indoor navigation requires discrete vertical stratification. The Z-axis is not merely a geometric elevation; it is a logical indexing layer that governs wayfinding graph construction, asset tracking, and spatial query resolution. Within the broader Indoor Mapping Architecture & Standards framework, Z-axis normalization serves as the critical bridge between raw survey data and routable topology.
Accurate level mapping begins with coordinate alignment. Raw BIM exports, LiDAR point clouds, and CAD floor plans rarely share a unified vertical datum. Facilities teams must reconcile local building datums (e.g., architectural floor-to-floor heights, structural slab elevations, or MEP clearance zones) with the facility’s Indoor Coordinate Reference Systems. Misalignment at this stage propagates downstream, causing vertical drift in routing engines, duplicated POI instances across phantom floors, and failed fallback handoffs when GPS signals degrade. When POIs inherit incorrect Z-indices, classification hierarchies break, leading to mismatched asset metadata and degraded POI Taxonomy & Classification integrity across multi-tenant campuses.
Step-by-Step Z-Axis Normalization Pipeline
The following pipeline transforms continuous elevation samples into discrete, routable Z-levels. It is engineered for integration into automated GIS processing workflows and Python-based mapping pipelines.
Phase 1: Raw Elevation Ingestion & Noise Filtering
Raw elevation data contains measurement noise, structural protrusions, and temporary obstructions. Before clustering, apply robust statistical filtering to isolate true walking surfaces and structural slabs. Remove outliers using interquartile range (IQR) clipping or median absolute deviation (MAD) to prevent ceiling fixtures, suspended lighting, or floor drains from skewing floor centroids. For point cloud workflows, downsample using voxel grid filtering prior to Z-extraction to maintain computational efficiency.
Phase 2: Hierarchical Clustering & Floor Discretization
Continuous Z-values must be discretized into logical floors. Ward’s hierarchical clustering or DBSCAN with a vertical distance threshold reliably groups elevation samples into distinct strata. The clustering tolerance should align with facility-specific vertical clearances (typically 2.8–4.5 meters for standard office/retail, 1.8–2.5 meters for mezzanines). Refer to Converting CAD elevations to indoor Z-levels when extracting Z-values directly from DWG/DXF layer properties, as CAD files often store elevations as relative offsets rather than absolute heights.
Phase 3: Z-Index Assignment & Graph Construction
Once clusters are identified, map them to sequential integer indices (0, 1, 2…) representing logical floor levels. These indices become the primary key for multi-level graph construction, enabling vertical edge generation (stairs, elevators, ramps) and cross-floor routing queries. The resulting topology must support directed traversal rules (e.g., escalator directionality, elevator call logic) and fallback routing when vertical connectors are temporarily unavailable.
Production Implementation
The following Python module implements the complete normalization pipeline. It is designed for batch processing, includes structured logging, and outputs a NetworkX MultiDiGraph ready for indoor wayfinding engines.
import numpy as np
import networkx as nx
from scipy.cluster.hierarchy import linkage, fcluster
from dataclasses import dataclass, field
from typing import List, Tuple, Dict, Optional
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
@dataclass
class ZLevelConfig:
vertical_tolerance: float = 0.35 # meters
min_cluster_size: int = 5
outlier_iqr_factor: float = 1.5
graph_type: str = "MultiDiGraph"
def filter_elevation_noise(
z_values: np.ndarray,
iqr_factor: float = 1.5
) -> np.ndarray:
"""Remove elevation outliers using IQR clipping."""
q75, q25 = np.percentile(z_values, [75, 25])
iqr = q75 - q25
lower_bound = q25 - iqr_factor * iqr
upper_bound = q75 + iqr_factor * iqr
mask = (z_values >= lower_bound) & (z_values <= upper_bound)
filtered = z_values[mask]
if len(filtered) < 5:
raise ValueError("Too few valid elevation samples after IQR filtering.")
return filtered
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 logical floor clusters."""
if len(z_values) < min_size:
raise ValueError("Insufficient samples for clustering.")
# Reshape for scipy linkage
z_reshaped = z_values.reshape(-1, 1)
Z_linkage = linkage(z_reshaped, method='ward')
# Convert tolerance to max cophenetic distance threshold
# fcluster uses distance threshold directly
cluster_labels = fcluster(Z_linkage, t=tolerance, criterion='distance')
# Map labels to sequential 0-based indices
unique_labels = np.unique(cluster_labels)
label_to_idx = {lbl: idx for idx, lbl in enumerate(unique_labels)}
z_indices = np.array([label_to_idx[lbl] for lbl in cluster_labels])
# Group original values by cluster
floor_centroids = {}
for idx in range(len(unique_labels)):
mask = z_indices == idx
if np.sum(mask) >= min_size:
floor_centroids[idx] = z_values[mask]
else:
logging.warning(f"Cluster {idx} dropped (size < {min_size})")
z_indices[mask] = -1 # Mark as invalid
return z_indices, floor_centroids
def build_multilevel_graph(
floor_centroids: Dict[int, np.ndarray],
vertical_connectors: Optional[List[Tuple[int, int, str]]] = None
) -> nx.MultiDiGraph:
"""Construct a routable multi-level graph from clustered floors."""
G = nx.MultiDiGraph()
# Add floor nodes with centroid metadata
for floor_idx, elevations in floor_centroids.items():
centroid_z = float(np.median(elevations))
G.add_node(floor_idx, z_centroid=centroid_z, floor_id=floor_idx)
# Add vertical edges (stairs, elevators, ramps)
if vertical_connectors:
for src, dst, conn_type in vertical_connectors:
if src in G.nodes and dst in G.nodes:
G.add_edge(src, dst, type=conn_type, weight=1.0)
G.add_edge(dst, src, type=conn_type, weight=1.0)
return G
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 pipeline."""
logging.info("Starting Z-axis normalization pipeline...")
# Phase 1: Filter
filtered_z = filter_elevation_noise(raw_elevations, config.outlier_iqr_factor)
logging.info(f"Filtered {len(raw_elevations)} -> {len(filtered_z)} valid samples")
# Phase 2: Cluster
z_indices, floor_centroids = cluster_floors(
filtered_z, config.vertical_tolerance, config.min_cluster_size
)
logging.info(f"Identified {len(floor_centroids)} routable floors")
# Phase 3: Graph
G = build_multilevel_graph(floor_centroids, vertical_connectors)
logging.info(f"Graph constructed: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
return G
# --- Example Execution ---
if __name__ == "__main__":
# Simulate raw LiDAR/CAD elevation samples with noise
np.random.seed(42)
floor_0 = np.random.normal(0.0, 0.05, 150)
floor_1 = np.random.normal(3.2, 0.08, 180)
floor_2 = np.random.normal(6.5, 0.06, 140)
noise = np.random.uniform(-2.0, 10.0, 30) # Outliers
raw_data = np.concatenate([floor_0, floor_1, floor_2, noise])
connectors = [(0, 1, "stair"), (1, 2, "elevator"), (0, 2, "ramp")]
graph = normalize_z_pipeline(raw_data, vertical_connectors=connectors)
print("\nFloor Metadata:")
for n, d in graph.nodes(data=True):
print(f" Floor {n}: Z-Centroid = {d['z_centroid']:.2f}m")
Troubleshooting & Validation
| Symptom | Root Cause | Diagnostic & Resolution |
|---|---|---|
| Phantom floors detected | Overly tight vertical_tolerance or unfiltered ceiling fixtures |
Increase tolerance to 0.4–0.6m. Verify IQR clipping removes >95% of non-slab returns. Cross-check with architectural floor schedules. |
| Vertical drift across datasets | Mismatched vertical datums (e.g., NAVD88 vs. local building 0.00) | Run a datum transformation before ingestion. Anchor to a known benchmark or structural column base. Validate against Indoor Coordinate Reference Systems alignment protocols. |
| Mezzanine floors merge with main levels | Insufficient sample density or low clearance (<1.8m) | Reduce vertical_tolerance to 0.25m. Apply spatial masking to isolate mezzanine footprints before clustering. Use DBSCAN with eps=0.2 for irregular geometries. |
| Graph disconnected across floors | Missing or mislabeled vertical connectors | Audit stair/elevator shaft coordinates. Ensure connector tuples reference valid floor indices. Implement a fallback BFS to detect unreachable Z-levels. |
| Performance degradation on large campuses | O(n²) linkage scaling on dense point clouds | Pre-aggregate Z-values using a 0.5m voxel grid. Switch to scipy.cluster.hierarchy’s single or average methods for faster approximate clustering, or use sklearn.cluster.AgglomerativeClustering with compute_full_tree=False. |
Validation Checklist for Facilities & GIS Teams
- Centroid Verification: Compare computed Z-centroids against architectural floor-to-floor heights. Acceptable variance: ±0.15m.
- Topology Integrity: Run
nx.is_strongly_connected(G)on the routing subgraph. Isolated nodes indicate missing vertical transitions. - POI Alignment: Cross-reference asset coordinates with assigned floor indices. Misaligned POIs should trigger a spatial join re-evaluation against the normalized Z-index layer.
- Fallback Routing Test: Simulate GPS degradation and verify that the routing engine correctly transitions from outdoor lat/lon to indoor (X, Y, Z_index) coordinates without elevation snapping errors.
For enterprise deployments, integrate this pipeline into CI/CD mapping workflows using containerized execution and automated schema validation against OGC IndoorGML specifications (OGC IndoorGML Standard). Reference the official SciPy clustering documentation and NetworkX MultiDiGraph API for parameter tuning and graph serialization.