AI & Computer Vision, lesson 45 of 52

Speed & Distance Estimation

advanced28 min read

Introduction

TV shows say "she ran 10.4 km today, top speed 31 km/h". You have tracked positions in metres, so you can work that out too. But a shaky tracker adds tiny fake steps that pile up into kilometres that never happened. Today you'll measure distance and speed honestly, and prove your method against a tape measure.

By the end of this lesson you'll be able to:

  • Compute distance and speed from timestamped positions in metres
  • Explain why tracking jitter inflates distance and top speed
  • Choose and compare filters (smoothing, step length, dead zone) against known truth
  • Flag impossible speeds, and explain why the limit alone is not enough
  • Report the fastest second instead of a single-frame maximum
  • Give a result with an honest error, and think about sharing fitness data about children

Working principle

Positions to movement

On the flat pitch (lesson 33) every position is in metres. Distance is the length of the path; speed is distance divided by time. That sounds trivial, and it is, until you meet noise.

distance = sum of the lengths of the steps
speed    = length of a step / time of the step

Time from timestamps

Each position has a time t. Speed uses the difference in time, not "1 / fps". If the tracker loses a player for a second, the next step spans two seconds and the speed stays right. Counting frames would make a fake speed spike.

Noise adds path length

A tracker is never exact. Even a player standing perfectly still appears to wobble by 20 to 30 cm from frame to frame. Every wobble is a little "step", and tiny steps add up: ten minutes of standing gave 1,581 metres. The more often you sample, the more wobble you add.

The player is standing still, but the tracker wobbles. Distance so far: 0.0 m. Every wobble counts as a step, so the distance keeps growing.

Real movement does the opposite: it is bigger than the noise. So:

FixIdeaDanger
SmoothingAverage neighbouring positionsToo much cuts the corners and delays
Longer stepsCompare positions a second apart: noise is small next to a second of walkingCuts corners, misses quick turns
Dead zoneIgnore steps smaller than some distanceLoses slow walking if set too big

Impossible speeds

No human runs faster than about 12 m/s. If your tracker says 30 m/s, it swapped two players or jumped to the wrong object. Flag those and do not use them. But the limit does not protect you from jitter. Jitter produced speeds near 12 m/s that were just under the limit, so every sprint looked super-human.

Extreme values are the least reliable

The maximum is a special number: it is the largest of many noisy numbers, so it is nearly always too big. A single-frame top speed read about 12 m/s for players whose true top speed was about 8. Instead report the fastest second (how far the player got in one second). It is calmer, and in our test it was within 4.4% of the truth.

Speed zones are choices

Coaches group speed into zones such as walking, jogging, running and sprinting. The boundaries (here 2, 4 and 6 m/s) are our choice for this lesson. Different sources use different numbers. Say which you used. A "sprint" also needs a minimum length (here one second) or a quick jerk counts.

Where the errors come from

SourceSize in our test
Tracking jitterThe biggest: more than +100% if ignored
Filter bias (corner cutting)About -5%
Calibration clicks (lesson 33)About 1% for 5 px clicks
Lost tracks or id swaps (lesson 26)Can be huge; flagged by impossible speeds
Player in the air, or camera movedBreaks the flat-pitch assumption

Say how sure you are

A number without an error is half a number. "Player 7 ran about 1.3 km, give or take 6%, during the first ten minutes" is honest. "Player 7 ran 1,284.9 m" is false precision.

Fitness data about children

Distance and speed can look like a performance score. Be careful about comparing children, ranking them, or sharing the numbers beyond the player and their coach or family. Never use them to judge effort or talent. They are noisy estimates, and children develop at different speeds. See lesson 14 and lesson 43.

What you'll need

  • Tracked positions in metres (lessons 26 and 33), or the sample CSV
  • Your cv_course environment
  • A tape measure and some cones for the check run

Build it

Cell 1: pretend players who really run

Real tracking data has jitter, and we rarely know the true distance. So first we practise on pretend players whose true distance and top speed we know. They walk, jog, run and sprint between spots, sometimes stand still, and we add 0.25 m of tracking jitter. Ten minutes at 10 frames per second.

"""Pretend players who really RUN somewhere: they walk, jog or sprint between waypoints and sometimes stop.
We know the true distance each one ran, so we can check how good our method is. Tracking noise is added on top."""
import json
 
import numpy as np
 
rng = np.random.default_rng(11)
FPS, MINUTES, NOISE = 10, 10, 0.25                  # 10 frames per second, ten minutes, 0.25 m tracking jitter
W, H = 105.0, 68.0
SPEEDS = [0.0, 1.5, 3.0, 5.0, 7.5]                  # metres per second: stopped, walk, jog, run, sprint
WEIGHTS = [0.12, 0.30, 0.30, 0.20, 0.08]
 
 
def one_player(anchor):
    n = FPS * 60 * MINUTES
    pos = np.array(anchor, float)
    path = [pos.copy()]
    while len(path) < n:
        target = np.clip(np.array(anchor) + rng.normal(0, [14, 9]), [1, 1], [W - 1, H - 1])
        speed = rng.choice(SPEEDS, p=WEIGHTS)
        if speed == 0.0:
            for _ in range(int(rng.integers(FPS * 2, FPS * 8))):
                path.append(pos.copy())
            continue
        dist = np.linalg.norm(target - pos)
        steps = max(1, int(dist / speed * FPS))
        for k in range(1, steps + 1):
            path.append(pos + (target - pos) * k / steps)
        pos = target
    return np.array(path[:n])
 
 
anchors = {1: (10, 34), 2: (30, 20), 3: (30, 48), 4: (55, 34), 5: (60, 15), 6: (60, 53), 7: (80, 30), 8: (85, 40)}
truth = {}
true_top = {}
tracks = {}
for pid, a in anchors.items():
    p = one_player(a)
    truth[pid] = round(float(np.sum(np.linalg.norm(np.diff(p, axis=0), axis=1))), 1)
    one_sec = np.linalg.norm(p[FPS:] - p[:-FPS], axis=1)             # metres covered in each 1 second window = speed in m/s
    true_top[pid] = round(float(one_sec.max()), 2)
    tracks[pid] = p + rng.normal(0, NOISE, p.shape)
n = FPS * 60 * MINUTES
frames = [{"frame": i, "t": round(i / FPS, 2),
           "players": [{"id": pid, "x": round(float(tracks[pid][i, 0]), 2), "y": round(float(tracks[pid][i, 1]), 2)} for pid in anchors],
           "ball": None} for i in range(n)]
json.dump({"units": "metres", "fps": FPS, "frames": frames}, open("positions_runs.json", "w"))
json.dump(truth, open("true_distance.json", "w"))
json.dump(true_top, open("true_top_speed.json", "w"))
print("true distances (m):", truth)
print("true top speeds over 1 s (m/s):", true_top)

Cell 2: the maths

Positions in metres (lesson 33). Time comes from the timestamps, not from counting frames, so a missing frame does not make a fake speed.

"""Speed and distance from positions in metres (lesson 33). Time comes from timestamps, so missing frames are handled."""
import math
 
import numpy as np
 
MAX_HUMAN_SPEED = 12.0        # m/s. Even the fastest sprinters peak near this: anything above is a tracking error, not a runner
ZONES = [("standing/walking", 0.0, 2.0), ("jogging", 2.0, 4.0), ("running", 4.0, 6.0), ("sprinting", 6.0, 99.0)]   # m/s: OUR choice
 
 
def track_of(frames, player_id):
    """All (t, x, y) for one player, in time order."""
    return [(fr["t"], p["x"], p["y"]) for fr in frames for p in fr["players"] if p["id"] == player_id]
 
 
def smooth_positions(track, window=5):
    """Moving average of x and y (window in samples, odd). Jitter shrinks; real movement stays."""
    if len(track) < window:
        return list(track)
    t = np.array([r[0] for r in track]); xy = np.array([[r[1], r[2]] for r in track], float)
    k = np.ones(window) / window
    pad = window // 2
    padded = np.pad(xy, ((pad, pad), (0, 0)), mode="edge")
    sm = np.column_stack([np.convolve(padded[:, c], k, mode="valid") for c in (0, 1)])
    return [(float(t[i]), float(sm[i, 0]), float(sm[i, 1])) for i in range(len(track))]
 
 
def steps(track):
    """(dt, metres) between neighbouring samples."""
    out = []
    for (t0, x0, y0), (t1, x1, y1) in zip(track, track[1:]):
        dt = t1 - t0
        if dt > 0:
            out.append((dt, math.hypot(x1 - x0, y1 - y0)))
    return out
 
 
def speeds(track):
    return [d / dt for dt, d in steps(track)]
 
 
def distance(track, dead_zone=0.0):
    """Total path length. dead_zone: steps shorter than this (metres) are treated as standing still."""
    return sum(d for dt, d in steps(track) if d >= dead_zone)
 
 
def clean_speeds(track, limit=MAX_HUMAN_SPEED):
    """Speeds with impossible ones removed. Returns (speeds, how many were removed)."""
    s = speeds(track)
    ok = [v for v in s if v <= limit]
    return ok, len(s) - len(ok)
 
 
def zone_times(track, limit=MAX_HUMAN_SPEED):
    """Seconds in each speed zone (impossible speeds skipped)."""
    out = {name: 0.0 for name, _, _ in ZONES}
    for dt, d in steps(track):
        v = d / dt
        if v > limit:
            continue
        for name, lo, hi in ZONES:
            if lo <= v < hi:
                out[name] += dt
                break
    return out
 
 
def top_speed(track, window_s=1.0):
    """The fastest speed held over `window_s` seconds: how far the player got in that time, divided by the time.
    A single-frame maximum is mostly noise (tiny time, tiny error, huge speed). Averaging over a second tames it."""
    best, j = 0.0, 0
    for i in range(len(track)):
        while j < len(track) and track[j][0] - track[i][0] < window_s - 1e-9:
            j += 1
        if j >= len(track):
            break
        d = math.hypot(track[j][1] - track[i][1], track[j][2] - track[i][2])
        v = d / (track[j][0] - track[i][0])
        if v <= MAX_HUMAN_SPEED:
            best = max(best, v)
    return best
 
 
def count_sprints(track, min_seconds=1.0, threshold=6.0, limit=MAX_HUMAN_SPEED):
    """A sprint = speed above `threshold` m/s for at least `min_seconds` in a row."""
    run, n = 0.0, 0
    for dt, d in steps(track):
        v = d / dt
        if threshold <= v <= limit:
            run += dt
        else:
            if run >= min_seconds:
                n += 1
            run = 0.0
    return n + (1 if run >= min_seconds else 0)
 
 
def downsample(track, every=1.0):
    """Keep one position per `every` seconds. Jitter of 0.25 m matters little over a 1 s step of a walker (1.5 m)."""
    if not track:
        return []
    out = [track[0]]
    for r in track[1:]:
        if r[0] - out[-1][0] >= every - 1e-9:
            out.append(r)
    return out
 
 
def summary(track, window=9, every=1.0, dead_zone=0.5):
    """Distance: smooth, then look at the path once a second, and ignore steps under 0.5 m (standing).
    Speeds and zones: from the smoothed full-rate track. Settings measured against known true distances in the lesson."""
    sm = smooth_positions(track, window)
    s, bad = clean_speeds(sm)
    return {"distance_m": round(distance(downsample(sm, every), dead_zone), 1), "top_speed_ms": round(top_speed(sm), 2),
            "impossible_speeds": bad, "sprints": count_sprints(sm), "zones_s": {k: round(v, 1) for k, v in zone_times(sm).items()}}

Cell 3: tests

import numpy as np
from kinematics import *
 
bad = 0
def check(name, ok, extra=""):
    global bad; bad += not ok; print("PASS" if ok else "FAIL", name, extra)
 
fps = 5
# runs 5 m/s in a straight line for 20 s
line = [(i / fps, 5.0 * i / fps, 10.0) for i in range(20 * fps + 1)]
check("straight run: distance 100 m", abs(distance(line) - 100) < 1e-6, distance(line))
check("straight run: speed 5 m/s everywhere", all(abs(v - 5) < 1e-9 for v in speeds(line)))
check("smoothing keeps a straight run's distance (about 100 m)", abs(distance(smooth_positions(line)) - 100) < 1.5, distance(smooth_positions(line)))
 
# STANDING STILL with tracking noise of about 0.3 m
rng = np.random.default_rng(0)
still = [(i / fps, 50 + rng.normal(0, 0.3), 30 + rng.normal(0, 0.3)) for i in range(10 * 60 * fps)]
raw = distance(still); sm = distance(smooth_positions(still, 5), dead_zone=0.25)
print(f"   standing still for 10 minutes: raw distance {raw:.0f} m, smoothed with dead zone {sm:.0f} m")
check("noise makes a motionless player 'run' hundreds of metres", raw > 500, f"{raw:.0f} m")
check("smoothing plus dead zone cuts it to a small fraction", sm < raw * 0.15, f"{sm:.0f} m")
 
# a real run is NOT destroyed by the dead zone: 5 m/s at 5 fps is 1 m per step
check("a real run is unaffected by a 0.25 m dead zone", abs(distance(line, dead_zone=0.25) - 100) < 1e-6)
# but a very slow walk (0.5 m/s = 0.1 m per step) IS lost: an honest limit
walk = [(i / fps, 0.5 * i / fps, 0.0) for i in range(10 * fps + 1)]
check("a very slow walk (0.1 m steps) is lost by a 0.25 m dead zone: a known trade-off", distance(walk, dead_zone=0.25) == 0.0)
 
# teleport glitch (tracker swaps ids): one 30 m jump
glitch = list(line[:20]) + [(t, x + 30, y) for t, x, y in line[20:40]] + list(line[40:])
s, removed = clean_speeds(glitch)
check("an impossible jump is flagged (2 steps are impossible: the jump out and back)", removed == 2, removed)
check("top speed ignores the glitch", max(s) < 6, max(s))
check("raw top speed would be absurd", max(speeds(glitch)) > 100, max(speeds(glitch)))
 
# time comes from timestamps: a missing second still gives the right speed
gap = [r for r in line if not (4.0 < r[0] < 5.0)]
check("missing frames do not create fake speed (5 m/s over the gap)", all(abs(v - 5) < 1e-6 for v in speeds(gap)))
 
# zones and sprints
spr = [(i / fps, 7.0 * i / fps, 0.0) for i in range(3 * fps + 1)]          # 3 s at 7 m/s
check("zone times: 3 s sprinting", abs(zone_times(spr)["sprinting"] - 3.0) < 1e-9, zone_times(spr))
check("one sprint counted", count_sprints(spr) == 1)
two = spr + [(3 + i / fps, 21 + 1.0 * i / fps, 0.0) for i in range(1, 5 * fps)] + [(8 + i / fps, 26 + 7.0 * i / fps, 0.0) for i in range(1, 2 * fps + 1)]
check("two separated sprints counted as two", count_sprints(two) == 2, count_sprints(two))
check("a 0.4 s burst is not a sprint", count_sprints([(i / fps, 7.0 * i / fps, 0.0) for i in range(3)]) == 0)
 
import json
d = json.load(open("positions_runs.json")); frames = d["frames"]
truth = {int(k): v for k, v in json.load(open("true_distance.json")).items()}
errs = {}
for pid in truth:
    t = track_of(frames, pid)
    errs[pid] = summary(t)["distance_m"] / truth[pid] - 1
print("   error against the known true distance:", {k: f"{v:+.1%}" for k, v in errs.items()})
check("all 8 players within 10 percent of the true distance", all(abs(e) < 0.10 for e in errs.values()), max(abs(e) for e in errs.values()))
true_top = {int(k): v for k, v in json.load(open("true_top_speed.json")).items()}
from kinematics import top_speed
top_err = {pid: top_speed(smooth_positions(track_of(frames, pid), 9)) / true_top[pid] - 1 for pid in true_top}
print("   top speed error (1 s window):", {k: f"{v:+.1%}" for k, v in top_err.items()})
check("top speed over a 1 s window is within 6 percent for all players", all(abs(e) < 0.06 for e in top_err.values()), max(abs(e) for e in top_err.values()))
raw_top = [max(speeds(track_of(frames, pid))) for pid in true_top]
check("a raw single-step maximum is far too high (over 1.4 times the truth) even though no 'impossible' limit trips", min(raw_top) > 1.4 * max(true_top.values()), f"{min(raw_top):.1f} m/s vs true about 8")
check("top_speed of a straight 5 m/s run is 5", abs(top_speed(line) - 5) < 1e-9)
check("top_speed ignores an impossible jump", top_speed(glitch) < 6, top_speed(glitch))
raw_errs = [distance(track_of(frames, pid)) / truth[pid] - 1 for pid in truth]
check("the raw, unfiltered distance is more than double the truth", min(raw_errs) > 1.0, f"{min(raw_errs):+.0%}")
dz_errs = [distance(smooth_positions(track_of(frames, pid), 9), 0.25) / truth[pid] - 1 for pid in truth]
check("a 0.25 m dead zone at 10 fps throws away slow walking: more than 30 percent too SHORT", max(dz_errs) < -0.30, f"{max(dz_errs):+.0%}")
rng2 = np.random.default_rng(0)
still10 = [(i / 10, 50 + rng2.normal(0, 0.25), 30 + rng2.normal(0, 0.25)) for i in range(6000)]
check("standing still for 10 minutes: summary says 0 m", summary(still10)["distance_m"] == 0.0, summary(still10)["distance_m"])
check("downsample keeps about one point per second", abs(len(downsample(line[:51], 1.0)) - 11) <= 1, len(downsample(line[:51], 1.0)))
check("downsample of nothing is nothing", downsample([]) == [])
print("failures:", bad)

All 25 should print PASS. Read the printed lines too:

   standing still for 10 minutes: raw distance 1581 m, smoothed with dead zone 10 m
   error against the known true distance: 1: -6.2%, 2: -5.2%, 3: -6.5%, 4: -5.9%, 5: -4.3%, 6: -3.7%, 7: -5.6%, 8: -5.2%
   top speed error (1 s window): 1: -1.3%, 2: +2.1%, 3: +1.4%, 4: -1.0%, 5: +4.4%, 6: +2.2%, 7: -2.0%, 8: +0.8%

Cell 4: the report

import json
 
from kinematics import summary, track_of
 
d = json.load(open("positions_runs.json"))
truth = {int(k): v for k, v in json.load(open("true_distance.json")).items()}
frames = d["frames"]
ids = sorted({p["id"] for fr in frames for p in fr["players"]})
print(f"{'player':>6} {'true m':>7} {'ours m':>7} {'error':>7} {'top m/s':>8} {'sprints':>8} {'jog s':>6} {'run s':>6} {'sprint s':>9} {'bad':>4}")
for pid in ids:
    s = summary(track_of(frames, pid))
    z = s["zones_s"]
    err = s["distance_m"] / truth[pid] - 1
    print(f"{pid:>6} {truth[pid]:>7} {s['distance_m']:>7} {err:>+7.1%} {s['top_speed_ms']:>8} {s['sprints']:>8} {z['jogging']:>6} {z['running']:>6} {z['sprinting']:>9} {s['impossible_speeds']:>4}")
player  true m  ours m   error  top m/s  sprints  jog s  run s  sprint s  bad
     1  1307.6  1225.9   -6.2%     7.81        5  201.4   41.5      10.7    0
     2  1345.2  1275.6   -5.2%     7.97        5  196.2   49.5      11.4    0
     3  1428.4  1335.4   -6.5%     7.94        4  193.1   71.9      10.0    0
     4  1395.5  1313.3   -5.9%     7.94        9  172.6   35.3      32.7    0
     5  1387.8  1328.5   -4.3%     8.04        3  197.1   77.1       8.1    0
     6  1114.4  1073.7   -3.7%     7.84        2  165.7   13.9       6.6    0
     7  1361.5  1284.9   -5.6%      7.9        7  194.1   37.5      19.3    0
     8  1336.6  1267.0   -5.2%     7.94        3  207.7   57.3       7.1    0

Every player is within about 6% on distance, and the top speed (about 8 m/s, which is right) is within 4.4%. The distance is a bit short (about 5%) because smoothing and one-second steps cut off the corners of turns. That is a bias: we know its direction, and we can say so.

Cell 5: what the jitter does, step by step

With the 10 fps demo players, compare ways of measuring distance. Mean error across the 8 players, against the known truth:

MethodMean errorA player standing still for 10 min
Raw positions, every frame+127%2,644 m
Smooth over 9 frames (0.9 s)+0%294 m
Smooth, then ignore steps under 0.25 m-43%0 m
Smooth, look once a second-5%87 m
Smooth, once a second, ignore under 0.5 m (ours)-5%0 m

Run it yourself. Edit METHODS to try your own settings.

"""Try ways of measuring distance and compare with the KNOWN truth. Edit METHODS and run again."""
import json
 
import numpy as np
 
from kinematics import distance, downsample, smooth_positions, track_of
 
frames = json.load(open("positions_runs.json"))["frames"]
truth = {int(k): v for k, v in json.load(open("true_distance.json")).items()}
 
rng = np.random.default_rng(0)
still = [(i / 10, 50 + rng.normal(0, 0.25), 30 + rng.normal(0, 0.25)) for i in range(6000)]   # 10 minutes standing, 10 fps
 
# (name, smoothing window in frames, seconds between positions (0 = every frame), dead zone in metres)
METHODS = [("raw, every frame", 1, 0, 0.0),
           ("smooth over 9 frames", 9, 0, 0.0),
           ("smooth, ignore steps under 0.25 m", 9, 0, 0.25),
           ("smooth, look once a second", 9, 1.0, 0.0),
           ("smooth, once a second, ignore under 0.5 m", 9, 1.0, 0.5)]
 
 
def measure(track, window, every, dead_zone):
    t = smooth_positions(track, window) if window > 1 else track
    t = downsample(t, every) if every else t
    return distance(t, dead_zone)
 
 
print(f"{'method':<44}{'mean error':>11}{'standing 10 min':>17}")
for name, w, every, dz in METHODS:
    errors = [measure(track_of(frames, p), w, every, dz) / truth[p] - 1 for p in truth]
    print(f"{name:<44}{np.mean(errors):>+11.0%}{measure(still, w, every, dz):>14.0f} m")
method                                       mean error  standing 10 min
raw, every frame                                  +127%          2644 m
smooth over 9 frames                                +0%           294 m
smooth, ignore steps under 0.25 m                  -43%             0 m
smooth, look once a second                          -5%            87 m
smooth, once a second, ignore under 0.5 m           -5%             0 m

Three lessons. Raw is more than double the truth: noise adds path length. A dead zone chosen without thinking (0.25 m at 10 frames per second) kills slow walking: a walk at 1.5 m/s moves only 0.15 m per frame, so nearly all of it is thrown away. And one trick is not enough: only the combination of smoothing, a one-second baseline and a dead zone gets both the movers and the standers right. These numbers belong to this noise level and frame rate. When yours differ, repeat the experiment.

Cell 6: how wrong is the calibration?

The other error source is the map from lesson 33. We move each calibration click by a random amount and measure the same 70 m run again.

"""How wrong is a distance when the calibration clicks are a little wrong? (uses pitch_map.py from lesson 33)"""
import numpy as np
 
from pitch_map import PITCH_H, PITCH_W, make_homography, to_pitch_many
 
pitch = [(0, 0), (PITCH_W, 0), (PITCH_W, PITCH_H), (0, PITCH_H)]
image = [(420, 150), (860, 150), (1180, 620), (100, 620)]          # the example camera from lesson 33
H_true = make_homography(image, pitch)
H_inv = np.linalg.inv(H_true)
 
 
def apparent_distance(click_noise_px, true_path_m, trials=300, seed=0):
    """A player really runs along true_path_m (metres). We calibrate with noisy clicks and measure again."""
    rng = np.random.default_rng(seed)
    px = to_pitch_many(H_inv, true_path_m)                          # where the path appears in the picture
    true_len = float(np.sum(np.linalg.norm(np.diff(true_path_m, axis=0), axis=1)))
    errors = []
    for _ in range(trials):
        noisy = np.array(image, float) + rng.normal(0, click_noise_px, (4, 2))
        H = make_homography(noisy, pitch)
        m = to_pitch_many(H, px)
        errors.append(float(np.sum(np.linalg.norm(np.diff(m, axis=0), axis=1))) / true_len - 1.0)
    return np.array(errors)
 
 
if __name__ == "__main__":
    # a 40 m run down the middle of the pitch, then across: 40 + 30 m
    run = np.array([[30 + i, 34] for i in range(0, 41)] + [[70, 34 - i * 1.0] for i in range(1, 31)], float)
    print("click noise (px) | typical distance error | worst of 300 trials")
    for noise in (1, 3, 5, 10):
        e = apparent_distance(noise, run)
        print(f"{noise:>16} | {np.mean(np.abs(e)):>21.1%} | {np.max(np.abs(e)):>8.1%}")
click noise (px) | typical distance error | worst of 300 trials
               1 |                  0.3% |     1.0%
               3 |                  0.8% |     2.8%
               5 |                  1.3% |     4.6%
              10 |                  2.6% |     9.6%

Five pixels of clicking error cost about 1% of distance on average (a bad case, about 5%). That is small compared with jitter. So spend your effort on the jitter first, and on a careful calibration second.

Questions: why is the single biggest speed in a match the least reliable number? What would you report to a coach: top speed, or the fastest second? Why does the report say "bad" next to each player? What happens to the distance of a player the tracker loses for a minute?

If it goes wrong

ErrorCauseFix
Standing player has hundreds of metresJitter, not smoothedSmooth, use a longer step, add a small dead zone
Everyone's distance is too shortDead zone too large for the frame rate, or smoothing too heavyReduce the dead zone; compare with the truth
Top speed is 12 m/s or moreSingle-frame maximum on noisy dataUse the fastest second (top_speed)
Speeds of 50 m/sId swap or jumpFlag impossible speeds; fix the tracks (lesson 26)
Different results at different frame ratesJitter adds more at high fpsRe-tune window and dead zone for your frame rate
Sprints counted when someone just twitchedNo minimum durationRequire at least one second above the threshold
Speeds look wrong near the pitch edgeCalibration is worst at the edges (lesson 33)Recalibrate with more points
Distance is right but speeds are wrongThe smoothing helps distance but blurs peaksChoose settings per measure, and say which

Try it

  • Add an acceleration measure and its error
  • Auto-tune the window and dead zone by searching against the truth
  • Do the calibration run with a real 40 m course

Check what you learnt

  1. Why is the raw distance too long?

  2. Why not use the biggest single-frame speed as top speed?

  3. What's the fastest a human can run, roughly?

  4. What does a large dead zone cost you?

  5. How can you check your method without TV data?

Conclusion

Distance and speed come from positions in metres and timestamps, but tracker jitter adds fake distance. Smooth the track, ignore wobbles below a dead zone, throw out impossible speeds (over about 12 m/s), report the fastest second, not the fastest frame, and test your method on a measured course.

What next?

Next: Formation & tactical analysis. You'll measure the shape of a whole team.