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) in float64. Single precision silently loses millimetres across a 500 m span, so the contract is dtype=float64 end 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:

  1. 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.
  2. 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.
  3. 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.

The indoor CRS affine pipeline: two tracks converge on one metric frame Raw CAD/BIM geometry, carrying drawing units, an arbitrary origin and CAD north, enters on the left and splits into two tracks. The planar X-Y track passes through three chained stages — scale (source units to metres), rotate (CAD north to grid north), and translate (add the surveyed origin). The vertical Z track passes through scale (source units to metres) then adds the finished-floor-level datum offset, bypassing the planar rotate and translate. Both tracks converge on a single Indoor CRS frame that is metric, orthogonal, Y-up and FFL-referenced, which fans out to three downstream consumers: the routing graph, POI placement, and RTLS sensor fusion. Scale → rotate → translate: two tracks, one survey-anchored frame PLANAR X · Y TRACK VERTICAL Z TRACK Z bypasses planar rotate / translate CAD / BIM geometry units · arbitrary origin · CAD north scale × units → metres rotate CAD north → grid north translate + surveyed origin scale × units → metres + FFL datum offset Z = 0 at ground FFL SHARED FRAME Indoor CRS metres · orthogonal Y-up · FFL-referenced routing graph POI placement RTLS / sensor fusion

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.

This page is part of the Indoor Mapping Architecture & Standards section of the Indoor Mapping & Wayfinding Automation reference.