This document describes the complete technical architecture of the PositionMe indoor positioning Android app. It covers every algorithm, data flow, parameter, and design decision. Intended as a reference for anyone who needs to understand, modify, or present this system.
PositionMe is a real-time indoor positioning app that fuses multiple positioning sources using a particle filter to estimate the user's location inside buildings. The system operates on Android and targets the Nucleus Building and Noreen & Kenneth Murray Library at the University of Edinburgh.
Sensors (IMU, Barometer, WiFi, GNSS)
|
v
SensorFusion (data collection & synchronisation)
|
+----+----+----+
| | | |
v v v v
PDR GNSS WiFi KNN
(heading + step) (API) (fingerprint)
| | | |
+----+----+----+
|
v
ParticleFilter (500 particles, SIR)
- Predict: PDR displacement + noise
- Update: GNSS/WiFi/KNN likelihood weighting
- Map matching: wall collision + projection
- Resample: when particle diversity drops
|
v
Fused Position Estimate (weighted mean)
|
+----+----+
| |
v v
Display Protobuf Recording
(map + UI) (upload to server)
All positioning operates in a local East-North-Up (ENU) coordinate system centred on the particle filter's initialisation point.
metersPerDegLat = 111320.0 (constant)
metersPerDegLng = 111320.0 * cos(originLat) (latitude-dependent)
toEasting(lng) = (lng - originLng) * metersPerDegLng
toNorthing(lat) = (lat - originLat) * metersPerDegLat
toLat(northing) = originLat + northing / metersPerDegLat
toLng(easting) = originLng + easting / metersPerDegLng
This is a flat-Earth approximation, accurate within ~1cm for building-scale distances (<1 km).
File: ParticleFilter.java:56-73
PDR estimates displacement from IMU data. It is the primary motion model for the particle filter's predict step.
The app uses Android's TYPE_STEP_DETECTOR sensor as the primary step detector, with a 200ms debounce to prevent double-counting the same physical step. If the step detector is silent for more than 500ms, it falls back to TYPE_STEP_COUNTER as a backup (capped at 3 catch-up steps per event).
File: SensorEventHandler.java:176-198
Heading is derived from the Game Rotation Vector sensor (gyroscope + accelerometer, NO magnetometer), which avoids indoor magnetic field interference.
At the start of recording, a one-time offset is computed:
headingOffset = magnetometer_orientation[0] - gameRotationHeading
This aligns the gyro heading to approximate magnetic north. The offset is never updated again during recording — continuous magnetometer drift correction is explicitly disabled because indoor magnetic fields are too distorted and would pull the offset in the wrong direction.
Final heading for PDR:
headingForPdr = gameHeading + headingOffset
The heading is used directly without low-pass filtering. Constants HEADING_LPF_ALPHA and smoothedHeading are defined but unused in the current code — the game rotation vector is stable enough without additional smoothing.
File: SensorEventHandler.java:159-174
Step length is estimated using the Weiberg min-max formula applied to accelerometer magnitude between consecutive steps:
bounce = (maxAccel - minAccel) ^ 0.25
stepLength = bounce * K * 2
Where K = 0.23 (tuned constant). For a typical walking acceleration bounce of ~5 m/s^2, this produces step lengths of ~0.69 m.
If the user enables manual step length in settings, a fixed value from preferences is used instead (default = 0.75 m).
File: PdrProcessing.java:235-262
Position is accumulated in ENU metres from the recording origin (0,0):
adaptedHeading = PI/2 - headingRad // Convert so 0 rad = East
positionX += stepLength * cos(adaptedHeading)
positionY += stepLength * sin(adaptedHeading)
File: PdrProcessing.java:142-178
Uses decomposed gravity and linear acceleration to detect elevator motion:
- Vertical acceleration > 0.3 m/s^2 (significant vertical movement)
- Horizontal acceleration < 0.18 m/s^2 (minimal horizontal movement)
- Both conditions evaluated over a rolling buffer of 100 samples
File: PdrProcessing.java:305-346
The core fusion algorithm is a Sequential Importance Resampling (SIR) Particle Filter with 500 particles.
| Parameter | Value | Description |
|---|---|---|
| N | 500 | Number of particles |
| INIT_SPREAD_M | 5.0 m | Gaussian scatter radius at initialisation |
| PDR_NOISE_SIGMA_M | 0.3 m | Base step length noise floor |
| PDR_STEP_NOISE_RATIO | 0.05 | Additional noise as 5% of step length |
| HEADING_NOISE_RAD | 15 degrees | Per-particle heading perturbation |
| WIFI_SIGMA_M | 5.0 m | WiFi API measurement uncertainty |
| GNSS_SIGMA_DEFAULT_M | 8.0 m | Default GNSS measurement uncertainty |
| WALL_WEIGHT_FACTOR | 0.01 | Weight multiplier for wall-crossing particles (99% penalty) |
| WIFI_OUTLIER_M | 50.0 m | WiFi outlier rejection distance |
| GNSS_OUTLIER_M | 40.0 m | GNSS outlier rejection distance |
File: ParticleFilter.java:22-35
particleFilter.initialize(initPos)
- Sets coordinate origin to
initPos(WGS84) - Converts
initPosto ENU (0,0) - Scatters 500 particles in a Gaussian cloud with sigma = 5.0 m around (0,0)
- Sets all weights to 1/N
- Random heading for each particle: uniform [0, 2*PI]
File: ParticleFilter.java:79-114
Called every 200ms with PDR displacement (dxMeters, dyMeters):
For each particle i:
- Compute PDR heading:
pdrHeading = atan2(dy, dx) - Add heading noise:
particleHeading[i] = pdrHeading + N(0, 15 degrees) - Compute noisy step length:
noisyStep = max(0, stepLen + N(0, max(0.3, stepLen * 0.05))) - Update position:
east[i] += noisyStep * cos(particleHeading[i]) north[i] += noisyStep * sin(particleHeading[i]) - Wall collision check: If the particle's movement crosses any wall segment:
- Project particle onto nearest point on the wall
- Multiply weight by 0.01 (99% penalty)
- Normalize weights and resample if ESS < N/2
File: ParticleFilter.java:137-183
When a new GNSS, WiFi, or KNN measurement arrives:
- Outlier check: If distance from current estimate to measurement > threshold, skip
- Wall blocking check: If line from estimate to measurement crosses a wall, skip
- Apply Gaussian likelihood to each particle:
d2 = (east[i] - measE)^2 + (north[i] - measN)^2 weight[i] *= exp(-0.5 * d2 / sigma^2) - Normalize weights
- Resample if ESS < N/2
Measurement sigmas:
- KNN WiFi: 3.0 m (tightest, most accurate)
- WiFi API: 5.0 m
- GNSS outdoor: actual reported accuracy
- GNSS indoor: max(reported accuracy, 15.0 m) — clamped to reduce indoor pull
File: ParticleFilter.java:185-242
Weighted mean of all particles:
estimateE = sum(weight[i] * east[i])
estimateN = sum(weight[i] * north[i])
The estimate is also wall-checked: if the new estimate would cross a wall from the previous estimate, it is projected back onto the nearest wall.
File: ParticleFilter.java:244-265
Method: Residual resampling (Algorithm 2, Arulampalam et al.)
Trigger: When Effective Sample Size < N/2 = 250
ESS = 1 / sum(weight[i]^2)
Residual resampling:
- Each particle gets
floor(N * weight[i])deterministic copies - Remaining slots filled stochastically from residual weights
- All resampled particles reset to weight = 1/N
File: ParticleFilter.java:277-325
A K-Nearest Neighbours algorithm that matches live WiFi scans against pre-collected fingerprint data.
- Stored in
assets/knn_data/wifi_fingerprints.json - Each entry:
{lat, lng, floor, aps: [{bssid, rssi, ssid}, ...]} - Currently 351 fingerprints across Nucleus GF, F1, F2, F3, and Basement
- Collected by long-pressing on the map in the Start Location screen
- Get current WiFi scan results from Android
WifiManager.getScanResults() - Build RSSI vector:
{bssid -> rssi}for all visible APs - For each training fingerprint, compute Euclidean distance:
Missing APs are filled with -100 dBm
allKeys = currentScan.keys UNION fingerprint.keys distance = sqrt(sum over allKeys of (rssi_current - rssi_fingerprint)^2) - Sort all fingerprints by distance
- Take K=3 nearest, compute inverse-distance weighted average:
weight = 1 / (distance + 1e-6) result = sum(weight * position) / sum(weight)
estimatePositionForFloor(floor) filters training data by floor index before running KNN. Used for floor-specific positioning when the current floor is known.
File: KnnPositioner.java
When recording starts, a 9-second calibration phase begins:
| Parameter | Value |
|---|---|
| CALIBRATION_TIMEOUT_MS | 9000 ms |
| CALIBRATION_SAMPLES | 3 (minimum target) |
During calibration:
- PDR displacement is frozen (previousPosX/Y updated but not accumulated)
- KNN/WiFi position samples are collected
- GNSS position samples are collected separately
- Calibration ends when 3 samples collected OR 9 seconds elapsed
if (GNSS accuracy < 15m):
initPos = average(GNSS samples) // outdoor/entrance
else if (KNN/WiFi samples available):
initPos = average(KNN/WiFi samples) // indoor
else if (GNSS available):
initPos = average(GNSS samples) // fallback
else:
abort (no position available)
No user selection is involved.
If PF was initialised from poor GNSS and a better KNN/WiFi fix arrives later:
particleFilter.reinitializeAround(betterPos, 5.0m)
This re-scatters all particles around the better position.
For the first 15 seconds after PF initialisation, KNN sigma is loosened from 3.0m to 15.0m. This prevents stale WiFi scan cache from pulling the estimate back to the starting position.
KNN_WARMUP_MS = 15000
sigma = (sinceInit < 15s) ? 15.0 : 3.0
File: RecordingFragment.java:338-420, 473-483
Building floor plans are loaded from a floor plan API. Each floor contains features with an indoor_type:
| Type | Description | Effect on Particles |
|---|---|---|
"wall" |
Areas that cannot be crossed | Particles crossing walls are projected back + 99% weight penalty |
"room" |
Room boundaries (also walls) | Same as wall |
"stairs" |
Staircase zones | PDR step scaled to 20% when elevation changing |
"lift" |
Elevator zones | PDR step scaled to 0% when elevation changing |
Uses 2D line-segment intersection (cross-product method):
For each particle movement (oldE,oldN) -> (newE,newN):
For each wall segment (wallE1,wallN1,wallE2,wallN2):
if segments_intersect:
project particle onto nearest point on wall
weight *= 0.01
Also applied to the weighted mean estimate: if the new estimate would cross a wall from the previous estimate, it is snapped back.
Also applied to measurements: if the line from the current estimate to a measurement crosses a wall, the measurement is skipped entirely.
File: ParticleFilter.java:159-167, 327-390
Two conditions must BOTH be true:
- User is within 5m of a stairs/lift transition zone (
getNearestTransitionZoneType) - Barometer detects elevation change > 0.3m per 200ms tick
| Zone Type | Step Scale | Meaning |
|---|---|---|
"lift" + elevation changing |
0.0 | No horizontal movement (elevator) |
"stairs" + elevation changing |
0.20 | 20% horizontal movement (stairs) |
| Neither / no elevation change | 1.0 | Normal walking |
File: RecordingFragment.java:436-457
The Auto Floor system uses:
- WiFi-based floor detection from the indoor map manager
- Barometric elevation tracking relative to the start elevation
- Floor height constants: Nucleus 4.2m, Library 3.6m, Murchison 4.0m
When Auto Floor is enabled, the floor label updates automatically and wall segments are refreshed for the new floor. Manual floor buttons disable Auto Floor.
File: IndoorMapManager.java
If the particle filter estimate stops moving while PDR indicates walking, particles are likely trapped behind walls.
Every 200ms tick:
if (PDR step distance > 0.2m AND estimate moved < 0.1m):
stuckCounter++
else:
stuckCounter = 0
if (stuckCounter >= 8): // ~1.6 seconds stuck
reinitialize particles around best KNN/WiFi position with 5m spread
stuckCounter = 0
File: RecordingFragment.java:490-510
The red arrow marker shows the particle filter's weighted mean estimate, updated every 200ms.
When enabled, applies three layers:
-
EMA (Exponential Moving Average) on position data:
displayLat = 0.35 * newLat + 0.65 * previousDisplayLat displayLng = 0.35 * newLng + 0.65 * previousDisplayLngReduces single-frame jitter.
-
Catmull-Rom Spline Interpolation on trajectory polyline:
- 5 subdivision points between each pair of raw trajectory points
- Produces smooth curves through all original points
- Converts sharp corners into natural arcs
-
Animation Interpolation on marker movement:
- 180ms linear interpolation from old position to new position
- Applied regardless of smooth toggle
File: TrajectoryMapFragment.java:69-75, 397-465, 489-535
Toggle switch "P W G" shows the last 15 observations:
- Yellow (P): PDR positions —
argb(200, 230, 180, 0) - Green (W): WiFi API positions —
argb(180, 0, 180, 0) - Blue (G): GNSS positions —
argb(180, 33, 100, 243)
Each dot is a 0.5m radius circle on the map. Oldest dots are removed when count exceeds 15.
File: TrajectoryMapFragment.java:604-630
Toggle switch "Particles" shows 80 randomly sampled particles as red circles, updated every 1 second to maintain performance.
| Control | Function |
|---|---|
| Show GNSS | Blue marker + polyline for raw GNSS path |
| WiFi | Green marker for WiFi API position |
| Auto Floor | Automatic floor detection on/off |
| Color | Toggle trajectory line red/black |
| Normal/Hybrid/Satellite | Map type selector |
| Floor Up/Down | Manual floor change (disables Auto Floor) |
| Test Point (+) | Place numbered ground truth marker |
| Info (i) | Help dialog |
| Cancel | Discard recording (with confirmation) |
| Complete | Finish recording, go to upload |
Data is stored in Protocol Buffers (protobuf) format using the Traj.Trajectory schema.
Recorded fields:
imu_data: accelerometer, gyroscope, rotation vector, step count (50 Hz max, throttled)pdr_data: cumulative X/Y displacement in metresgnss_data: WGS84 lat/lng with accuracy, speed, bearingwifi_data: WiFi scan results with BSSIDs and RSSIspressure_data: barometric pressuremagnetometer_data: magnetic field vectorlight_data,proximity_data: ambient sensorsinitial_position: starting WGS84 coordinatescorrected_positions: fused positions (if written)test_points: user-placed ground truth markers with timestamps
The recording timer runs at TIME_CONST = 20ms intervals, but a hard throttle ensures IMU writes are spaced at least 20ms apart (50 Hz maximum). This prevents Android Timer jitter from exceeding the server's frequency limit.
if (currentTime - lastImuWriteTime < 20ms) return; // skip this tickFile: TrajectoryRecorder.java:35, 548-560
Trajectories are uploaded to the openpositioning API as protobuf binary. The server validates IMU frequency and rejects data exceeding its limit.
The app automatically detects which building the user is in based on GPS coordinates. Building boundaries are defined as polygons.
| Building | Code | Floor Height |
|---|---|---|
| Nucleus | 1 | 4.2 m |
| Library | 2 | 3.6 m |
| Murchison | 3 | 4.0 m |
A "sticky radius" of 25m keeps the indoor map loaded even if GPS briefly places the user outside the building boundary (which happens frequently indoors due to GPS inaccuracy).
Each building floor has:
- Wall/room boundaries (line segments for collision detection)
- Stairs zones (polygons)
- Lift zones (polygons)
Wall segments are converted from WGS84 to the particle filter's local ENU coordinates when loaded.
File: IndoorMapManager.java
| Parameter | Value | File:Line |
|---|---|---|
| Particles | 500 | ParticleFilter.java:23 |
| Init spread | 5.0 m | ParticleFilter.java:24 |
| PDR noise floor | 0.3 m | ParticleFilter.java:25 |
| PDR noise ratio | 5% | ParticleFilter.java:26 |
| Heading noise | 15 deg | ParticleFilter.java:27 |
| WiFi sigma | 5.0 m | ParticleFilter.java:28 |
| GNSS sigma default | 8.0 m | ParticleFilter.java:29 |
| Wall weight penalty | 0.01 | ParticleFilter.java:30 |
| WiFi outlier | 50.0 m | ParticleFilter.java:32 |
| GNSS outlier | 40.0 m | ParticleFilter.java:33 |
| Parameter | Value | File:Line |
|---|---|---|
| K neighbours | 3 | KnnPositioner.java:32 |
| Default RSSI | -100 dBm | KnnPositioner.java:33 |
| KNN sigma | 3.0 m | RecordingFragment.java:109 |
| KNN warmup | 15 s | RecordingFragment.java:101 |
| KNN warmup sigma | 15.0 m | RecordingFragment.java:479 |
| Parameter | Value | File:Line |
|---|---|---|
| Timeout | 9000 ms | RecordingFragment.java:126 |
| Min samples | 3 | RecordingFragment.java:125 |
| GNSS reliable threshold | < 15 m | RecordingFragment.java:385 |
| Indoor GNSS min sigma | 15.0 m | RecordingFragment.java:464 |
| Parameter | Value | File:Line |
|---|---|---|
| Elevation rate threshold | 0.3 m/tick | RecordingFragment.java:121 |
| Stair step scale | 0.20 | RecordingFragment.java:122 |
| Lift step scale | 0.0 | RecordingFragment.java:449 |
| Transition zone radius | 5.0 m | IndoorMapManager.java |
| Parameter | Value | File:Line |
|---|---|---|
| Weiberg K | 0.23 | PdrProcessing.java:31 |
| Movement threshold | 0.3 m/s^2 | PdrProcessing.java:37 |
| Stillness epsilon | 0.18 m/s^2 | PdrProcessing.java:39 |
| Heading LPF alpha | 0.85 (defined but unused) | SensorEventHandler.java:29 |
| Step debounce | 200 ms | SensorEventHandler.java:206 |
| Step fallback timeout | 500 ms | SensorEventHandler.java:48 |
| Parameter | Value | File:Line |
|---|---|---|
| PDR moving threshold | 0.2 m | RecordingFragment.java:492 |
| Estimate not-moving | < 0.1 m | RecordingFragment.java:497 |
| Stuck counter threshold | 8 ticks (~1.6s) | RecordingFragment.java:117 |
| Reinit spread | 5.0 m | RecordingFragment.java:502 |
| Parameter | Value | File:Line |
|---|---|---|
| EMA alpha | 0.35 | TrajectoryMapFragment.java:72 |
| Spline subdivisions | 5 | TrajectoryMapFragment.java:75 |
| Animation duration | 180 ms | TrajectoryMapFragment.java:444 |
| Max observation dots | 15 | TrajectoryMapFragment.java:79 |
| Dot radius | 0.5 m | TrajectoryMapFragment.java |
| Camera resume delay | 5000 ms | TrajectoryMapFragment.java:386 |
| Particle draw interval | 1000 ms | TrajectoryMapFragment.java |
| UI update frequency | 200 ms | RecordingFragment.java:138 |
| Parameter | Value | File:Line |
|---|---|---|
| IMU write interval | 20 ms (50 Hz max) | TrajectoryRecorder.java:35 |
| IMU hard throttle | 20 ms min gap | TrajectoryRecorder.java:548 |
| Accel filter coefficient | 0.96 | TrajectoryRecorder.java:36 |
fusion/
ParticleFilter.java - SIR particle filter (predict, update, resample, map matching)
KnnPositioner.java - KNN WiFi fingerprint positioning
sensors/
SensorFusion.java - Singleton sensor data aggregator
SensorEventHandler.java - IMU event processing, heading calibration, step detection
SensorState.java - Shared volatile sensor state
TrajectoryRecorder.java - Protobuf recording & server upload
utils/
PdrProcessing.java - PDR step length, elevation, floor, elevator detection
IndoorMapManager.java - Building detection, floor plans, wall/stair/lift features
UtilFunctions.java - Coordinate conversion helpers
WifiFingerprinter.java - WiFi fingerprint collection tool
presentation/fragment/
RecordingFragment.java - Main recording loop: calibration, PF orchestration, display
TrajectoryMapFragment.java - Map display, toggles, polyline, markers, smooth filter
StartLocationFragment.java - Pre-recording: building selection, fingerprint collection
CorrectionFragment.java - Post-recording: review & upload
ReplayFragment.java - Trajectory replay
assets/knn_data/
wifi_fingerprints.json - Merged KNN training data (351 fingerprints)
wifi_fingerprintsGF.json - Ground floor fingerprints
wifi_fingerprintsF1.json - Floor 1 fingerprints
wifi_fingerprintsF2.json - Floor 2 fingerprints
wifi_fingerprintsNucluesF3.json - Floor 3 fingerprints
wifi_fingerprintsNucleusBF.json - Basement fingerprints
Tested with trajectory 3677 (Nucleus F2, 4 test points against surveyed ground truth):
| Method | Mean Error | RMSE | CEP95 |
|---|---|---|---|
| App (fused) | 0.71 m | 0.92 m | 1.65 m |
| GNSS | 9.94 m | 10.16 m | 11.79 m |
| PDR only | 18.50 m | 18.55 m | 19.92 m |
- App vs GNSS improvement: +91%
- App vs PDR improvement: +95%