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.

Trajectory Segmentation Pipeline Overview A cleaned continuous track flows left to right through four stages: Gap Split on temporal gaps, Stop Detection by dwell radius and duration, Movement-Change Split on sustained speed or heading change, and Label & Emit which produces an ordered sequence of labelled move and stop segments. Below the flow, a legend shows move segments as solid boxes and stop segments as dashed boxes. Trajectory Segmentation Pipeline one cleaned track in, an ordered sequence of labelled sub-trajectories out Clean Track metric CRS, ordered by time Gap Split break on time gap > T Stop Detection dwell radius R for duration D Movement-Change Split speed / heading Label & Emit segment_id + move / stop tag resulting ordered segment sequence move · trip stop move · trip stop move · trip move segment (travel) stop segment (dwell)

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.0 and numpy ≥ 1.24 for vectorized column operations.
  • pyproj ≥ 3.6 for the WGS84 → metric transformation, imported with always_xy=True to avoid axis-order bugs.
  • Input schema: each row needs lat (float64), lon (float64), and ts (UTC datetime64), sorted per device. A per-device device_id is 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:4326 degrees.
  • 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.

  1. Project to a metric CRS. Transform lat/lon to a UTM zone, producing x_m/y_m in metres. Every subsequent distance, dwell radius, and speed is computed here — never on degrees.
  2. 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.
  3. Detect stops by dwell. Slide over the points and flag any run that stays within a radius R of its own start for at least a duration D. These runs become stop segments; the movement between them becomes candidate trips. This is the same dwell logic used by stay-point detection algorithms.
  4. 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.
  5. Label and emit. Assign a monotonically increasing segment_id, tag each segment as move or stop, 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.

PYTHON
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_id this stage emits is a refinement of the coarse gap-based segment_id that 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_id maps 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.

Back to Movement Pattern Extraction & Trajectory Analysis