Indoor Coordinate Reference Systems: Architecture & Implementation
An indoor Coordinate Reference System (CRS) is the facility-local Cartesian frame that every other stage of the Indoor Mapping Architecture & Standards reference assumes is already correct — it is where raw CAD/BIM geometry, sensor telemetry, and the routing graph all agree on what “one metre to the left” means. Unlike a global geographic system such as WGS84 (EPSG:4326), an indoor CRS decouples from geodetic curvature and instead pins a building to a localized, high-precision plane that stays deterministic across floor levels, structures, and navigation meshes.
The Problem: Local Frames That Silently Disagree
The failure this collection solves is subtle because it almost never crashes. A floor plan imported with the wrong drawing unit does not throw — it just produces a routing graph where a 45-metre corridor is computed as 45,000 metres, so the pathfinder reports a fifteen-hour walk between two adjacent rooms. A CAD file exported under a rotated User Coordinate System (UCS) does not error either; it quietly skews every polygon a few degrees, and points of interest drift diagonally further from their true position the deeper into the building you go. An arbitrary (0,0) anchor looks fine on one floor and then misaligns the moment a second building, an RTLS anchor set, or an emergency egress graph has to share the same coordinate space.
The common root cause is that each upstream source — AutoCAD, a BIM viewer, a survey crew, an IoT vendor — ships geometry in its own local frame with its own unit, origin, and rotation, and none of them announce it. An indoor CRS is the contract that forces all of them onto one metric, orthogonal, survey-anchored plane before any distance-based logic runs. This page covers how to define that contract and implement the transformer that enforces it.
Prerequisites & Dependencies
Before implementing the transformer, pin the libraries and agree the coordinate assumptions the rest of the pipeline depends on:
numpy— affine matrix math (rotation, scale, translation) infloat64. Single precision silently loses millimetres across a 500 m span, so the contract isdtype=float64end to end.pyproj/ PROJ — only needed when the local frame must bridge to a global datum for campus-wide GIS interoperability. Indoor frames rarely tie to a geodetic datum, but emitting a PROJ-compatible descriptor keeps the local grid parseable by standard tooling.- Coordinate contract: output is a local Cartesian frame, units in metres, X and Y strictly orthogonal, origin at a surveyed reference point, and Z = 0 at the finished floor level (FFL) of the ground floor. Geometry only reaches this stage after vector ingestion — the SVG/DWG parsing workflows already deliver a unit-aligned, Y-up
FeatureCollection; the CRS step then anchors that geometry to a stable, documented datum.
Three constraints are non-negotiable, and every later stage assumes all three hold:
- Metric consistency. One CRS unit equals exactly one metre. CAD/BIM imports default to millimetres, inches, or arbitrary drawing units, which breaks every distance-based routing weight.
- Orthogonal floor planes. X and Y must stay strictly perpendicular. A skewed UCS or rotated viewport introduces angular drift that corrupts polygon topology and sensor fusion.
- Deterministic origin placement. The
(0,0)anchor must be physically stable, survey-grade, and documented, or every downstream integration inherits the same offset error.
Architecture: One Affine Transform, Three Stages
An indoor CRS transform is a single affine pipeline — scale, then rotate, then translate — applied to every incoming coordinate. Scale converts the source drawing unit to metres; rotation aligns CAD north to grid/true north; translation moves the source origin onto the surveyed facility datum. The Z component is handled separately because it is referenced to the FFL datum rather than the planar origin, and conflating the two is the most common vertical bug.
Origin selection & datum anchoring
The origin must sit at a physically stable, survey-grade reference point. Common enterprise practice anchors it to a structural grid intersection (e.g. column line A-1), a survey control monument tied to the building footprint, or the south-west exterior corner so the whole facility occupies positive coordinate space. For deployments spanning more than one structure, origin choice has to be harmonized campus-wide; the hierarchical datum tree and inter-building transforms are covered in how to define indoor CRS for multi-building campuses.
Vertical referencing & floor alignment
Vertical referencing is the single largest source of indoor mapping failures. The Z-axis must represent true elevation relative to a consistent floor datum, not the arbitrary elevation offsets CAD files carry. Standard practice sets Z = 0 at ground-floor FFL, positive increments for upper floor levels and negative for subterranean, with stair and elevator shafts modelled as continuous vertical volumes rather than discrete slices. The floor-to-floor transform logic that turns these offsets into a connected vertical stack is detailed in level mapping and Z-axis logic.
Step-by-Step Implementation
The following module builds and applies a production indoor CRS transformer. Each step is runnable on its own; together they take raw CAD coordinates to a routing-engine-ready metric frame.
1. Declare the facility datum as configuration
Treat the CRS parameters as infrastructure-as-code: an immutable, versioned record stored alongside the floor plan revision. A dataclass makes the contract explicit and reviewable.
import logging
from dataclasses import dataclass
from typing import Tuple
import numpy as np
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("indoor_crs")
@dataclass(frozen=True)
class IndoorCRSConfig:
"""Versioned, facility-specific local Cartesian CRS definition."""
origin_xy: Tuple[float, float] # surveyed (x, y) datum, in metres
origin_z: float # Z offset for ground-floor FFL, in metres
rotation_deg: float # clockwise CAD-north -> grid-north correction
scale_factor: float # source units -> metres (e.g. 0.001 for mm)
tolerance_orthogonality: float = 1e-6
tolerance_scale: float = 1e-4
def __post_init__(self) -> None:
if self.scale_factor <= 0:
raise ValueError(f"scale_factor must be positive, got {self.scale_factor}")
logger.info(
"CRS configured: origin=%s rot=%.3f deg scale=%g",
self.origin_xy, self.rotation_deg, self.scale_factor,
)
2. Build the affine matrices once
Constructing the rotation matrix and translation vector at initialization keeps the per-coordinate hot path to two matrix operations.
class IndoorCRSTransformer:
"""Apply scale -> rotate -> translate to map CAD coords into an indoor CRS."""
def __init__(self, config: IndoorCRSConfig) -> None:
self.config = config
rad = np.radians(config.rotation_deg)
self.rotation_matrix: np.ndarray = np.array(
[[np.cos(rad), -np.sin(rad)],
[np.sin(rad), np.cos(rad)]],
dtype=np.float64,
)
self.translation: np.ndarray = np.asarray(config.origin_xy, dtype=np.float64)
logger.info("Transformer built for rotation %.3f deg", config.rotation_deg)
3. Transform raw CAD coordinates to the indoor CRS
The transform accepts an Nx2 or Nx3 array. Casting to float64 up front is deliberate — it is the cheapest defence against the precision drift that appears in large facilities.
def transform_to_indoor(self, cad_coords: np.ndarray) -> np.ndarray:
"""Map an Nx2/Nx3 array of CAD coordinates into the indoor CRS (metres)."""
coords = np.asarray(cad_coords, dtype=np.float64)
if coords.ndim != 2 or coords.shape[1] not in (2, 3):
raise ValueError(f"Expected an Nx2 or Nx3 array, got shape {coords.shape}")
xy = coords[:, :2] * self.config.scale_factor # 1. scale to metres
xy = xy @ self.rotation_matrix.T # 2. rotate to grid north
xy = xy + self.translation # 3. translate to datum
if coords.shape[1] == 3:
z = coords[:, 2] * self.config.scale_factor + self.config.origin_z
logger.info("Transformed %d points (3D)", coords.shape[0])
return np.column_stack((xy, z))
logger.info("Transformed %d points (2D)", coords.shape[0])
return xy
4. Emit a PROJ descriptor for GIS bridging
Indoor frames rarely tie to a global datum, so the descriptor is a Transverse Mercator at (lat_0=0, lon_0=0, k=1): PROJ tooling parses it cleanly and treats the result as a planar metric grid offset by the facility origin. Because tmerc is planar, the Z offset is tracked separately and applied to elevations at export.
def proj_string(self) -> str:
"""Return a PROJ-parseable descriptor for the local engineering grid."""
ox, oy = self.config.origin_xy
descriptor = (
f"+proj=tmerc +lat_0=0 +lon_0=0 +k=1 "
f"+x_0={ox} +y_0={oy} +units=m +no_defs"
)
logger.info("PROJ descriptor: %s", descriptor)
return descriptor
5. Run the pipeline against a surveyed datum
The usage workflow wires the steps together: declare the datum, load geometry, transform, then diagnose.
# Facility datum: surveyed origin, no rotation, CAD authored in millimetres.
config = IndoorCRSConfig(
origin_xy=(125.50, 88.20),
origin_z=0.0,
rotation_deg=0.0,
scale_factor=0.001, # mm -> m
)
transformer = IndoorCRSTransformer(config)
raw_cad = np.array([
[1000.0, 2000.0, 0.0],
[1500.0, 2500.0, 3500.0],
[ 0.0, 0.0, 0.0],
]) # Nx3: x, y, z in millimetres
indoor_coords = transformer.transform_to_indoor(raw_cad)
logger.info("Indoor coordinates:\n%s", indoor_coords)
logger.info("Local grid: %s", transformer.proj_string())
Edge Cases & Gotchas
CRS bugs surface as routing anomalies and POI drift rather than exceptions. The patterns below cover the failures that reach production most often.
| Symptom | Root cause | Diagnostic step | Resolution |
|---|---|---|---|
| Routing distances 1000× too large/small | Unit mismatch (mm/inch vs metre) | np.ptp(coords, axis=0) vs a known floor dimension |
Set the correct scale_factor |
| POIs shift diagonally, worse deeper into the floor | Non-orthogonal UCS or rotated viewport | Dot product of transformed basis vectors | Apply rotation_deg; re-export with UCS=World |
| Z jumps between adjacent rooms | Arbitrary CAD elevation offsets, no FFL datum | Per-floor Z histogram; look for a bimodal split | Normalize to FFL; apply floor-level Z offsets |
| Drift in facilities over ~500 m span | float32 precision loss (IEEE 754) |
Assert coords.dtype == np.float64 |
Cast all arrays to float64 before transform |
| Cross-building routing fails at connectors | Misaligned inter-building datums | Compare shared corridor coords across buildings | Use the campus hierarchical datum tree |
A particularly nasty variant of the orthogonality bug is double rotation: geometry exported from a rotated UCS and then corrected with rotation_deg lands twice-rotated and plausibly wrong. Always confirm the source export convention before adding a rotation correction.
Validation Output
The contract is verifiable, so gate it. The routine below transforms a set of points whose true metric dimensions are surveyed, then asserts the result is within tolerance. Deploy it in CI before any routing graph is built — a malformed CRS corrupts adjacency weights irreversibly, and catching it costs one assertion.
def validate_crs_integrity(
transformer: IndoorCRSTransformer,
surveyed: dict[str, float],
tolerance_m: float = 0.05,
) -> bool:
"""Assert a transformed test frame matches surveyed facility dimensions."""
# Corner-to-corner along X, plus one floor height along Z, in millimetres.
probe = np.array([[0.0, 0.0, 0.0],
[surveyed["hallway_length_m"] * 1000.0, 0.0, 0.0],
[0.0, 0.0, surveyed["floor_height_m"] * 1000.0]])
out = transformer.transform_to_indoor(probe)
measured_len = float(np.linalg.norm(out[1, :2] - out[0, :2]))
measured_h = float(out[2, 2] - out[0, 2])
len_err = abs(measured_len - surveyed["hallway_length_m"])
h_err = abs(measured_h - surveyed["floor_height_m"])
if len_err > tolerance_m or h_err > tolerance_m:
logger.error("CRS drift: length err %.3f m, Z err %.3f m", len_err, h_err)
return False
logger.info("CRS integrity validated (len %.3f m, Z %.3f m)", measured_len, measured_h)
return True
A correct run prints INFO: CRS integrity validated (len 45.200 m, Z 3.800 m); an incorrect one (for example, scale_factor left at 1.0 for a millimetre drawing) prints ERROR: CRS drift: length err 45154.800 m, Z err 3796.200 m — the 1000× signature that pins the failure to a unit mismatch immediately. Pair this with an orthogonality assertion:
def assert_orthogonal(transformer: IndoorCRSTransformer) -> None:
"""Confirm the basis stays perpendicular after rotation (no skew)."""
basis = np.array([[1.0, 0.0], [0.0, 1.0]]) @ transformer.rotation_matrix.T
dot = float(np.dot(basis[0], basis[1]))
if abs(dot) > transformer.config.tolerance_orthogonality:
raise AssertionError(f"Non-orthogonal basis, dot={dot:.2e}")
logger.info("Basis orthogonal (dot=%.2e)", dot)
Performance & Scale Notes
The transform itself is cheap: it is two vectorised numpy operations per coordinate, so cost is O(n) in vertex count and a full building floor of a few hundred thousand vertices transforms in single-digit milliseconds. The real costs are memory and precision, not arithmetic. Keep everything in float64 — the ~8 byte per ordinate is the price of not accumulating millimetre drift across a large span — and transform whole arrays at once rather than point-by-point so the work stays inside numpy’s C loop.
For portfolio-scale ingestion, the CRS parameters are tiny and stable, so the high-leverage move is caching the transformed geometry, not recomputing it: store the IndoorCRSConfig (a few floats) under version control next to each floor plan, attach a topology hash to the transformed output, and only re-run the transform when the config or the source geometry actually changes. Per-floor-level results then merge into the campus stack, where they feed the routing graph and resilient fallback routing architectures that depend on every floor sharing one metric frame.
Frequently Asked Questions
Why not just use WGS84 / EPSG:4326 indoors and skip the local frame entirely?
Geographic coordinates are angular and tied to an ellipsoid, so distance and area calculations near a building require an on-the-fly projection that introduces millimetre-to-centimetre error and real CPU cost on every routing weight. An indoor CRS is already planar and metric, so sqrt(dx² + dy²) is the true distance with no projection step. Keep WGS84 only at the campus boundary, where PROJ bridges the local grid to the wider world for GIS interoperability.
How do I choose `rotation_deg` when the CAD file looks "straight" but POIs still drift?
Drift that grows with distance from the origin is the tell for a rotation, not a translation, error. Measure the angle between a known-orthogonal building feature (a long exterior wall) and the CAD X-axis, and use that as rotation_deg. Then confirm with the orthogonality assertion: if the dot product of the transformed basis vectors is non-zero, the export carried a skew that a single rotation cannot fix and the drawing must be re-exported with UCS=World.
Should the Z offset live in the CRS transform or in the floor-level logic?
Keep the ground-floor FFL offset (origin_z) in the CRS so a single transformed point is fully metric, but keep the per-floor-level stacking — which floor maps to which elevation band, and how connectors span them — in the level mapping and Z-axis logic layer. Conflating the two is what produces Z jumps between adjacent rooms, because the planar origin and the vertical datum get edited together by mistake.
How do I keep POI coordinates from disagreeing with the routing graph?
Register both against the same IndoorCRSConfig before either is built. If POIs are placed in a different frame, room centroids and routing nodes resolve to slightly different coordinate spaces and proximity queries fail intermittently. Align POI registration with the POI taxonomy and classification pipeline so labels, asset tags, and graph nodes all transform through one CRS.
Related
- How to define indoor CRS for multi-building campuses
- Level Mapping & Z-Axis Logic
- POI Taxonomy & Classification
- Fallback Routing Architectures
This page is part of the Indoor Mapping Architecture & Standards section of the Indoor Mapping & Wayfinding Automation reference.