Trajectory Segmentation
Trajectory segmentation is the process of cutting one continuous stream of GPS fixes into an ordered sequence of meaningful sub-trajectories — trips, stops, and single-mode legs — so that every downstream metric is computed over a coherent unit of movement rather than an arbitrary slice of a device’s day.
Prerequisites Checklist
Segmentation is a mid-pipeline stage: it assumes the raw signal has already been cleaned and that you have a reliable time axis. Confirm the following before running any of the code below. This work sits inside the broader Movement Pattern Extraction & Trajectory Analysis discipline and consumes the output of the upstream cleaning stages.
- Cleaned input. Points must already have passed GPS precision and error handling — DOP filtering, kinematic thresholding, and smoothing. Segmenting raw jitter is the single largest source of spurious segments.
- Python ≥ 3.10 with
pandas ≥ 2.0andnumpy ≥ 1.24for vectorized column operations. pyproj ≥ 3.6for the WGS84 → metric transformation, imported withalways_xy=Trueto avoid axis-order bugs.- Input schema: each row needs
lat(float64),lon(float64), andts(UTCdatetime64), sorted per device. A per-devicedevice_idis expected when processing more than one track. - A metric CRS in mind. Choose the UTM zone covering your study area. Every dwell radius, speed, and heading below is computed in metres — never in raw
EPSG:4326degrees. - A labelled validation subset. A few dozen tracks with known trip and stop boundaries let you calibrate thresholds instead of guessing.
Error & Problem Taxonomy
Segmentation failures are rarely dramatic crashes; they are quiet structural errors that produce plausible-looking but wrong trip sequences. Classifying the failure mode tells you which knob to turn. Most of these traces back to either dirty input or a threshold that treats noise as signal.
| Problem | Mechanism | Symptom | Mitigation |
|---|---|---|---|
| Over-segmentation from noise | Residual GPS jitter creates fake speed or heading spikes on un-smoothed data | One real trip split into dozens of tiny fragments | Segment only cleaned/smoothed input; require sustained change over 2–4 points |
| Missed trip boundaries | Trip ends with the device still logging but not moving, and no time gap appears | Two distinct trips merged into one long segment | Add a dwell-based stop test; do not rely on time gaps alone |
| GPS gaps mistaken for stops | A tunnel or power-save blackout looks like a long stationary period | Phantom “stop” tagged where the device was actually moving | Break at gaps but tag them gap, not stop, unless a dwell test confirms it |
| Mode-change flicker | Speed hovers near a mode boundary (e.g. slow cycling vs brisk walking) | Segment ID toggles rapidly between two modes | Apply hysteresis / minimum segment duration; smooth speed before thresholding |
| Boundary bleed at stops | The first and last points of a stop are assigned to the adjacent move | Trip distance inflated by stop-edge points | Snap stop boundaries to the dwell cluster’s first/last member |
| Single-point and empty tracks | A device reports one fix, or a filter removes every row | Index errors or a segment with undefined speed | Guard explicitly: return the frame unchanged with a single segment or empty |
Deterministic Pipeline Overview
The order of operations matters. You cannot detect a dwell before you have metric coordinates, and you cannot trust a heading change before the track is smoothed. The five stages below are the standard production sequence; each is fully vectorized with no Python-level row iteration.
- Project to a metric CRS. Transform
lat/lonto a UTM zone, producingx_m/y_min metres. Every subsequent distance, dwell radius, and speed is computed here — never on degrees. - Split on temporal gaps. Compute the inter-fix interval; wherever it exceeds the mode’s gap threshold, start a new segment. This isolates trips separated by logging blackouts and prevents a phantom straight line across the missing interval.
- Detect stops by dwell. Slide over the points and flag any run that stays within a radius
Rof its own start for at least a durationD. These runs become stop segments; the movement between them becomes candidate trips. This is the same dwell logic used by stay-point detection algorithms. - Split on movement change. Within each remaining move segment, introduce a break where the smoothed speed or heading changes are sustained across several consecutive points — the signature of a mode or behaviour change rather than momentary noise.
- Label and emit. Assign a monotonically increasing
segment_id, tag each segment asmoveorstop, snap stop boundaries to the dwell cluster, and hand the labelled frame to downstream aggregation.
Implementation Walkthrough
The segment_trajectory function below implements the full sequence for a single device. All geometry is computed in a metric projected CRS (EPSG:32633 by default) — never on raw EPSG:4326 coordinates. Adjust the UTM zone to your data’s extent. The function returns the input frame with an added segment_id and a segment_kind label, plus the intermediate x_m, y_m, and speed_ms columns for auditability.
from __future__ import annotations
import logging
from typing import Literal
import numpy as np
import pandas as pd
from pyproj import Transformer
logger = logging.getLogger(__name__)
TransportMode = Literal["pedestrian", "cyclist", "vehicle", "unknown"]
# Mode-specific segmentation thresholds.
# gap_s : temporal gap (s) that forces a new segment
# stop_r : dwell radius (m) within which points count as "not moving"
# stop_d : minimum dwell duration (s) to qualify as a stop
# move_v : speed (m/s) below which a point is considered stationary
SEGMENT_PARAMS: dict[TransportMode, dict[str, float]] = {
"pedestrian": {"gap_s": 180.0, "stop_r": 20.0, "stop_d": 120.0, "move_v": 0.5},
"cyclist": {"gap_s": 90.0, "stop_r": 25.0, "stop_d": 90.0, "move_v": 0.8},
"vehicle": {"gap_s": 45.0, "stop_r": 30.0, "stop_d": 60.0, "move_v": 1.0},
"unknown": {"gap_s": 60.0, "stop_r": 30.0, "stop_d": 90.0, "move_v": 0.8},
}
def segment_trajectory(
df: pd.DataFrame,
mode: TransportMode = "vehicle",
target_crs_epsg: int = 32633, # UTM zone 33N — adjust to your region
lat_col: str = "lat",
lon_col: str = "lon",
ts_col: str = "ts",
) -> pd.DataFrame:
"""
Split a single-device trajectory into ordered move and stop segments.
Segmentation criteria, applied in order:
1. temporal gap — a break longer than params['gap_s']
2. stop dwell — a run staying within params['stop_r'] metres
for at least params['stop_d'] seconds
3. movement flag — points slower than params['move_v'] m/s are
candidate stop members
Parameters
----------
df : DataFrame with lat, lon (WGS84 degrees) and ts (UTC datetime64),
already cleaned and smoothed upstream. Must be single-device.
mode : Transport mode selecting the threshold profile.
target_crs_epsg : EPSG code of the metric projected CRS used for ALL
distance/speed computation. NEVER 4326 — degrees
are not metres.
Returns
-------
DataFrame sorted by ts with added columns:
x_m, y_m — projected coordinates (metres) in target_crs_epsg
speed_ms — instantaneous speed (m/s), computed in metric CRS
segment_id — monotonic integer id, one per sub-trajectory
segment_kind — 'move' or 'stop'
"""
required = {lat_col, lon_col, ts_col}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Input DataFrame missing columns: {missing}")
# ── Edge case: empty frame ────────────────────────────────────────────────
if df.empty:
logger.warning("segment_trajectory received an empty DataFrame.")
out = df.copy()
for col in ("x_m", "y_m", "speed_ms", "segment_id"):
out[col] = pd.Series(dtype="float64")
out["segment_kind"] = pd.Series(dtype="object")
return out
params = SEGMENT_PARAMS[mode]
out = df.copy()
out[ts_col] = pd.to_datetime(out[ts_col], utc=True)
out = out.sort_values(ts_col).reset_index(drop=True)
# ── Stage 1: project to a metric CRS ──────────────────────────────────────
# All dwell radii and speeds below are metres/seconds — never degrees.
transformer = Transformer.from_crs(
"EPSG:4326", f"EPSG:{target_crs_epsg}", always_xy=True
)
out["x_m"], out["y_m"] = transformer.transform(
out[lon_col].to_numpy(), out[lat_col].to_numpy()
)
# ── Edge case: single point ───────────────────────────────────────────────
if len(out) == 1:
out["speed_ms"] = 0.0
out["segment_id"] = 0
out["segment_kind"] = "stop" # one fix = a point of presence, not travel
return out
# ── Per-step geometry (vectorized, metric) ────────────────────────────────
dt_s = out[ts_col].diff().dt.total_seconds().to_numpy()
dx = out["x_m"].diff().to_numpy()
dy = out["y_m"].diff().to_numpy()
step_dist = np.sqrt(dx**2 + dy**2)
with np.errstate(divide="ignore", invalid="ignore"):
speed = np.where(dt_s > 0, step_dist / dt_s, 0.0)
speed[0] = 0.0
out["speed_ms"] = speed
# ── Stage 2: temporal-gap breaks ──────────────────────────────────────────
gap_break = np.zeros(len(out), dtype=bool)
gap_break[1:] = dt_s[1:] > params["gap_s"]
# ── Stage 3: stop membership by speed, then confirmed by dwell ─────────────
# A point is a stop candidate if it is slower than move_v.
is_slow = out["speed_ms"].to_numpy() < params["move_v"]
# Group consecutive same-state (slow/moving) runs, respecting gap breaks.
state_change = np.zeros(len(out), dtype=bool)
state_change[1:] = (is_slow[1:] != is_slow[:-1]) | gap_break[1:]
run_id = np.cumsum(state_change)
# Confirm each slow run as a stop only if it dwells long enough within stop_r.
ts_num = out[ts_col].astype("int64").to_numpy() / 1e9 # seconds
x = out["x_m"].to_numpy()
y = out["y_m"].to_numpy()
is_stop = np.zeros(len(out), dtype=bool)
for rid in np.unique(run_id[is_slow]):
idx = np.flatnonzero((run_id == rid) & is_slow)
if idx.size == 0:
continue
duration = ts_num[idx[-1]] - ts_num[idx[0]]
radius = np.sqrt(
(x[idx] - x[idx].mean()) ** 2 + (y[idx] - y[idx].mean()) ** 2
).max()
if duration >= params["stop_d"] and radius <= params["stop_r"]:
is_stop[idx] = True
# ── Stage 4: assemble segment boundaries ──────────────────────────────────
# A boundary opens on: a gap break, or a transition between stop and move.
kind = np.where(is_stop, "stop", "move")
boundary = np.zeros(len(out), dtype=bool)
boundary[1:] = gap_break[1:] | (kind[1:] != kind[:-1])
out["segment_id"] = np.cumsum(boundary).astype(int)
out["segment_kind"] = kind
n_stop = int((kind == "stop").sum())
logger.info(
"segment_trajectory: %d points -> %d segments (%d stop points, mode=%s)",
len(out), out["segment_id"].nunique(), n_stop, mode,
)
return out
The function is deliberately conservative: a run of slow points is only promoted to a stop segment when it both dwells long enough (stop_d) and stays inside the dwell radius (stop_r). That double condition is what separates a genuine activity stop from a slow-crawl through congestion, and it is the single most important guard against the “over-segmentation from noise” failure mode in the taxonomy above.
Geometric & Mathematical Grounding
Dwell / stop definition. A stop is a spatiotemporal cluster: a maximal run of consecutive fixes P = {p_i, …, p_j} such that every member lies within a radius R of the run’s centroid and the elapsed time spans at least D. Formally, P is a stop when
max_k ‖p_k − c‖ ≤ R and t_j − t_i ≥ D, with c = mean(p_i … p_j).
The radius test uses Euclidean distance in the metric CRS, which is why projection must precede this step — the same 30 m radius is a fixed circle in UTM but a latitude-dependent ellipse in degrees. R encodes the receiver’s positional noise plus a tolerance for small in-place movement (a car idling, a pedestrian pacing); D encodes the minimum activity duration you care about. Together they define what “stationary” means for your analysis.
Speed-based change points. Between stops, mode and behaviour changes appear as change points in the speed signal. For a smoothed speed series v_t, a change point at index t is a location where the local mean shifts by more than a threshold Δ and stays shifted:
|mean(v_{t..t+w}) − mean(v_{t−w..t})| > Δ
evaluated over a window w of several epochs. Requiring the shift to persist over w — rather than reacting to a single spike — is the mathematical statement of the “sustained over 2–4 points” rule that suppresses mode-change flicker. Heading change is treated identically, with the wrap-around handled by comparing the absolute angular difference min(|Δθ|, 360 − |Δθ|).
Why gaps are not stops. A temporal gap carries no positional information about the missing interval, so no dwell can be measured across it. The pipeline therefore always breaks at a gap but never labels the break a stop unless the surrounding points independently pass the dwell test. This keeps the “unknown” state honest and prevents phantom activity locations from entering origin-destination flow matrices downstream.
Calibration & Parameter Tuning
The four thresholds interact, and the right values depend heavily on mode and sampling cadence. Start from the table below, then tune against a labelled subset until segment counts match ground truth.
| Parameter | Pedestrian | Cyclist | Passenger Vehicle | Notes |
|---|---|---|---|---|
gap_s (trip-split gap, s) |
180 | 90 | 45 | Longer for modes that pause often; shorter for continuous travel |
stop_r (dwell radius, m) |
20 | 25 | 30 | Roughly the receiver noise floor plus in-place movement |
stop_d (min dwell, s) |
120 | 90 | 60 | The shortest pause you want to count as an activity |
move_v (stationary speed, m/s) |
0.5 | 0.8 | 1.0 | Below this a point is a stop candidate |
Change-point window w (points) |
3–5 | 3–5 | 5–9 | Wider window = fewer, more confident splits |
| Heading Δ (degrees) | 45 | 40 | 30 | Sustained turn magnitude signalling a behaviour change |
Two calibration rules save the most grief. First, tie stop_r to the smoothed positional noise of your cleaned data, not to a textbook number — measure the 95th-percentile point-to-centroid distance of known stationary periods and use that. Second, when a dataset mixes modes, infer mode first from speed and acceleration profiling and run a separate segmentation pass per mode rather than forcing a single “unknown” profile, which will over-segment vehicle trips and under-segment pedestrian ones.
Integration & Compatibility
Segmentation sits between cleaning and aggregation, and its inputs and outputs are well defined:
- GPS Precision & Error Handling: the upstream producer. Segmentation consumes its cleaned, smoothed, metric-projected output. The
segment_idthis stage emits is a refinement of the coarse gap-basedsegment_idthat the cleaning pipeline already assigns. - Stay-Point Detection Algorithms: the natural companion. Stay-point detection supplies the activity locations; segmentation turns the moves between them into an ordered trip sequence. Running the two together is covered in the dedicated guide on combining trajectory segmentation with stay-point detection.
- Directionality & Turn Analysis: consumes clean move segments so that turn statistics are computed within a single leg rather than across a stop.
- Origin-Destination Flow Matrices: the primary downstream consumer. Each move segment’s first and last stop become an origin and a destination, so the quality of the OD matrix is bounded directly by segmentation accuracy.
- Trajectory Object Design Patterns: wrap the labelled frame so that each
segment_idmaps to a first-class sub-trajectory object with its own metrics.
FAQ
What is the difference between trajectory segmentation and stay-point detection?
Stay-point detection finds where a device dwells — the activity locations. Segmentation uses those stops, together with time gaps and movement changes, to cut the whole track into an ordered sequence of move and stop sub-trajectories. In practice you run stay-point detection first and then segment the moving portions between stays into trips.
How do I stop GPS noise from over-segmenting my tracks?
Clean the track first — DOP filtering, kinematic thresholding, and smoothing — so jitter cannot masquerade as a stop or heading change. Then require every stop to persist for a minimum dwell duration and every speed or heading change to be sustained across several consecutive fixes. A single-point excursion is noise; a change held across two to four points is real.
Should a long GPS gap always start a new segment?
Yes, but do not assume the gap was a stop. A gap means the movement state is unknown. Always break at gaps beyond the threshold so no phantom straight line is drawn across the missing interval, but tag the boundary as gap unless a dwell test on the surrounding points confirms the device was actually stationary.
What time-gap threshold should I use to split trips?
It depends on cadence and mode. For 1 Hz vehicle data a 30–60 second gap is a reasonable trip boundary; for pedestrian data logged every few seconds, 120–300 seconds better reflects genuine pauses. Calibrate against a labelled subset and always pair the gap rule with a dwell-based stop test.
Can I segment directly on raw latitude/longitude to save a projection step?
No. Dwell radius and speed are metric quantities; a fixed degree radius is a different physical distance at every latitude, so a stop test in degrees is inconsistent across your study area. Project to a UTM zone once at the start and compute every threshold in metres.
Related
- Stay-Point Detection Algorithms — the companion stage that supplies activity locations.
- Combining Trajectory Segmentation with Stay-Point Detection — the end-to-end trip/stop sequence pipeline.
- GPS Precision & Error Handling — the upstream cleaning stage segmentation depends on.
- Speed & Acceleration Profiling — infers mode so you can segment per profile.
- Origin-Destination Flow Matrices — the downstream consumer of segmented trips.